Diamond Storage Beyond Diamonds: A Practical Guide to Upgradeable Proxy Contracts

10 min read

How to leverage Diamond Storage pattern for any proxy contract architecture, not just EIP-2535 Diamonds

Introduction

When developers hear "Diamond Storage," they immediately think of EIP-2535 Diamond contracts. While it's true that Diamond Storage was popularized by the Diamond standard, it's actually a powerful storage management technique that can enhance any proxy contract pattern. Today, I'll show you how to build a robust, upgradeable subscription system using Diamond Storage with a simple proxy pattern—no complex Diamond architecture required.

What Is Diamond Storage, Really?

Diamond Storage is fundamentally about deterministic storage slot allocation. Instead of relying on Solidity's automatic storage layout (which can cause collisions in proxy contracts), Diamond Storage uses keccak256 hashes to create unique, predetermined storage locations.

solidity
1// Traditional storage (collision-prone in proxies)
2contract BadProxy {
3    address owner;        // slot 0
4    uint256 balance;      // slot 1
5    // Adding variables here shifts everything!
6}
7
8// Diamond Storage (collision-resistant)
9library LibStorage {
10    bytes32 constant STORAGE_POSITION = keccak256("my.app.storage");
11
12    struct AppStorage {
13        address owner;
14        uint256 balance;
15        // Adding variables here is safe!
16    }
17
18    function appStorage() internal pure returns (AppStorage storage ds) {
19        bytes32 position = STORAGE_POSITION;
20        assembly {
21            ds.slot := position
22        }
23    }
24}
25

Building a Subscription Proxy with Diamond Storage

Let's build a real-world example: a subscription service that needs to be upgradeable while maintaining state across versions.

The Storage Library

Our storage library centralizes all state management and business logic:

solidity
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.19;
3
4library LibSubscription {
5    bytes32 constant APP_STORAGE_POSITION = keccak256("myproject.subscription.app.storage");
6
7    struct Subscription {
8        uint8 planId;
9        uint256 expiry;
10        bool paused;
11    }
12
13    struct Plan {
14        string name;
15        uint256 price;
16        uint256 duration;
17    }
18
19    struct AppStorage {
20        address logicContract;
21        address owner;
22        uint8 planCounter;
23        mapping(address => Subscription) subscriptions;
24        mapping(uint8 => Plan) plans;
25        mapping(uint8 => bool) planActive;
26        mapping(bytes32 => bool) planNameExists;
27        mapping(address => uint256) refundBalances;
28    }
29
30    function appStorage() internal pure returns (AppStorage storage ds) {
31        bytes32 position = APP_STORAGE_POSITION;
32        assembly {
33            ds.slot := position
34        }
35    }
36
37    // Essential proxy functions
38    function initializeContract(address _owner, address _logicContract) internal {
39        AppStorage storage ds = appStorage();
40        if (ds.owner != address(0)) revert AlreadyInitialized();
41        ds.owner = _owner;
42        ds.logicContract = _logicContract;
43    }
44
45    function upgradeTo(address _newLogic) internal {
46        if (_newLogic == address(0)) revert ZeroAddressNotAllowed();
47        enforceIsContractOwner();
48
49        AppStorage storage ds = appStorage();
50        if (_newLogic == ds.logicContract) revert SameLogicContract();
51
52        address previousLogic = ds.logicContract;
53        ds.logicContract = _newLogic;
54        emit LogicContractUpgraded(previousLogic, _newLogic);
55    }
56
57    function logicContract() internal view returns (address) {
58        return appStorage().logicContract;
59    }
60
61    // Utility functions
62    function enforceIsContractOwner() internal view {
63        if (msg.sender != appStorage().owner) revert NotOwner(msg.sender, appStorage().owner);
64    }
65
66    function planExists(uint8 _planId) internal view returns (bool) {
67        AppStorage storage ds = appStorage();
68        return _planId > 0 && _planId <= ds.planCounter && ds.planActive[_planId];
69    }
70
71    // ... other business logic functions
72}
73

The Proxy Contract

Our proxy is surprisingly simple:

