Smart Contract Design Patterns

Smart Contracts, DeFi & Web3 Security
Course 2 · Chapter 3 · Smart Contract Design Patterns

Chapters 1 and 2 gave you Solidity's syntax and the EVM's real mechanics. This chapter covers something different: the real, established patterns experienced Solidity developers actually reach for, several of which exist specifically because of one of the most expensive lessons in blockchain history — the same DAO hack Course 1 Chapter 6 already introduced, revisited here with the specific technical detail that chapter deliberately left out.

Checks-Effects-Interactions: The Pattern the DAO Hack Made Famous

The Checks-Effects-Interactions (CEI) pattern is a real, foundational rule for structuring any function that both updates a contract's own state and calls out to another address: always perform these three kinds of operations in this exact order.

  1. Checks — validate every condition first (does the caller have permission, is the requested amount valid, and so on).
  2. Effects — update the contract's own internal state (Chapter 1's state variables) to reflect the outcome, before talking to anyone else.
  3. Interactions — only now, last, call out to an external address or contract (for example, actually sending ETH).

Why the Order Matters: The Real DAO Hack Mechanism

Course 1 Chapter 6 covered the real, documented 2016 DAO hack — roughly $50 million drained from a $150 million smart contract, resolved by a contentious hard fork. What it deliberately didn't cover is precisely how the attacker actually did it — and the real mechanism is a textbook violation of this exact ordering.

May 2016
A published security paper had already flagged a real vulnerability related to "recursive calls" in the DAO's own contract code, before the attack ever happened.
17 Jun 2016
An attacker exploits it: the DAO's withdrawal function sent ETH to the caller before updating the caller's own internal balance record. The attacker's own contract, receiving that ETH, automatically called straight back into the same withdrawal function again — and because the balance hadn't been updated yet, the DAO happily paid out again. And again. Real, genuine reentrancy: the same, not-yet-updated function called recursively, over and over, before its own first invocation had ever finished. Roughly 3.6 million ETH — about a third of the 11.5 million ETH committed to the DAO — was drained this way, worth roughly $50 million at the time.
A Real, Fortunate Design Detail The DAO's own contract happened to include a real 28-day holding period before withdrawn funds could be fully moved elsewhere — not a deliberate security feature against this specific attack, just a structural detail of the DAO's own design. It gave the community real time to respond before the attacker could walk away with the funds entirely, directly enabling the hard fork response Course 1 Chapter 6 already covered. This is worth knowing honestly as a piece of genuine luck in how events unfolded, not a security mechanism anyone had deliberately planned to rely on.

Applying Checks-Effects-Interactions correctly would have prevented this outright: if the DAO's own contract had updated the caller's balance before sending any ETH, the second and every subsequent recursive call would have seen a balance already reduced to zero, and simply failed.

