← Course home Module 8 / 19 Blockchain — Part 2

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.

~75 min read Module 8 ⟠ Part 2

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.

Inheritance.sol Solidity
// 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.

InterfaceAbstract.sol Solidity
// 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.

AccessControlExample.sol Solidity
// 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.

LibraryGas.sol Solidity
// 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

Component
Responsibility
Key Detail
Proxy
Holds storage, receives all calls, delegates execution
Immutable once deployed. Only its storage pointer (implementation address) changes.
Implementation
Holds all logic, can be replaced on upgrade
Stateless — reads/writes the Proxy's storage via delegatecall.
Admin
Can trigger upgrades, points Proxy to new Implementation
Often a multisig or governance contract for security.
🔍

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.

ProxyConcept.sol Solidity
// 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.

Vault.sol Solidity
// 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

Inheritance
Inherits AccessControl from OpenZeppelin — gives us the entire role-based infrastructure with a single is clause.
Roles
DEPOSITOR_ROLE and WITHDRAWER_ROLE — defined as bytes32 constants using keccak256. Deployer gets both roles in the constructor.
State
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.
Functions
deposit() (payable, DEPOSITOR only), withdraw(amount) (WITHDRAWER only), getBalance() (view, free off-chain). Each state-changing function emits an event.
Events
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.
Custom Errors
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

  1. Inheritance chain: Create an Animal → Pet → Dog inheritance chain. Animal has a virtual getName() returning "Animal". Pet overrides it to return "Pet". Dog overrides it to return "Dog". Deploy Dog and verify getName() returns "Dog".
  2. Interface design: Write a complete IERC20 interface 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.
  3. Access control: Add a PAUSER_ROLE to the Multi-Role Vault. Implement pause() and unpause() functions that set a bool paused state variable. Add a whenNotPaused modifier to deposit() and withdraw().
  4. 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?
  5. Upgrade analysis: A proxy contract has Implementation V1 with uint256 balance; address owner;. V2 inserts bool paused; between them: uint256 balance; bool paused; address owner;. Explain exactly what goes wrong and how to fix it.

Self-Check Questions

  1. What is C3 linearization and why does the order of parent contracts in the is clause matter?
  2. What is the difference between an interface and an abstract contract? When would you use each?
  3. Why is AccessControl more flexible than Ownable? Give a concrete example where Ownable isn't enough.
  4. What does delegatecall do differently from a regular call? Why is this critical for the proxy pattern?
  5. 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.

Next module

You have the patterns. Now build something real. Module 9 covers ERC-20 tokens — the standard behind every fungible token on Ethereum. You'll implement the full interface, add minting and burning, and deploy your own token.

Module 9: ERC-20 Token — Coming Soon
📋 Course Project

Ready to apply everything you've learned? The end-of-course project combines smart contracts, AI, and Node.js into a real decentralised academic assistant.

View Project Brief →