Solidity Patterns
You know the language. Now learn the craft. Inheritance, interfaces, access control, proxy upgrades, gas optimization — the patterns that separate smart contract beginners from professionals.
Recap: From Fundamentals to Patterns
In Module 7, you learned Solidity's core building blocks — types, functions, modifiers, events, and error handling. You built a TodoList contract that worked, but had real limitations. Now it's time to level up: from writing code that works to writing code that is production-ready.
What You Built
A TodoList with basic access control (onlyOwner), persistent storage (structs + mappings), and events for off-chain tracking. It worked — but it wasn't production-ready.
What's Missing
No inheritance hierarchy, no standard interfaces, no upgradeability, no role management beyond a single owner, and no gas optimization. These are the gaps that separate a learning exercise from a deployable contract.
Why Patterns?
OpenZeppelin provides battle-tested, audited implementations of common patterns. Don't reinvent the wheel — extend it. 90%+ of production contracts use OpenZeppelin. Import from @openzeppelin/contracts and build on top of proven, secure code.
Inheritance & Polymorphism
Solidity supports multiple inheritance. Contracts can inherit state variables, functions, modifiers, and events from one or more parent contracts. This is the foundation for code reuse in the Solidity ecosystem.
The is Keyword
contract Child is Parent { ... } — the child contract inherits all public and internal members from Parent. You can chain inheritance: contract C is A, B { ... }.
virtual and override
A parent marks functions as virtual to allow children to replace them. The child uses override to provide a new implementation. Both keywords are required since Solidity 0.6.0 — the compiler enforces explicit intent.
The super Keyword
super.functionName() calls the parent's implementation. Useful when you want to extend behaviour rather than replace it entirely. In a multiple inheritance chain, super follows the C3 linearization order.
Multiple Inheritance & Linearization
When inheriting from multiple contracts, Solidity uses C3 linearization to determine the order of precedence. The order in the is clause matters: contract C is A, B means B overrides A for conflicting functions.
Constructor Arguments
Pass arguments to parent constructors directly: contract Child is Parent(42) { ... } or in the child's constructor: constructor() Parent(42) { ... }. Both are valid; the first is cleaner for constants.
Inheritance Comparison
| Pattern | Syntax | Use Case |
|---|---|---|
| Single inheritance | contract B is A |
Extend a base contract with additional functionality. Simplest form. |
| Multiple inheritance | contract C is A, B |
Combine features from multiple parents (e.g., Ownable + Pausable). Order matters. |
| Interface implementation | contract T is IERC20 |
Guarantee your contract follows a standard. Compiler enforces all functions are implemented. |
The Diamond Problem
Solidity uses C3 linearization to resolve the diamond problem. When inheriting from multiple contracts that share a common ancestor, the order matters. The rightmost parent wins for conflicting functions. Always list the most general parent first, the most specific last.
Code: Inheritance Chain
An Animal → Dog → GuideDog chain demonstrating virtual, override, and super. Each level extends or replaces the parent's speak() function while optionally calling the parent implementation.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Animal {
function speak() public pure virtual returns (string memory) {
return "...";
}
}
contract Dog is Animal {
function speak() public pure virtual override returns (string memory) {
return "Woof!";
}
}
contract GuideDog is Dog {
function speak() public pure override returns (string memory) {
// Calls Dog.speak() via super
return string.concat(super.speak(), " (trained)");
}
}
Interfaces & Abstract Contracts
Interfaces define what a contract must do without specifying how. Abstract contracts provide partial implementations — some functions are complete, others are left for children to implement.
Interfaces
An interface has strict rules: no state variables, no constructor, all functions must be external, and no function can have an implementation. Interfaces define a contract (in the legal sense) — a promise of what functions exist.
Abstract Contracts
An abstract contract can have some implemented functions and some unimplemented ones (marked virtual without a body). You cannot deploy an abstract contract directly — a child must implement all missing functions.
When to Use Each
Use an interface to define a standard (like ERC-20). Use an abstract contract when you want to share base logic across multiple children (like a base Token contract with common accounting functions).
Comparison
| Feature | Interface | Abstract Contract | Regular Contract |
|---|---|---|---|
| Has state variables? | ❌ No | ✅ Yes | ✅ Yes |
| Has constructor? | ❌ No | ✅ Yes | ✅ Yes |
| Can be deployed? | ❌ No | ❌ No | ✅ Yes |
| Can have implemented functions? | ❌ No | ✅ Yes (partial) | ✅ Yes (all) |
ERC Standards
ERC-20 (fungible tokens), ERC-721 (NFTs), ERC-1155 (multi-token). All defined as interfaces. They are the backbone of the token economy. In Module 9, you'll implement a full ERC-20 token using these patterns.
Code: Interface + Abstract Contract
A simplified IERC20 interface (totalSupply, balanceOf, transfer) alongside an abstract contract Token that implements balanceOf and totalSupply but leaves transfer for children to define.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ── Interface: defines WHAT ──
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
// ── Abstract: partial HOW ──
abstract contract Token is IERC20 {
mapping(address => uint256) internal _balances;
uint256 internal _totalSupply;
function totalSupply() external view override returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) external view override returns (uint256) {
return _balances[account];
}
// transfer(), approve(), allowance(), transferFrom()
// are NOT implemented — children must do it
}
Access Control Patterns
Module 7's onlyOwner modifier was simple but limited: one address controls everything. Real production contracts need multiple roles with granular permissions.
Ownable
The single-owner pattern from OpenZeppelin. Simple and effective: one address controls everything. Provides onlyOwner modifier, transferOwnership(), and renounceOwnership(). Best for small contracts where a single admin is sufficient.
AccessControl
Role-based access from OpenZeppelin: ADMIN_ROLE, MINTER_ROLE, PAUSER_ROLE — define as many roles as you need. Each role has an admin role that can grant or revoke it. Scalable for complex systems with multiple actors.
When to Use What
Use Ownable for simple contracts with a single administrator. Use AccessControl for multi-role systems — tokens with minters and pausers, DAOs with proposal and execution roles, marketplaces with listing and moderation roles.
OpenZeppelin's Pattern
import "@openzeppelin/contracts/access/AccessControl.sol"; — Roles are bytes32 constants: bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");. Grant with _grantRole(), check with hasRole(), restrict with onlyRole(MINTER_ROLE).
Code: AccessControl in Action
A contract inheriting AccessControl with two roles (ADMIN_ROLE, MINTER_ROLE). The constructor grants both roles to the deployer. A mint() function restricted to onlyRole(MINTER_ROLE) demonstrates granular access.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract MyToken is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
uint256 public totalMinted;
constructor() {
// Deployer gets both roles
_grantRole(ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
totalMinted += amount;
// ... mint logic
}
}
Libraries & Gas Optimization
Two ways to write more efficient contracts: reuse code with libraries, and minimize storage costs with gas optimization techniques. Together, they make your contracts cheaper to deploy and cheaper to use.
Libraries
The library keyword defines stateless utility functions. Use using X for Y; to attach library functions to a type. Example: using Strings for uint256; lets you write myNumber.toString(). OpenZeppelin provides libraries like Strings, Address, SafeCast, and Math.
Storage Packing
Variables smaller than 256 bits can be packed into the same storage slot. uint128 a; uint128 b; = 1 slot (20,000 gas). uint256 a; uint256 b; = 2 slots (40,000 gas). Order your variables by size to maximize packing.
constant & immutable
constant: value known at compile time, inlined into bytecode — costs 0 gas to read. immutable: set once in the constructor, also 0 gas to read at runtime. Compare with regular storage variables: 2,100 gas per SLOAD.
Short-Circuit & Caching
Cache storage reads in memory: uint256 cached = storageVar; then use cached 10 times = 1 SLOAD instead of 10 SLOADs. Also: && short-circuits — if the first condition is false, the second is never evaluated.
Events vs Storage
If data is only needed off-chain, emit events (~375 gas) instead of writing to storage (~20,000 gas). That's 50x cheaper. Use events for history, audit trails, and analytics — reserve storage for data your contract logic actually reads.
The 80/20 Rule
80% of gas costs come from SSTORE (storage writes). Focus your optimization on storage writes first, everything else second. One eliminated SSTORE saves more gas than dozens of other micro-optimizations combined.
Code: Library + Packed Struct
A library MathUtils with a clamp function, attached via using. Plus a packed struct example showing how reordering fields from uint256, uint8, uint256, uint8 (4 slots) to uint256, uint256, uint8, uint8 (3 slots) saves 20,000 gas.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ── Library: stateless utility ──
library MathUtils {
function clamp(uint256 value, uint256 min, uint256 max)
internal pure returns (uint256)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
}
contract PackedExample {
using MathUtils for uint256;
// BAD: 4 storage slots
// uint256 id; // slot 0
// uint8 category; // slot 1
// uint256 amount; // slot 2
// uint8 priority; // slot 3
// GOOD: 3 storage slots (packed)
uint256 public id; // slot 0
uint256 public amount; // slot 1
uint8 public category; // slot 2 (packed)
uint8 public priority; // slot 2 (packed)
function setAmount(uint256 _amount) external {
// clamp via library: min 1, max 10000
amount = _amount.clamp(1, 10000);
}
}
Upgradeable Contracts: The Proxy Pattern
Smart contracts are immutable — once deployed, their code can never change. But bugs happen, and requirements evolve. The proxy pattern solves this without breaking immutability.
The Problem
Deployed code can't change. A bug in a contract holding $100M means those funds could be at risk permanently. You can't patch a smart contract like you'd patch a server.
The Solution: Separate Storage from Logic
Users talk to a Proxy contract (which holds storage). The Proxy forwards every call to an Implementation contract (which holds logic). To upgrade, you deploy a new Implementation and point the Proxy to it. Storage is preserved.
delegatecall
The key EVM opcode: delegatecall executes the Implementation's code in the Proxy's storage context. The Implementation reads and writes the Proxy's state variables, not its own. This is what makes the entire pattern possible.
Storage Collision Risk
The Proxy and Implementation must share the same storage layout. If the Implementation expects uint256 balance at slot 0 but the Proxy has address admin at slot 0, you get corrupted data.
Proxy Architecture
Transparent Proxy
Admin calls go to proxy logic (upgrade functions), user calls go to the implementation. The proxy checks msg.sender: if it's the admin, execute proxy functions; otherwise, delegatecall to implementation. OpenZeppelin's TransparentUpgradeableProxy.
UUPS (Universal Upgradeable Proxy Standard)
The upgrade function lives in the implementation, not the proxy. The proxy is lighter and cheaper to deploy. Recommended by OpenZeppelin since v4. Uses UUPSUpgradeable base contract with _authorizeUpgrade() hook.
Warning: Storage Layout
When upgrading, NEVER change the order of existing state variables. Only append new ones at the end. Changing order = storage collision = corrupted data. This is the #1 upgrade bug. Tools like OpenZeppelin Upgrades Plugins check this automatically.
Code: Proxy Concept
A simplified proxy illustration (not production-ready). Shows the delegatecall pattern and how the implementation address is stored. For real contracts, always use OpenZeppelin's proxy implementations.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ── Simplified Proxy (educational only) ──
contract SimpleProxy {
// Storage slot for implementation address (EIP-1967)
bytes32 private constant IMPL_SLOT =
keccak256("eip1967.proxy.implementation");
constructor(address _impl) {
assembly {
sstore(IMPL_SLOT, _impl)
}
}
// Fallback: forward ALL calls to implementation
fallback() external payable {
assembly {
let impl := sload(IMPL_SLOT)
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(
gas(), impl, 0, calldatasize(), 0, 0
)
returndatacopy(0, 0, returndatasize())
if iszero(ok) { revert(0, returndatasize()) }
return(0, returndatasize())
}
}
}
Your Second Real Contract: Multi-Role Vault
This contract combines everything from this module: inheritance (AccessControl), role-based access, events, custom errors, immutable variables, and gas-optimized history via events instead of storage.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
// ── Custom Errors ──
error Unauthorized();
error InsufficientBalance(uint256 requested, uint256 available);
contract Vault is AccessControl {
// ── Roles ──
bytes32 public constant DEPOSITOR_ROLE =
keccak256("DEPOSITOR_ROLE");
bytes32 public constant WITHDRAWER_ROLE =
keccak256("WITHDRAWER_ROLE");
// ── Immutable (0 gas to read) ──
uint256 public immutable deployedAt;
// ── Events ──
event Deposited(address indexed who, uint256 amount);
event Withdrawn(address indexed who, uint256 amount);
// ── Constructor ──
constructor() {
deployedAt = block.timestamp;
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(DEPOSITOR_ROLE, msg.sender);
_grantRole(WITHDRAWER_ROLE, msg.sender);
}
// ── Deposit (payable, DEPOSITOR only) ──
function deposit() external payable onlyRole(DEPOSITOR_ROLE) {
require(msg.value > 0, "No ETH sent");
emit Deposited(msg.sender, msg.value);
}
// ── Withdraw (WITHDRAWER only) ──
function withdraw(uint256 amount) external onlyRole(WITHDRAWER_ROLE) {
if (amount > address(this).balance)
revert InsufficientBalance(amount, address(this).balance);
payable(msg.sender).transfer(amount);
emit Withdrawn(msg.sender, amount);
}
// ── View (free off-chain) ──
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
Contract Breakdown
AccessControl from OpenZeppelin — gives us the entire role-based infrastructure with a single is clause.DEPOSITOR_ROLE and WITHDRAWER_ROLE — defined as bytes32 constants using keccak256. Deployer gets both roles in the constructor.immutable deployedAt (deployment timestamp, 0 gas to read). The Vault's ETH balance is tracked natively by the EVM — no need for a separate state variable.deposit() (payable, DEPOSITOR only), withdraw(amount) (WITHDRAWER only), getBalance() (view, free off-chain). Each state-changing function emits an event.Deposited(address indexed who, uint256 amount) and Withdrawn(address indexed who, uint256 amount). History is tracked off-chain via events — no storage writes for history.error Unauthorized() and error InsufficientBalance(uint256 requested, uint256 available). Gas-efficient: ~50 bytes cheaper than string messages per error.Gas Analysis
deposit(): ~50,000 gas (ETH transfer + event emission). withdraw(): ~35,000 gas (ETH transfer + event). No storage writes for tracking history — events only. Compared to the TodoList's 60,000–80,000 gas, this Vault is leaner despite being more complex.
A Note on .transfer()
The Vault uses .transfer() for ETH withdrawals. This forwards only 2,300 gas — safe for EOAs but will fail if the recipient is a contract with complex logic. In production, prefer (bool ok, ) = payable(to).call{value: amount}("") with a success check. We use .transfer() here for clarity; Module 13 (Security) covers reentrancy guards and safe ETH transfer patterns.
Progression
Module 7's TodoList used a single owner. This Vault uses role-based access control. In Module 9, you'll use these exact patterns to build a full ERC-20 token — with minting, burning, pausing, and role management.
Exercise & Self-Check
Exercises
- Inheritance chain: Create an
Animal → Pet → Doginheritance chain.Animalhas avirtual getName()returning"Animal".Petoverrides it to return"Pet".Dogoverrides it to return"Dog". DeployDogand verifygetName()returns"Dog". - Interface design: Write a complete
IERC20interface with 6 functions:totalSupply(),balanceOf(address),transfer(address, uint256),approve(address, uint256),allowance(address, address),transferFrom(address, address, uint256). Include the correct visibility and return types. - Access control: Add a
PAUSER_ROLEto the Multi-Role Vault. Implementpause()andunpause()functions that set abool pausedstate variable. Add awhenNotPausedmodifier todeposit()andwithdraw(). - Storage packing: Given the struct
{ uint256 id; bool active; uint256 amount; uint8 category; address owner; uint8 priority; }, rearrange fields to minimize storage slots. How many slots before vs after? - Upgrade analysis: A proxy contract has Implementation V1 with
uint256 balance; address owner;. V2 insertsbool paused;between them:uint256 balance; bool paused; address owner;. Explain exactly what goes wrong and how to fix it.
Self-Check Questions
- What is C3 linearization and why does the order of parent contracts in the
isclause matter? - What is the difference between an
interfaceand anabstract contract? When would you use each? - Why is
AccessControlmore flexible thanOwnable? Give a concrete example where Ownable isn't enough. - What does
delegatecalldo differently from a regularcall? Why is this critical for the proxy pattern? - Why is storage packing important and what is the rule for ordering variables to maximize packing?
Module Summary
You can now write production-quality Solidity using proven patterns. You understand inheritance and polymorphism, interfaces and abstract contracts, role-based access control with OpenZeppelin, gas optimization techniques (packing, constants, events vs storage), and the proxy upgrade pattern. These are the building blocks of every serious smart contract.