DEV Community

Cover image for Immutability by Default, Upgradeability by Necessity: Lessons from a Crowdfunding Protocol
Obinna Duru
Obinna Duru

Posted on

Immutability by Default, Upgradeability by Necessity: Lessons from a Crowdfunding Protocol

Smart contracts handle real value, so I believe every line of code should communicate trust.

When you first start learning Solidity, you are taught one golden rule: smart contracts are immutable. Deploying a contract is like launching a rocket into space, once it leaves the launchpad, you can't easily change the wiring. If there is a bug, the code is set in stone.

But recently, while building MilestoneCrowdfundUpgradeable: an onchain escrow protocol verified on Polygon. I hit a classic Web3 crossroads.

My stakeholder had a very practical requirement: flexibility. We knew we had future upgrades planned for the protocol, but we couldn't ask our users to interact with a new contract address every single month. That is a terrible user experience and a quick way to erode trust. We needed a single, reliable entry point for users, while maintaining the ability to upgrade the underlying logic.

We needed an upgradeable contract.

In this post (the first of a five-part series), I want to sit down with you and share exactly how I approached Upgradeability. If you are a beginner or intermediate developer trying to wrap your head around proxies, storage collisions, and initialization traps, this post is for you.

What Actually is a Proxy? (The Restaurant Analogy)
Before we talk about how to upgrade, we have to understand the architecture. How do you change code that is supposed to be unchangeable?

You split the contract into two pieces. I like to explain it using a restaurant analogy:

  1. The Proxy (The Building & Cash Register): This contract has a permanent address. Users only ever interact with this contract. It holds all the money (ETH) and all the data (state).

  2. The Implementation (The Kitchen Staff & Recipe): This contract holds the actual logic (the code for pledge(), refund(), etc.).

When a user sends money to the Proxy, the Proxy uses a special Ethereum command called delegatecall. It basically says to the Implementation: "Hey Kitchen, borrow my ingredients and tell me how to process this order, but leave the money in my cash register."

If we find a bug in our recipe, the Admin simply deploys a brand new Implementation contract (a new Kitchen Staff), and tells the Proxy to start asking them for instructions instead. The building address and the cash register never change.

MilestoneCrowdfundUpgradeable architecture Image 1

There are a few ways to build this. I chose a pattern called UUPS (Universal Upgradeable Proxy Standard) via the OpenZeppelin library.

Without getting too bogged down in jargon, older patterns (like Transparent Proxies) put the "upgrade manager" in the Proxy itself. UUPS puts the "upgrade manager" in the Implementation contract. I chose UUPS because it makes the Proxy lightweight, which makes transactions cheaper (lower gas fees) for our users.

The Constructor Trap (And How to Get Hacked)

When you write a normal smart contract, you use a constructor() to set up your initial variables (like assigning the owner). Constructors run exactly once, during deployment.
But in a proxy setup, the Proxy deploys after the Implementation. The Proxy can't trigger the Implementation's constructor. So, the golden rule of upgradeability is: Do not use constructors. Use an initialize() function instead.

At first, it feels like you can just swap the words and move on. But here is the trap: The implementation contract is still a live contract sitting on the blockchain. Imagine you deploy your Implementation contract, and it has an initialize() function that sets the owner. Your Proxy connects to it and calls initialize(). Great! Your Proxy is the owner.

But what about the Implementation contract itself? It's just floating out there. If you don't lock it, a hacker can call initialize() directly on the Implementation contract, make themselves the owner, and potentially command it to self-destruct. If the Implementation is destroyed, your Proxy is permanently broken.

MilestoneCrowdfundUpgradeable architecture Image 2

To prevent this, my approach is strict:

  1. All protocol setup goes into initialize().
  2. I immediately lock the Implementation contract using a special constructor.
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    // This locks the Implementation contract so no one can initialize it directly
    _disableInitializers(); 
}
Enter fullscreen mode Exit fullscreen mode

From a mindset perspective: Assume attackers will interact with your implementation contract directly. Once you internalize that, locking it stops being a confusing "trap" and simply becomes part of your default security posture.