solidity
1contract Subscription {
2    constructor(address _logicContract) {
3        LibSubscription.initializeContract(msg.sender, _logicContract);
4    }
5
6    function upgradeLogic(address _newLogic) external {
7        LibSubscription.upgradeTo(_newLogic);
8    }
9
10    fallback() external payable {
11        address logicContract = LibSubscription.logicContract();
12
13        assembly {
14            calldatacopy(0, 0, calldatasize())
15            let result := delegatecall(gas(), logicContract, 0, calldatasize(), 0, 0)
16            returndatacopy(0, 0, returndatasize())
17
18            switch result
19            case 0 { revert(0, returndatasize()) }
20            default { return(0, returndatasize()) }
21        }
22    }
23}
24

Version 1: Basic Functionality

solidity
1contract SubscriptionLogicV1 {
2    function subscribe(uint8 planId) external payable {
3        if (!LibSubscription.planExists(planId)) revert PlanNotFound();
4
5        LibSubscription.AppStorage storage ds = LibSubscription.appStorage();
6        LibSubscription.Plan memory plan = ds.plans[planId];
7
8        if (msg.value < plan.price) revert InsufficientPayment();
9
10        // Handle subscription logic...
11        bool hasActiveSubscription = block.timestamp < ds.subscriptions[msg.sender].expiry;
12        if (hasActiveSubscription) {
13            ds.subscriptions[msg.sender].expiry += plan.duration;
14        } else {
15            ds.subscriptions[msg.sender] = LibSubscription.Subscription({
16                planId: planId,
17                expiry: block.timestamp + plan.duration,
18                paused: false
19            });
20        }
21
22        emit Subscribed(msg.sender, planId);
23    }
24}
25

Version 2: Adding Admin Controls

Here's where Diamond Storage really shines. We can add new functionality without breaking existing state:

solidity
1contract SubscriptionLogicV2 {
2    // All V1 functions remain unchanged...
3
4    // NEW: Admin pause functionality
5    function pauseUserSubscription(address user) external {
6        LibSubscription.enforceIsContractOwner();
7
8        LibSubscription.AppStorage storage ds = LibSubscription.appStorage();
9        LibSubscription.Subscription storage subscription = ds.subscriptions[user];
10
11        if (block.timestamp >= subscription.expiry) revert UserNotSubscribed();
12        if (subscription.paused) revert SubscriptionAlreadyPaused();
13
14        subscription.paused = true;
15        emit SubscriptionPaused(user, msg.sender);
16    }
17}
18

Diamond Storage vs. External Storage Contracts

Before diving into why this approach works, let's compare it with the alternative: using a separate storage contract.

Alternative Approach: External Storage Contract

solidity
1// Storage contract approach
2contract SubscriptionStorage {
3    address public owner;
4    uint8 public planCounter;
5    mapping(address => Subscription) public subscriptions;
6    mapping(uint8 => Plan) public plans;
7    // ... other storage
8
9    modifier onlyAuthorized() {
10        require(authorizedContracts[msg.sender], "Not authorized");
11        _;
12    }
13
14    function setSubscription(address user, Subscription memory sub)
15        external onlyAuthorized {
16        subscriptions[user] = sub;
17    }
18}
19
20// Logic contract using external storage
21contract SubscriptionLogicV1 {
22    SubscriptionStorage public immutable storageContract;
23
24    constructor(address _storage) {
25        storageContract = SubscriptionStorage(_storage);
26    }
27
28    function subscribe(uint8 planId) external payable {
29        // Must call external contract for every storage operation
30        Plan memory plan = storageContract.plans(planId);
31
32        // Complex state updates require multiple external calls
33        storageContract.setSubscription(msg.sender, newSubscription);
34    }
35}
36

Why Diamond Storage Is Superior

AspectDiamond StorageExternal Storage Contract
Gas Efficiency~200 gas per storage access~2,100+ gas per external call
Atomic OperationsAll operations in single transactionRisk of partial state updates
Access ControlHandled in library functionsRequires complex authorization system
Code ComplexityClean, library-basedRequires getter/setter for every field
Storage Layout ControlFull control via structLimited by contract boundaries

1. Gas Efficiency Comparison

solidity
1// Diamond Storage: Direct storage access
2function updateSubscription() internal {
3    AppStorage storage ds = appStorage();  // ~20 gas
4    ds.subscriptions[user].expiry = newExpiry;  // ~5,000 gas
5    ds.subscriptions[user].paused = false;      // ~5,000 gas
6    // Total: ~10,020 gas
7}
8
9// External Storage: Multiple contract calls
10function updateSubscription() external {
11    storageContract.setExpiry(user, newExpiry);     // ~21,000 gas
12    storageContract.setPaused(user, false);         // ~21,000 gas
13    // Total: ~42,000 gas (4x more expensive!)
14}
15

2. Atomic Operations

Diamond Storage ensures atomicity:

solidity
1// Diamond Storage: All-or-nothing updates
2function subscribe(uint8 planId) external payable {
3    AppStorage storage ds = appStorage();
4
5    // If any operation fails, entire transaction reverts
6    ds.subscriptions[msg.sender].expiry = block.timestamp + duration;
7    ds.subscriptions[msg.sender].planId = planId;
8    ds.refundBalances[msg.sender] += overpayment;
9    // All updates succeed or all fail together
10}
11

External storage requires careful orchestration:

solidity
1// External Storage: Risk of partial updates
2function subscribe(uint8 planId) external payable {
3    // What if first call succeeds but second fails?
4    storageContract.setExpiry(msg.sender, block.timestamp + duration);  // ✅ Success
5    storageContract.setPlanId(msg.sender, planId);  // ❌ Might fail, leaving inconsistent state
6}
7

3. Access Control Complexity

Diamond Storage with built-in access control:

solidity
1// Clean, integrated access control
2function createPlan(string memory name, uint256 price, uint256 duration)
3    internal returns (uint8) {
4    enforceIsContractOwner();  // Single point of control
5    // Direct storage access with no external dependencies
6    AppStorage storage ds = appStorage();
7    ds.plans[planId] = Plan(name, price, duration);
8}
9

External storage requires complex authorization:

solidity
1contract SubscriptionStorage {
2    mapping(address => bool) public authorizedContracts;
3    mapping(address => mapping(bytes4 => bool)) public methodPermissions;
4
5    modifier onlyAuthorized(bytes4 method) {
6        require(
7            authorizedContracts[msg.sender] &&
8            methodPermissions[msg.sender][method],
9            "Not authorized"
10        );
11        _;
12    }
13
14    function setPlan(uint8 planId, Plan memory plan)
15        external onlyAuthorized(this.setPlan.selector) {
16        plans[planId] = plan;
17    }
18
19    // Need separate functions for every storage operation!
20    function setExpiry(address user, uint256 expiry) external onlyAuthorized(this.setExpiry.selector) { ... }
21    function setPlanId(address user, uint8 planId) external onlyAuthorized(this.setPlanId.selector) { ... }
22    function setPaused(address user, bool paused) external onlyAuthorized(this.setPaused.selector) { ... }
23}
24

4. Developer Experience

Diamond Storage feels like working with regular Solidity:

solidity
1// Natural, struct-based access
2AppStorage storage ds = appStorage();
3ds.subscriptions[user].expiry += duration;
4

External storage requires verbose getter/setter patterns:

solidity
1// Verbose, error-prone
2uint256 currentExpiry = storageContract.getExpiry(user);
3storageContract.setExpiry(user, currentExpiry + duration);
4

5. Upgrade Flexibility

Both approaches handle upgrades, but Diamond Storage is more flexible:

solidity
1// Diamond Storage: Easy to add new fields
2struct AppStorage {
3    // V1 fields
4    mapping(address => Subscription) subscriptions;
5
6    // V2 addition: Just append to struct
7    mapping(address => uint256) loyaltyPoints;
8
9    // V3 addition: Another append
10    mapping(address => bool) premiumMembers;
11}
12

External storage requires new contract deployments or complex versioning:

solidity
1// External Storage: Need new contracts or complex inheritance
2contract SubscriptionStorageV2 is SubscriptionStorageV1 {
3    mapping(address => uint256) public loyaltyPoints;
4    // Risk of storage collisions with inheritance
5}
6

Why This Approach Works

1. Gas Efficiency

Diamond Storage operations are 3-4x cheaper than external storage calls because they use direct storage access instead of cross-contract calls.

2. Atomic Guarantees

Complex state updates happen atomically within a single contract context, eliminating the risk of partial state corruption.

3. Simplified Access Control

Access control logic lives in the library alongside business logic, eliminating the need for complex authorization systems.

4. Collision-Free

The keccak256 hash ensures our storage slot is unique and won't conflict with future Solidity compiler changes or proxy implementations.

5. Developer Experience

Working with Diamond Storage feels like regular Solidity development, while external storage requires verbose getter/setter patterns.

6. Testing-Friendly

Each component can be tested independently, and Foundry's state management makes upgrade testing straightforward.

Testing with Foundry

Here's how you might test state persistence across upgrades:

solidity
1contract SubscriptionUpgradeTest is Test {
2    Subscription proxy;
3    SubscriptionLogicV1 logicV1;
4    SubscriptionLogicV2 logicV2;
5
6    function testUpgradePreservesState() public {
7        // Deploy and set up V1
8        logicV1 = new SubscriptionLogicV1();
9        proxy = new Subscription(address(logicV1));
10
11        // Create subscription with V1
12        vm.deal(alice, 1 ether);
13        vm.prank(alice);
14        SubscriptionLogicV1(address(proxy)).subscribe{value: 0.1 ether}(1);
15
16        // Verify subscription exists
17        assertTrue(SubscriptionLogicV1(address(proxy)).isActiveSubscriber(alice));
18
19        // Upgrade to V2
20        logicV2 = new SubscriptionLogicV2();
21        proxy.upgradeLogic(address(logicV2));
22
23        // Verify state preserved
24        assertTrue(SubscriptionLogicV2(address(proxy)).isActiveSubscriber(alice));
25
26        // Test new V2 functionality
27        proxy.pauseUserSubscription(alice);
28        assertFalse(SubscriptionLogicV2(address(proxy)).isActiveSubscriber(alice));
29    }
30}
31

Gas Considerations

Diamond Storage does have a small gas overhead due to the assembly code, but it's minimal:

  • Storage access: ~20 additional gas per operation
  • Deployment: Slightly larger bytecode due to library imports
  • Upgrade safety: Priceless 😉

When to Use This Pattern

This approach is ideal when you need:

  • Upgradeable contracts with complex state
  • Multiple contract versions sharing the same storage
  • Modular architecture with reusable components
  • Testing isolation between proxy and logic layers

Common Pitfalls to Avoid

1. Storage Layout Changes

Never reorder fields in your AppStorage struct. Always append new fields:

solidity
1// ❌ Bad: Reordering existing fields
2struct AppStorage {
3    uint8 planCounter;    // Moved!
4    address owner;        // This breaks everything
5    // ...
6}
7
8// ✅ Good: Appending new fields
9struct AppStorage {
10    address owner;        // Original position
11    uint8 planCounter;    // Original position
12    bool newFeature;      // Added at the end
13    // ...
14}
15

2. Hash Collisions

Use descriptive, unique strings for your storage position:

solidity
1// ❌ Bad: Generic hash
2bytes32 constant STORAGE_POSITION = keccak256("storage");
3
4// ✅ Good: Specific to your project
5bytes32 constant STORAGE_POSITION = keccak256("mycompany.subscription.v1.storage");
6

Conclusion

Diamond Storage isn't just for Diamond contracts—it's a powerful pattern for any upgradeable proxy architecture. By centralizing storage management in a library and using deterministic slot allocation, you can build robust, upgradeable systems that preserve state across versions.

The subscription system we built demonstrates how this pattern enables clean separation of concerns while maintaining upgrade safety. Whether you're building a simple proxy or a complex multi-faceted system, Diamond Storage provides the foundation for reliable, long-term contract evolution.

The complete source code for this subscription system can be found on GitHub here.

Further Reading


Want to see more practical Solidity patterns? Follow me on LinkedIn or check out my projects on GitHub.

← Back to Blog Page