Why Constructors Cannot Be Used in Implementation Contracts: A Deep Dive

To understand why constructors pose a problem in implementation contracts, we need to first understand how contract deployment works and then see how this interacts with the proxy pattern. Let's build this understanding step by step.

The Contract Creation Process

When you deploy a smart contract, the process involves two distinct phases: the creation phase and the runtime phase. Think of this like building a house - first you construct it (creation phase), and then people live in it (runtime phase).

The Creation Phase

During the creation phase, the Ethereum Virtual Machine (EVM) executes what we call the "creation code." This code has several responsibilities:

  1. Setting up the initial state of the contract

  2. Running the constructor code

  3. Returning the actual contract code(run time code) that will live on the blockchain

The constructor is a special function that runs only once, during this creation phase. It's like the construction crew that sets up your house - once they're done, they leave, and their tools and equipment go with them.

The Runtime Phase

After the creation phase is complete, only the runtime code remains on the blockchain. This runtime code is what handles all future interactions with the contract. The constructor code is not included in this runtime code—it's already done its job and is discarded.

The Proxy Pattern and Why It Matters

Now, let's understand how upgradeability works with the proxy pattern. Imagine you have two contracts:

// The Proxy Contract
contract Proxy {
    address public implementation;

    function upgradeTo(address newImplementation) external {
        implementation = newImplementation;
    }

    fallback() external {
        // Forwards calls to the implementation contract
        (bool success,) = implementation.delegatecall(msg.data);
        require(success);
    }
}

// The Implementation Contract
contract Implementation {
    uint256 public value;

    constructor(uint256 _value) {
        value = _value;  // This will never work as expected!
    }
}

Here's where the problem begins. When you use a proxy pattern:

  1. The proxy contract maintains the state (storage)

  2. The implementation contract provides the logic (code)

  3. All calls go through the proxy using delegatecall

The Core Problem

The fundamental issue lies in how constructors work with proxies. Let's break down why:

1. Constructor Execution Timing

When you deploy an implementation contract:

  • Its constructor runs immediately during deployment

  • The state changes happen in the implementation contract's storage

  • But this storage is never used because all calls go through the proxy

Here's what actually happens:

// What you might expect:
Proxy proxy = new Proxy();
Implementation impl = new Implementation(100);
proxy.upgradeTo(address(impl));
// You might expect 'value' to be 100 when accessed through the proxy

// What actually happens:
// 1. Implementation contract gets its own storage with value = 100
// 2. Proxy points to Implementation
// 3. Proxy's storage for 'value' remains uninitialized (0)

2. Storage Context

When using delegatecall:

  • The code from the implementation contract runs in the context of the proxy's storage

  • The constructor has already run in a different storage context

  • Any state set in the constructor is essentially lost

3. One-Time Execution

Constructors can only run once, during contract creation. This means:

  • You can't reinitialize the contract through the proxy

  • The constructor's code is not available in the runtime code

  • There's no way to trigger the constructor logic through the proxy

The Solution: Initialize Functions

Instead of constructors, upgradeable contracts use initializer functions:

contract UpgradeableImplementation {
    uint256 public value;
    bool private initialized;

    function initialize(uint256 _value) public {
        require(!initialized, "Already initialized");
        initialized = true;
        value = _value;  // This works!
    }
}

This works because:

  1. The initializer function is part of the runtime code

  2. It can be called through the proxy

  3. It runs in the proxy's storage context

  4. It includes guards against multiple initializations

Real-World Example

Let's see a complete example of how this works in practice:

// Implementation V1
contract ImplementationV1 {
    uint256 public value;
    bool private initialized;

    // Instead of constructor(uint256 _value)
    function initialize(uint256 _value) public {
        require(!initialized, "Already initialized");
        initialized = true;
        value = _value;
    }
}

// Implementation V2
contract ImplementationV2 {
    uint256 public value;
    bool private initialized;

    function initialize(uint256 _value) public {
        require(!initialized, "Already initialized");
        initialized = true;
        value = _value * 2;  // New logic
    }

    function doubleValue() public {
        value *= 2;
    }
}

// Deployment and upgrade process:
1. Deploy Proxy
2. Deploy ImplementationV1
3. Point proxy to ImplementationV1
4. Initialize through proxy
5. Later: Deploy ImplementationV2
6. Upgrade proxy to point to ImplementationV2

