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:
Setting up the initial state of the contract
Running the constructor code
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:
The proxy contract maintains the state (storage)
The implementation contract provides the logic (code)
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:
The initializer function is part of the runtime code
It can be called through the proxy
It runs in the proxy's storage context
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://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
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