Vulnerable — sends ETH before updating state (violates CEI)
function withdraw(uint amount) public { require(balances[msg.sender] >= amount, "Insufficient balance"); // INTERACTION happens before the EFFECT — the bug (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); balances[msg.sender] -= amount; // too late — already sent }
Fixed — follows Checks, Effects, then Interactions
function withdraw(uint amount) public { // CHECK require(balances[msg.sender] >= amount, "Insufficient balance"); // EFFECT — update state first balances[msg.sender] -= amount; // INTERACTION — last (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); }

In the fixed version, even if the recipient's own contract tries the exact same recursive re-entry trick, balances[msg.sender] has already been reduced by the time the external call happens — so a second, recursive call to withdraw() now correctly fails the very first require check. Chapter 7's own dedicated security chapter covers reentrancy and several other real vulnerability classes in full depth — this chapter's job is establishing the pattern that prevents this one by default.

Pull Over Push Payments

A closely related real pattern: when a contract needs to pay out to multiple recipients, don't loop through them and actively push ETH to each one directly. Instead, record how much each address is owed, and let each recipient pull their own payment by calling a separate withdraw() function themselves, whenever they choose.

This has two real, concrete benefits: a single recipient with a broken or malicious contract can't block payment to everyone else in the same loop (since each pull is now a fully independent transaction), and it naturally reduces how many places in your own code perform a real external interaction — directly shrinking the surface area where a Checks-Effects-Interactions mistake could occur in the first place.

Access Control: Modifiers and the Ownable Pattern

Solidity's real modifier feature lets you define a reusable condition, checked before a function's own body runs. The most common real example restricts a function to one specific, privileged address — typically called the Ownable pattern:

address public owner; modifier onlyOwner() { require(msg.sender == owner, "Not the owner"); _; // the calling function's own body runs here } function withdrawFees() public onlyOwner { // only the owner address can ever reach this point }
PatternReal Problem It Solves
Checks-Effects-InteractionsPrevents reentrancy by updating state before making external calls
Pull over pushPrevents one broken recipient from blocking payment to everyone else
Ownable / access controlRestricts sensitive functions to one privileged address

A Real, Honest Complication: Contracts Are Immutable by Default

Course 1 Chapter 3 established that a blockchain's history is tamper-evident by design — and the exact same immutability applies to a deployed contract's own code. Once deployed, a contract's bytecode cannot simply be edited later, even by its own original developer, even to fix a genuine bug. This is a deliberate, structural consequence of everything Course 1 already established, not a separate limitation.

Real projects that genuinely need to upgrade their own logic over time use a real, more advanced approach called a proxy pattern: users interact with one fixed, permanent "proxy" contract address, which internally delegates its actual logic to a separate implementation contract — and upgrading later means pointing the proxy at a new implementation contract, rather than editing any contract's own code after the fact. This is a genuinely more complex pattern with its own real trade-offs and risks, deliberately left out of full depth here — the honest, important point for this chapter is simply that "just fix the bug and redeploy in place" is never actually an option once a contract is live.

OpenZeppelin: The Real, Standard Library

Don't Reinvent These Patterns From Scratch OpenZeppelin Contracts is a real, widely used, community-reviewed library of battle-tested Solidity code implementing exactly the patterns this chapter covers — a ready-made Ownable contract, a ReentrancyGuard modifier providing an extra layer of reentrancy protection on top of CEI, and standard, audited ERC-20/ERC-721 implementations (Course 1, Chapter 8). Real, major DeFi protocols and financial platforms rely on it directly rather than hand-writing these same patterns themselves each time. In real-world development, using an established, reviewed library like this for foundational patterns is almost always safer than writing your own from scratch.

Hands-On Exercises

Three exercises applying Checks-Effects-Interactions and this chapter's other real patterns, before Chapter 4 turns to DeFi mechanics built directly on top of them.

Exercise 1
Using this chapter's own real DAO hack mechanism, explain step by step why the vulnerable withdraw() function in this chapter allows a malicious contract to drain far more than its own actual balance, while the fixed version doesn't.
Exercise 2
A contract needs to pay out prize money to 50 different competition winners at once. Using this chapter's own pull-over-push reasoning, explain what could go wrong with a loop that sends ETH directly to all 50 addresses in a single transaction, and how the pull pattern avoids it.
Exercise 3
A junior developer says: "If we find a bug in our deployed contract, we'll just push a fix." Using this chapter's own explanation of contract immutability, correct this statement and describe what a real fix would actually require.

Quick Reference

  • Checks-Effects-Interactions — validate, then update your own state, then call external addresses last — in that exact order.
  • The DAO hack, precisely — a real reentrancy exploit (17 June 2016) that sent ETH before updating balances, letting a recursive callback drain ~3.6M ETH (~$50M) before the balance was ever reduced.
  • Pull over push — let recipients withdraw their own payments individually, rather than pushing to everyone in one loop.
  • Modifiers / Ownable — reusable pre-conditions (like onlyOwner) restricting sensitive functions to a privileged address.
  • Immutability & proxies — deployed contract code can't be edited in place; real upgradability needs a dedicated proxy pattern instead.
  • OpenZeppelin — the real, standard, audited library implementing these patterns rather than hand-rolling them.