Therefore, constructor logic, code, and values are strictly part of the creation code and vanish after deployment, while initializer logic, code, and values remain as part of the runtime code, making them suitable for upgradeable contracts using proxy patterns. When a proxy delegates calls to an implementation contract, it can only work with what exists in the runtime code.

Important Considerations

When working with upgradeable contracts, there are several critical aspects to keep in mind beyond just avoiding constructors:

1. Storage Pattern Rigidity

The storage layout in upgradeable contracts must be treated with extreme care. Think of your contract's storage like a giant warehouse where each item has its specific shelf - you can't simply reorganize the shelves without losing track of what's stored where. In practice, this means:

// Original contract
contract ImplementationV1 {
    uint256 public value;
    address public owner;
}

// WRONG - This will cause storage collision
contract ImplementationV2 {
    address public owner;    // Wrong! Changed order
    uint256 public value;
    uint256 public newValue;
}

// CORRECT - Maintains storage layout
contract ImplementationV2 {
    uint256 public value;    // Same order as V1
    address public owner;    // Same order as V1
    uint256 public newValue; // New variables added at the end
}

When you change the order or type of state variables, the proxy contract will read and write data from incorrect storage slots, leading to corrupted state and potentially catastrophic failures.

2. Function Selector Clashes

Transparent proxies face a unique challenge with function naming. Imagine a scenario where both your proxy and implementation contracts have functions with the same name:

// Proxy Contract
contract TransparentProxy {
    function upgradeTo(address newImplementation) public {
        // Upgrade logic
    }
}

// Implementation Contract
contract Implementation {
    function upgradeTo(address newValue) public {
        // Some completely different logic
    }
}

This creates ambiguity: when someone calls upgradeTo, which function should execute? It's like having two different departments in a company with the same phone extension - calls could get routed to the wrong place. This is why transparent proxy patterns implement sophisticated function selector handling to prevent such conflicts.

3. The Dangers of Self-Destruction

Using selfdestruct in implementation contracts is like setting up a self-destruct button in a building that's being used by multiple tenants - if anyone presses it, everyone loses their home. Here's why it's dangerous:

contract DangerousImplementation {
    function shutdown() public {
        selfdestruct(payable(msg.sender));
    }
}

// What happens:
// 1. Someone calls shutdown() directly on the implementation
// 2. Implementation contract is destroyed
// 3. ALL proxies pointing to this implementation now point to dead code
// 4. No recovery possible without redeploying implementation

Instead of using selfdestruct, implement proper deactivation patterns:

contract SafeImplementation {
    bool public active = true;

    function deactivate() public {
        active = false;
    }

    function someFunction() public {
        require(active, "Contract is deactivated");
        // Function logic
    }
}

4. Initialization Guards

Protect against multiple initializations to prevent state corruption:

contract SecureImplementation {
    bool private initialized;

    function initialize() public {
        require(!initialized, "Already initialized");
        initialized = true;
        // Initialization logic
    }
}

By understanding these concepts, you can see why constructors don't work with implementation contracts and how to properly initialize upgradeable contracts instead.
Remember that upgradeability adds significant complexity to your smart contracts, so these considerations should be part of your design process from the very beginning.

References:

https://blog.openzeppelin.com/deconstructing-a-solidity-smart-contract-part-i-introduction-832efd2d7737

https://blog.openzeppelin.com/deconstructing-a-solidity-contract-part-ii-creation-vs-runtime-6b9d60ecb44c

https://ethereum.stackexchange.com/questions/76334/what-is-the-difference-between-bytecode-init-code-deployed-bytecode-creation

https://docs.soliditylang.org/en/latest/contracts.html#constant-state-variables https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable https://docs.openzeppelin.com/upgrades-plugins/proxies

https://mirror.xyz/0x31523909f39ce9365c2e7f3cc865c27c5d43923c/u4iCaxsN6oQ4q508MkReWNlh4IQqciHh_luhB82GyQc

1
Subscribe to my newsletter

Read articles from Adewale Iyanuoluwa Isaac directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Adewale Iyanuoluwa Isaac
Adewale Iyanuoluwa Isaac