Storage Management: The Giant Spreadsheet

Upgradeable contracts are notorious for "storage collisions."

Think of smart contract storage like a giant, invisible spreadsheet. Row 1 is owner. Row 2 is totalRaised.

When you upgrade to Implementation V2, the Proxy still uses that exact same spreadsheet. If you accidentally write V2 so that a new variable called isCampaignActive is placed in Row 1, you just overwrote the owner! That is a storage collision, and it is catastrophic.

MilestoneCrowdfundUpgradeable architecture Image 3

To prevent this, my approach is conservative and explicit:

  • Append-only storage: Never reorder variables. Never delete variables.
  • Storage Gaps: I use the classic uint256[50] private __gap; at the bottom of my contracts.

A storage gap is literally just claiming 50 empty rows at the bottom of your spreadsheet. If I ever need to add a new variable in V2, I take one row away from the gap (uint256[49] private __gap;) and add my new variable.

Why gaps instead of newer, complex patterns (like ERC-7201)? Because it's simple, battle-tested, and auditors easily understand it. Storage layout is not "code you can refactor"; it's "state you must preserve forever." Discipline is your real protection.

The Hardest Lesson: Hunting for Ghosts

The single most frustrating moment I had while setting up this project in Foundry (my testing framework) came down to one line of code.

In Web3, we use something called a ReentrancyGuard to stop hackers from double-spending money. Because I was building an upgradeable contract, I kept trying to import ReentrancyGuardUpgradeable.

It just wasn't there. Most older tutorials, blog posts, and AI tools told me to use the Upgradeable version, so it felt like I was doing something wrong.

What finally clicked was reading the source code of the newest OpenZeppelin version. I realized that the standard ReentrancyGuard no longer relied on a constructor. It used internal logic that worked perfectly fine behind a proxy. I didn't need an upgradeable version anymore; I could just use the normal one:

import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
Enter fullscreen mode Exit fullscreen mode

The frustrating part wasn't the code, it was the mismatch between my old mental model ("everything must have an Upgradeable suffix") and reality (some contracts are now inherently proxy-safe).

It forced a deeper shift in how I work: I stopped coding from patterns, and started reasoning from first principles. Upgradeability isn't just about making a proxy; it's about understanding how every single tool you import behaves under the hood.

The BinnaDev Takeaway

If a junior developer asked me today, "Obinna, should I make my next project upgradeable?" my answer would be:

It depends but try not to use it unless you have a clear, defensible reason.

Upgradeability is not a free feature. You gain flexibility, but you introduce new attack surfaces and complexity. If you can ship your protocol as immutable, do that first. It gives you a simpler mental model, fewer failure modes, and it is vastly easier to secure.

Only reach for upgradeability if the system will hold significant user funds, has evolving logic, or needs long-term maintenance. At that point, upgradeability becomes a risk management tool, not a convenience.

My guiding principle is this: Immutability by default, upgradeability by necessity. If you can't clearly explain why it must be upgradeable, who controls the upgrades, and how users are protected, then you aren't ready to use it yet.

Deployment Update

The MilestoneCrowdfundUpgradeable contract has been successfully deployed on Polygon (Chain ID: 137), marking the transition from design and testing into a live, verifiable environment.

Here are the core deployment details:

  1. Proxy Address: https://polygonscan.com/address/0xf83aaB5f1fAA1a7a74AD27E2f8058801EaA31393
  2. Implementation Address: https://polygonscan.com/address/0x003e81b1b080029b87c728590c9bfec339180625

This deployment reflects the architectural decisions discussed earlier: a proxy-based upgradeable system with clearly defined control boundaries and fund flow roles. With the contract now live, the focus shifts from infrastructure to behavior, how funds move, how milestones are enforced, and how the protocol responds under real-world conditions.

In the next post, we'll dive into the actual mechanics of the MilestoneCrowdfundUpgradeable protocol itself. I'll walk you through how we designed the escrow, the math behind milestone-based releases, and the trust model that protects users if a creator decides to walk away.

Top comments (0)