Introduction
Modern Web3 apps need upgradeability just like traditional SaaS apps. Users shouldn’t lose data or need to reinstall dApps whenever new features or bug fixes are released.
In this tutorial, we’ll build an upgradeable subscription manager using the proxy pattern and delegatecall
in Solidity. This approach separates storage from logic, enabling seamless upgrades while preserving user subscription data.
By the end, you’ll understand:
- How to design upgrade-safe contracts
- How to separate storage and logic
- How to add/upgrade subscription plans without migrating data
- Best practices for proxy-based dApps
Why Upgradeable Contracts?
Normal smart contracts are immutable. Once deployed, changing logic requires deploying a new contract. This is a major problem for SaaS dApps:
- Users lose their subscription data if a new contract is deployed
- Requiring users to "switch" contracts is bad UX
- Hard to fix bugs or add features in production
The solution: Proxy + Logic pattern
- Proxy contract: stores all data (plans, user subscriptions, expiry, paused status)
-
Logic contract: contains the functions to manage subscriptions (
addPlan
,subscribe
,renew
,pauseAccount
) - Proxy uses
delegatecall
to execute logic in its own storage context
Upgrades are simple: deploy a new logic contract and point the proxy to it. Data stays intact.
Architecture Overview
+------------------+ +------------------------+
| Proxy Contract | <---> | Subscription Logic V1 |
|-----------------| delegatecall |--------------------|
| Storage: | | Functions: |
| - Plans | | - addPlan |
| - Users | | - subscribe |
| - Expiry | | - renew |
| - Paused flags | | - pauseAccount |
+------------------+ +------------------------+
Key Notes
- Use EIP-1967 style storage slots for admin & implementation to avoid storage collisions
- Keep all app state in the proxy
- Logic contract must not declare storage variables that conflict with proxy
Solidity Implementation
1) Upgradeable Proxy Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract UpgradeableProxy {
bytes32 private constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
bytes32 private constant ADMIN_SLOT = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
struct Plan { uint256 price; uint256 duration; bool active; }
mapping(uint256 => Plan) private _plans;
uint256 private _nextPlanId;
mapping(address => mapping(uint256 => uint256)) private _userExpiry;
mapping(address => bool) private _paused;
event Upgraded(address indexed implementation);
event AdminChanged(address indexed previousAdmin, address indexed newAdmin);
constructor(address admin_, address implementation_) {
_setAdmin(admin_);
_setImplementation(implementation_);
}
function _setAdmin(address newAdmin) internal { assembly { sstore(ADMIN_SLOT, newAdmin) } emit AdminChanged(address(0), newAdmin); }
function _getAdmin() internal view returns (address adm) { assembly { adm := sload(ADMIN_SLOT) } }
function _setImplementation(address newImpl) internal { assembly { sstore(IMPLEMENTATION_SLOT, newImpl) } emit Upgraded(newImpl); }
function _getImplementation() internal view returns (address impl) { assembly { impl := sload(IMPLEMENTATION_SLOT) } }
function upgradeTo(address newImplementation) external { require(msg.sender == _getAdmin(), "not admin"); _setImplementation(newImplementation); }
fallback() external payable { _delegate(_getImplementation()); }
receive() external payable { _delegate(_getImplementation()); }
function _delegate(address implementation) internal {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) }
}
}
}
2) Subscription Logic V1 Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract SubscriptionManagerLogicV1 {
struct Plan { uint256 price; uint256 duration; bool active; }
mapping(uint256 => Plan) private _plans;
uint256 private _nextPlanId;
mapping(address => mapping(uint256 => uint256)) private _userExpiry;
mapping(address => bool) private _paused;
event PlanAdded(uint256 indexed planId, uint256 price, uint256 duration);
event Subscribed(address indexed user, uint256 indexed planId, uint256 expiry);
event Paused(address indexed user, bool paused);
function addPlan(uint256 price, uint256 duration) external { /* admin check */ }
function subscribe(uint256 planId) external payable { /* logic */ }
function renew(uint256 planId) external payable { /* logic */ }
function setPaused(bool paused) external { _paused[msg.sender] = paused; emit Paused(msg.sender, paused); }
}
⚠️ In production, always match storage layout between proxy and logic. Consider unstructured storage or a shared Storage contract.
Step-by-Step Implementation Guide
Step 1: Setup Hardhat Environment
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox ethers
npx hardhat
- Choose “Create an empty Hardhat config”
Step 2: Create Contracts
-
contracts/UpgradeableProxy.sol
→ paste proxy code -
contracts/SubscriptionManagerLogicV1.sol
→ paste logic code
Step 3: Deployment Script (scripts/deploy.js
)
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
const Logic = await ethers.getContractFactory("SubscriptionManagerLogicV1");
const logic = await Logic.deploy();
await logic.deployed();
const Proxy = await ethers.getContractFactory("UpgradeableProxy");
const proxy = await Proxy.deploy(deployer.address, logic.address);
await proxy.deployed();
console.log("Logic:", logic.address);
console.log("Proxy:", proxy.address);
}
main().catch(console.error);
- Run:
npx hardhat run scripts/deploy.js --network localhost
Step 4: Frontend / Ethers.js Interaction
const proxyAddress = "0xProxyAddressHere";
const abi = ["function subscribe(uint256) payable", "function addPlan(uint256,uint256)"];
const contract = new ethers.Contract(proxyAddress, abi, signer);
// Admin adds a plan
await contract.addPlan(ethers.utils.parseEther("0.1"), 30*24*3600);
// User subscribes
await contract.subscribe(0, { value: ethers.utils.parseEther("0.1") });
// Check expiry
const expiry = await contract.getUserExpiry(userAddress, 0);
Step 5: Upgrade Logic Contract
const LogicV2 = await ethers.getContractFactory("SubscriptionManagerLogicV2");
const logicV2 = await LogicV2.deploy();
await logicV2.deployed();
const proxyAdminAbi = ["function upgradeTo(address)"];
const proxyAdmin = new ethers.Contract(proxyAddress, proxyAdminAbi, signer);
await proxyAdmin.upgradeTo(logicV2.address);
- Now all calls use V2 logic without losing user data.
Step 6: Test Persistence
- Subscribe as user → Upgrade → Check
getUserExpiry
- ✅ Values remain intact, upgrade successful
Step 7: Best Practices
- Storage safety: avoid collisions (EIP-1967 or unstructured storage)
- Access control: admin-only upgrades, consider multisig/timelock
- Payable functions: withdraw only via admin
-
Delegatecall awareness:
msg.sender
= caller,address(this)
= proxy - Events: emitted from proxy address
Next Steps & Extensions
- Add trial periods, coupons, or ERC20 payments
- Build frontend dashboards showing active plans and expiry
- Implement emergency pause / circuit breaker
- Add signature-based meta-transactions for gasless subscriptions
Conclusion
This tutorial demonstrates a future-proof SaaS subscription manager on Ethereum:
- Users never lose data
- Admins can upgrade features seamlessly
- Patterns mirror real-world SaaS apps
Mastering proxy + delegatecall upgrade patterns is essential for any serious Web3 developer.
🔗 Check the full #30DaysOfSolidity series: https://dev.to/sauravkumar8178
💡 Pro tip: Always test upgrades and storage layouts before deploying real funds.
Top comments (0)