DEV Community

Cover image for Day 17 of #30DaysOfSolidity — Build an Upgradeable Subscription Manager for Your SaaS dApp
Saurav Kumar
Saurav Kumar

Posted on

Day 17 of #30DaysOfSolidity — Build an Upgradeable Subscription Manager for Your SaaS dApp

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         |
+------------------+           +------------------------+
Enter fullscreen mode Exit fullscreen mode

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()) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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); }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode
  • Run:
npx hardhat run scripts/deploy.js --network localhost
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
  • 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

  1. Storage safety: avoid collisions (EIP-1967 or unstructured storage)
  2. Access control: admin-only upgrades, consider multisig/timelock
  3. Payable functions: withdraw only via admin
  4. Delegatecall awareness: msg.sender = caller, address(this) = proxy
  5. 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)