March 22, 2026·9 min·By

Upgradeable Smart Contracts: Proxy Patterns Explained

Solidityupgradeable contractsproxy patternOpenZeppelinWeb3

Smart contracts are immutable by design. Upgradeable contracts are a workaround -- useful, but carrying real tradeoffs you need to understand.

The Core Problem

Once deployed, a contract's logic can't change. But bugs happen and requirements change. The upgrade pattern separates storage (proxy contract, permanent address) from logic (implementation contract, swappable).

User -> Proxy Contract (permanent address, holds storage)
             |
             | delegatecall
             v
        Implementation V1 (contains logic, uses proxy's storage)

After upgrade:
User -> Proxy Contract (same address)
             |
             | delegatecall
             v
        Implementation V2 (new logic, same storage)

delegatecall runs the implementation's code but in the proxy's context -- it reads and writes to the proxy's storage, not the implementation's.

UUPS Pattern (Recommended)

The upgrade logic lives in the implementation contract. More gas-efficient than Transparent proxy.

// Implementation V1
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract TokenV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    mapping(address => uint256) public balances;
    uint256 public totalSupply;

    // Use initialize() instead of constructor()
    function initialize(address owner) public initializer {
        __Ownable_init(owner);
        __UUPSUpgradeable_init();
        totalSupply = 1_000_000 * 10**18;
        balances[owner] = totalSupply;
    }

    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    // Only owner can upgrade
    function _authorizeUpgrade(address newImplementation)
        internal override onlyOwner {}
}
// Implementation V2 -- adds a new feature
contract TokenV2 is TokenV1 {
    mapping(address => bool) public frozen;

    function freeze(address account) external onlyOwner {
        frozen[account] = true;
    }

    function transfer(address to, uint256 amount) external override {
        require(!frozen[msg.sender], "Account frozen");
        super.transfer(to, amount);  // reuse V1 logic
    }
}

Deployment with Hardhat

import { ethers, upgrades } from 'hardhat'

// Deploy upgradeable proxy
const TokenV1 = await ethers.getContractFactory('TokenV1')
const token = await upgrades.deployProxy(TokenV1, [deployer.address], {
    kind: 'uups'
})
await token.waitForDeployment()
console.log('Proxy address:', await token.getAddress())
console.log('Implementation:', await upgrades.erc1967.getImplementationAddress(await token.getAddress()))

// Upgrade to V2
const TokenV2 = await ethers.getContractFactory('TokenV2')
const upgraded = await upgrades.upgradeProxy(await token.getAddress(), TokenV2)
console.log('New implementation:', await upgrades.erc1967.getImplementationAddress(await upgraded.getAddress()))

Storage Layout: The Critical Gotcha

You cannot change the order or type of existing storage variables in an upgrade. New variables must be added at the END:

// V1 storage
contract TokenV1 {
    mapping(address => uint256) public balances;  // slot 0
    uint256 public totalSupply;                   // slot 1
}

// V2 -- SAFE: only adds new variables after existing ones
contract TokenV2 is TokenV1 {
    mapping(address => bool) public frozen;  // slot 2 -- new slot
}

// V2 -- UNSAFE: changes existing storage layout
contract TokenBroken is TokenV1 {
    uint256 public newVar;                        // slot 0 -- COLLISION with balances!
    mapping(address => uint256) public balances;  // slot 1 -- wrong slot!
}

Use OpenZeppelin's storage gap pattern for library contracts:

contract Base {
    uint256 public value;
    uint256[49] private __gap;  // Reserve 49 slots for future use
}

When NOT to Use Upgradeable Contracts

Upgradeable contracts are a centralization risk. The owner can change any logic, including changing fund flows. For DeFi protocols where trustlessness matters, this is a red flag.

Prefer:

  • Timelocks: 48-hour delay on upgrades so users can exit
  • Governance: multi-sig or DAO vote required for upgrades
  • Immutable contracts for core protocol logic, upgradeable for periphery
// Add a timelock delay
uint256 public constant UPGRADE_DELAY = 2 days;
mapping(address => uint256) public upgradeScheduled;

function scheduleUpgrade(address newImpl) external onlyOwner {
    upgradeScheduled[newImpl] = block.timestamp + UPGRADE_DELAY;
}

function _authorizeUpgrade(address newImpl) internal override onlyOwner {
    require(upgradeScheduled[newImpl] != 0, "Not scheduled");
    require(block.timestamp >= upgradeScheduled[newImpl], "Too early");
    delete upgradeScheduled[newImpl];
}

This gives users time to exit before an upgrade takes effect -- a meaningful security property for user funds.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

What tech stack does Innovibe use for projects like this?+

Next.js + TypeScript on the frontend, Node.js or Go on the backend, Postgres for the primary data store, and GCP (Cloud Run, BigQuery, Pub/Sub) for infrastructure. We pick tools that are boring in the best way — proven, well-documented, and easy to hire for.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation