Diamond Storage Beyond Diamonds: A Practical Guide to Upgradeable Proxy Contracts
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.
solidity1// 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:
solidity1// 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:
solidity1contract 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
solidity1contract 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:
solidity1contract 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
solidity1// 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
Aspect | Diamond Storage | External Storage Contract |
---|---|---|
Gas Efficiency | ~200 gas per storage access | ~2,100+ gas per external call |
Atomic Operations | All operations in single transaction | Risk of partial state updates |
Access Control | Handled in library functions | Requires complex authorization system |
Code Complexity | Clean, library-based | Requires getter/setter for every field |
Storage Layout Control | Full control via struct | Limited by contract boundaries |
1. Gas Efficiency Comparison
solidity1// 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:
solidity1// 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:
solidity1// 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:
solidity1// 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:
solidity1contract 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:
solidity1// Natural, struct-based access 2AppStorage storage ds = appStorage(); 3ds.subscriptions[user].expiry += duration; 4
External storage requires verbose getter/setter patterns:
solidity1// 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:
solidity1// 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:
solidity1// 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:
solidity1contract 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:
solidity1// ❌ 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:
solidity1// ❌ 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
- Diamond Storage
- EIP-2535 Diamond Standard
- Foundry Book: Advanced Testing
- OpenZeppelin Proxy Patterns
Want to see more practical Solidity patterns? Follow me on LinkedIn or check out my projects on GitHub.