Tutoriel·12 min de lecture·Par Solingo

8 Solidity Design Patterns Every Developer Should Know

From Checks-Effects-Interactions to Factory and Proxy patterns. Write cleaner, safer, more maintainable smart contracts.

# 8 Solidity Design Patterns Every Developer Should Know

Design patterns are battle-tested solutions to recurring problems. In Solidity, they prevent bugs, save gas, and make code maintainable.

Unlike web development, smart contract mistakes are permanent. A single reentrancy bug can drain millions. These 8 patterns will make you a safer, more professional developer.

1. Checks-Effects-Interactions (CEI)

Problem: Reentrancy attacks—external calls triggering unexpected re-entry.

Solution: Follow this order in every function:

  • Checks: Validate conditions (require)
  • Effects: Update state variables
  • Interactions: Make external calls
  • Vulnerable Code:

    function withdraw(uint amount) public {
    

    require(balances[msg.sender] >= amount);

    // INTERACTION before EFFECT (vulnerable!)

    (bool success,) = msg.sender.call{value: amount}("");

    require(success);

    balances[msg.sender] -= amount; // Updated AFTER external call

    }

    Attack:

    The attacker's fallback function calls withdraw again before balances is updated, draining the contract.

    Secure Code (CEI):

    function withdraw(uint amount) public {
    

    // 1. CHECKS

    require(balances[msg.sender] >= amount, "Insufficient balance");

    // 2. EFFECTS

    balances[msg.sender] -= amount;

    // 3. INTERACTIONS

    (bool success,) = msg.sender.call{value: amount}("");

    require(success, "Transfer failed");

    }

    Now, even if the attacker re-enters, their balance is already zero.

    When to use: Always, especially when making external calls.

    ---

    2. Pull Over Push (Withdrawal Pattern)

    Problem: Pushing payments to multiple addresses can fail if one reverts, blocking everyone.

    Solution: Let users pull their funds instead of pushing.

    Bad (Push):

    function distributeRewards(address[] memory recipients) public {
    

    for (uint i = 0; i < recipients.length; i++) {

    // If one transfer fails, entire function reverts

    (bool success,) = recipients[i].call{value: 1 ether}("");

    require(success);

    }

    }

    Good (Pull):

    mapping(address => uint) public pendingWithdrawals;
    
    

    function distributeRewards(address[] memory recipients) public {

    for (uint i = 0; i < recipients.length; i++) {

    pendingWithdrawals[recipients[i]] += 1 ether;

    }

    }

    function withdraw() public {

    uint amount = pendingWithdrawals[msg.sender];

    require(amount > 0, "No funds");

    pendingWithdrawals[msg.sender] = 0; // CEI pattern

    (bool success,) = msg.sender.call{value: amount}("");

    require(success);

    }

    Benefits:

    • One user can't block others
    • Gas-efficient (no loops in critical path)
    • User controls when to withdraw

    When to use: Airdrops, dividends, reward distribution.

    ---

    3. Guard Check (Modifier Pattern)

    Problem: Repeated validation logic clutters functions.

    Solution: Extract checks into reusable modifier functions.

    Without Modifiers:

    function adminFunction() public {
    

    require(msg.sender == owner, "Not owner");

    require(!paused, "Contract paused");

    // ... logic

    }

    function anotherAdminFunction() public {

    require(msg.sender == owner, "Not owner");

    require(!paused, "Contract paused");

    // ... logic

    }

    With Modifiers:

    modifier onlyOwner() {
    

    require(msg.sender == owner, "Not owner");

    _;

    }

    modifier whenNotPaused() {

    require(!paused, "Contract paused");

    _;

    }

    function adminFunction() public onlyOwner whenNotPaused {

    // Clean, readable logic

    }

    Common Modifiers:

    modifier nonReentrant() {
    

    require(!locked, "Reentrant call");

    locked = true;

    _;

    locked = false;

    }

    modifier validAddress(address addr) {

    require(addr != address(0), "Zero address");

    _;

    }

    modifier withinLimit(uint amount) {

    require(amount <= MAX_AMOUNT, "Exceeds limit");

    _;

    }

    When to use: Any repeated validation logic.

    ---

    4. Factory Pattern

    Problem: Deploying multiple instances of the same contract.

    Solution: A factory contract that creates and tracks child contracts.

    // Child contract
    

    contract Token {

    string public name;

    address public owner;

    constructor(string memory _name, address _owner) {

    name = _name;

    owner = _owner;

    }

    }

    // Factory contract

    contract TokenFactory {

    Token[] public tokens;

    mapping(address => Token[]) public userTokens;

    event TokenCreated(address indexed owner, address tokenAddress);

    function createToken(string memory name) public returns (address) {

    Token newToken = new Token(name, msg.sender);

    tokens.push(newToken);

    userTokens[msg.sender].push(newToken);

    emit TokenCreated(msg.sender, address(newToken));

    return address(newToken);

    }

    function getTokenCount() public view returns (uint) {

    return tokens.length;

    }

    function getUserTokens(address user) public view returns (Token[] memory) {

    return userTokens[user];

    }

    }

    Benefits:

    • Centralized registry of all instances
    • User-friendly deployment (one transaction)
    • Track ownership/metrics

    Advanced: Minimal Proxy (EIP-1167)

    For gas-efficient clones:

    import "@openzeppelin/contracts/proxy/Clones.sol";
    
    

    contract MinimalProxyFactory {

    address public implementation;

    constructor(address _implementation) {

    implementation = _implementation;

    }

    function createClone() public returns (address) {

    // Costs ~10x less gas than 'new'

    return Clones.clone(implementation);

    }

    }

    When to use: Token factories, NFT collections, DAO creation.

    ---

    5. Proxy Pattern (Upgradeable Contracts)

    Problem: Smart contracts are immutable—you can't fix bugs or add features.

    Solution: Separate logic (upgradeable) from storage (permanent).

    Transparent Proxy (OpenZeppelin):

    // Implementation contract (logic)
    

    contract BoxV1 {

    uint256 public value;

    function store(uint256 newValue) public {

    value = newValue;

    }

    }

    // Upgraded version

    contract BoxV2 {

    uint256 public value;

    function store(uint256 newValue) public {

    value = newValue;

    }

    function increment() public {

    value += 1;

    }

    }

    Deployment with Foundry:

    import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
    
    

    contract DeployProxy is Script {

    function run() external {

    BoxV1 implementation = new BoxV1();

    TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(

    address(implementation),

    msg.sender, // admin

    "" // no initialization data

    );

    // Use proxy as BoxV1

    BoxV1(address(proxy)).store(42);

    }

    }

    Upgrading:

    function upgrade() external {
    

    BoxV2 newImplementation = new BoxV2();

    ProxyAdmin(proxyAdmin).upgrade(proxy, address(newImplementation));

    }

    Critical Rules:

  • Never change storage layout (add variables at the end only)
  • Use initializers, not constructors (constructors run in implementation, not proxy)
  • Test upgrades extensively on testnet
  • When to use: High-value contracts (DAOs, treasuries), evolving protocols.

    ---

    6. Access Control (Role-Based)

    Problem: Simple onlyOwner doesn't scale. You need roles (admin, minter, burner).

    Solution: OpenZeppelin's AccessControl pattern.

    import "@openzeppelin/contracts/access/AccessControl.sol";
    
    

    contract RoleBasedToken is AccessControl {

    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    mapping(address => uint) public balances;

    constructor() {

    // Deployer is default admin

    _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);

    _grantRole(MINTER_ROLE, msg.sender);

    }

    function mint(address to, uint amount) public onlyRole(MINTER_ROLE) {

    balances[to] += amount;

    }

    function burn(address from, uint amount) public onlyRole(BURNER_ROLE) {

    balances[from] -= amount;

    }

    // Admin can grant/revoke roles

    function addMinter(address account) public onlyRole(DEFAULT_ADMIN_ROLE) {

    grantRole(MINTER_ROLE, account);

    }

    }

    Hierarchical Roles:

    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    

    bytes32 public constant MODERATOR_ROLE = keccak256("MODERATOR_ROLE");

    constructor() {

    _setRoleAdmin(MODERATOR_ROLE, ADMIN_ROLE); // Admins manage moderators

    }

    When to use: DAOs, multi-admin systems, DeFi protocols.

    ---

    7. Oracle Pattern (Trusted Data Source)

    Problem: Smart contracts can't access off-chain data (price feeds, weather, sports scores).

    Solution: Oracle pattern with trusted data providers.

    Simple Oracle (Centralized):

    contract PriceOracle {
    

    address public oracle;

    mapping(string => uint) public prices; // symbol => price in USD (8 decimals)

    event PriceUpdated(string symbol, uint price, uint timestamp);

    modifier onlyOracle() {

    require(msg.sender == oracle, "Not oracle");

    _;

    }

    constructor(address _oracle) {

    oracle = _oracle;

    }

    function updatePrice(string memory symbol, uint price) public onlyOracle {

    prices[symbol] = price;

    emit PriceUpdated(symbol, price, block.timestamp);

    }

    function getPrice(string memory symbol) public view returns (uint) {

    uint price = prices[symbol];

    require(price > 0, "Price not set");

    return price;

    }

    }

    // Consumer contract

    contract LendingProtocol {

    PriceOracle oracle;

    function calculateCollateral(uint ethAmount) public view returns (uint usdValue) {

    uint ethPrice = oracle.getPrice("ETH"); // e.g., 2000_00000000 ($2000)

    usdValue = (ethAmount * ethPrice) / 1e18;

    }

    }

    Production: Use Chainlink

    import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
    
    

    contract ChainlinkConsumer {

    AggregatorV3Interface internal priceFeed;

    constructor() {

    // ETH/USD on Ethereum mainnet

    priceFeed = AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);

    }

    function getLatestPrice() public view returns (int) {

    (, int price,,,) = priceFeed.latestRoundData();

    return price; // 8 decimals

    }

    }

    When to use: DeFi (price feeds), gaming (randomness), insurance (real-world events).

    ---

    8. Emergency Stop (Circuit Breaker)

    Problem: A critical bug is discovered—you need to pause the contract.

    Solution: Pausable pattern.

    import "@openzeppelin/contracts/security/Pausable.sol";
    

    import "@openzeppelin/contracts/access/Ownable.sol";

    contract EmergencyStop is Pausable, Ownable {

    mapping(address => uint) public balances;

    constructor() Ownable(msg.sender) {}

    function deposit() public payable whenNotPaused {

    balances[msg.sender] += msg.value;

    }

    function withdraw(uint amount) public whenNotPaused {

    require(balances[msg.sender] >= amount);

    balances[msg.sender] -= amount;

    payable(msg.sender).transfer(amount);

    }

    // Emergency functions (admin only)

    function pause() public onlyOwner {

    _pause();

    }

    function unpause() public onlyOwner {

    _unpause();

    }

    }

    Advanced: Time-Locked Pause

    uint public pausedUntil;
    
    

    function emergencyPause(uint duration) public onlyOwner {

    pausedUntil = block.timestamp + duration;

    }

    modifier whenNotPaused() {

    require(block.timestamp > pausedUntil, "Contract paused");

    _;

    }

    When to use: High-value protocols, public launches, uncertain security.

    ---

    Combining Patterns

    Real-world contracts use multiple patterns:

    contract SecureVault is Ownable, Pausable, ReentrancyGuard {
    

    mapping(address => uint) public balances;

    // Guard Check + Emergency Stop

    function deposit() public payable whenNotPaused {

    balances[msg.sender] += msg.value;

    }

    // CEI + Pull Pattern + Reentrancy Guard

    function withdraw(uint amount) public whenNotPaused nonReentrant {

    // CHECKS

    require(balances[msg.sender] >= amount, "Insufficient balance");

    // EFFECTS

    balances[msg.sender] -= amount;

    // INTERACTIONS

    (bool success,) = msg.sender.call{value: amount}("");

    require(success, "Transfer failed");

    }

    // Access Control

    function pause() public onlyOwner {

    _pause();

    }

    }

    ---

    Pattern Selection Guide

    | Use Case | Patterns |

    |----------|----------|

    | Token contract | CEI, Guard Check, Access Control |

    | NFT marketplace | CEI, Pull Pattern, Emergency Stop |

    | DAO | Factory, Proxy, Access Control |

    | DeFi protocol | CEI, Oracle, Emergency Stop, Pull Pattern |

    | Upgradeable system | Proxy, Access Control |

    ---

    Anti-Patterns to Avoid

    1. Tx.origin for Authentication

    // VULNERABLE
    

    require(tx.origin == owner);

    // SECURE

    require(msg.sender == owner);

    2. Floating Pragma

    // RISKY
    

    pragma solidity ^0.8.0;

    // SAFE

    pragma solidity 0.8.20;

    3. Block Variables for Randomness

    // PREDICTABLE (miners can manipulate)
    

    uint random = uint(keccak256(abi.encodePacked(block.timestamp, block.difficulty)));

    // SECURE

    // Use Chainlink VRF for true randomness

    ---

    Conclusion

    These 8 patterns are the foundation of professional Solidity development:

  • Checks-Effects-Interactions: Prevent reentrancy
  • Pull Over Push: Avoid DOS attacks
  • Guard Check: Clean, reusable validation
  • Factory: Manage multiple instances
  • Proxy: Upgradeable contracts
  • Access Control: Role-based permissions
  • Oracle: Bridge on-chain/off-chain
  • Emergency Stop: Risk mitigation
  • Master these, and you'll write code that's:

    • Secure (resistant to attacks)
    • Efficient (gas-optimized)
    • Maintainable (clear, modular)
    • Professional (follows industry standards)

    Next Steps:

    • Practice implementing each pattern

    Build better. Build safer. 🛡️

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement