Chapter 12 · Article 53 of 55

Designing a Digital Wallet System

Design a digital wallet system (similar to PayTM Wallet, Google Pay balance, or Apple Pay Cash) that allows users to store money digitally, transfer funds peer-to-peer, pay merc…

Article outline15 sections on this page

Problem Statement

Design a digital wallet system (similar to PayTM Wallet, Google Pay balance, or Apple Pay Cash) that allows users to store money digitally, transfer funds peer-to-peer, pay merchants, and maintain a complete transaction history. The system must guarantee financial consistency - no money is created or destroyed during transfers - while handling millions of concurrent transactions with low latency.

The core challenge lies in managing concurrent access to shared mutable state (wallet balances) in a distributed environment without sacrificing correctness or performance.


Requirements

Functional Requirements

  1. Add Money - Load funds into wallet from linked bank account, debit card, or credit card
  2. Send Money (P2P Transfer) - Transfer funds from one wallet to another user's wallet
  3. Pay Merchants - Make payments to registered merchants for goods/services
  4. Check Balance - View current available and pending balance in real-time
  5. Transaction History - View paginated list of all past transactions with filters (date, type, status)
  6. Refunds - Process full or partial refunds for failed/disputed transactions
  7. Wallet Limits - Enforce daily, monthly, and per-transaction limits based on KYC tier
  8. KYC Levels - Support tiered access (minimum KYC = low limits, full KYC = higher limits)

Non-Functional Requirements

  1. Strong Consistency - No double-spending; balance must never go negative unintentionally
  2. High Availability - 99.99% uptime; wallet operations should not have extended downtime
  3. Low Latency - Balance checks < 50ms, transfers < 500ms end-to-end
  4. Audit Trail - Every balance mutation must be traceable with immutable logs
  5. Security - Encryption at rest and in transit, fraud detection, rate limiting
  6. Concurrent Transaction Handling - Multiple simultaneous debits from the same wallet must be serialized correctly

Constraints & Assumptions

  • Single currency per wallet (multi-currency is an extensibility point)
  • Daily limit: ₹10,000 for minimum KYC, ₹1,00,000 for full KYC
  • Monthly limit: ₹25,000 for minimum KYC, ₹5,00,000 for full KYC
  • Per-transaction limit: ₹5,000 (minimum KYC), ₹50,000 (full KYC)
  • Minimum balance: ₹0 (no overdraft facility)
  • Transaction fees: 0% for P2P, 1-2% for merchant payments (borne by merchant)
  • Maximum wallet balance: ₹10,000 (minimum KYC), ₹1,00,000 (full KYC) as per RBI guidelines
  • All amounts stored as integers in smallest currency unit (paise) to avoid floating-point errors
  • Idempotency keys required for all write operations

Key Entities & Relationships

EntityDescription
UserAccount holder with profile, auth credentials, KYC status
WalletMoney container linked 1:1 with User; holds balance and version
TransactionImmutable record of money movement (Credit, Debit, Transfer, Refund)
BalanceCurrent available amount + held/blocked amount
TransactionLogAppend-only audit log of every state change
PaymentMethodLinked bank/card used to load money
MerchantBusiness entity that accepts wallet payments
KYCLevelTier (MINIMUM, FULL) determining limits and features
LedgerDouble-entry bookkeeping record (every transaction = debit entry + credit entry)

Relationships:

  • User 1:1 Wallet
  • Wallet 1:N Transaction
  • Transaction 1:2 LedgerEntry (debit + credit)
  • User 1:N PaymentMethod
  • User 1:1 KYCLevel
  • Transaction N:1 Merchant (for merchant payments)

ASCII Class Diagram

┌─────────────────┐       1:1        ┌─────────────────────┐
│      User       │─────────────────▶│       Wallet        │
├─────────────────┤                  ├─────────────────────┤
│ userId          │                  │ walletId            │
│ name            │                  │ userId              │
│ email           │                  │ balance (paise)     │
│ phone           │                  │ heldAmount          │
│ kycLevel        │                  │ version             │
│ status          │                  │ currency            │
│ createdAt       │                  │ status (ACTIVE/     │
└─────────────────┘                  │         FROZEN)     │
        │                            │ dailySpent          │
        │ 1:N                        │ monthlySpent        │
        ▼                            └─────────────────────┘
┌─────────────────┐                           │ 1:N
│  PaymentMethod  │                           ▼
├─────────────────┤                  ┌─────────────────────┐
│ methodId        │                  │    Transaction      │
│ userId          │                  ├─────────────────────┤
│ type (BANK/CARD)│                  │ txnId               │
│ details         │                  │ walletId            │
│ isDefault       │                  │ type (CREDIT/DEBIT/ │
└─────────────────┘                  │       TRANSFER/     │
                                     │       REFUND)       │
                                     │ amount              │
                                     │ status (PENDING/    │
                                     │   SUCCESS/FAILED/   │
                                     │   REVERSED)         │
                                     │ idempotencyKey      │
                                     │ counterpartyId      │
                                     │ description         │
                                     │ createdAt           │
                                     └─────────────────────┘
                                              │ 1:2
                                              ▼
                                     ┌─────────────────────┐
                                     │    LedgerEntry      │
                                     ├─────────────────────┤
                                     │ entryId             │
                                     │ txnId               │
                                     │ accountId           │
                                     │ entryType (DR/CR)   │
                                     │ amount              │
                                     │ balanceAfter        │
                                     │ createdAt           │
                                     └─────────────────────┘

Types of Locking Mechanisms

This is the most critical design concern for a digital wallet. When multiple requests attempt to modify the same wallet balance concurrently, we need a mechanism to prevent race conditions, lost updates, and double-spending. The choice of locking strategy directly impacts system throughput, consistency, and user experience.

Optimistic Locking

How It Works:

Optimistic locking assumes conflicts are rare. Every wallet row carries a version number (or updated_at timestamp). When a transaction reads the wallet, it notes the current version. At write time, it performs a conditional update: "UPDATE balance WHERE version = X". If another transaction modified the wallet in between, the version won't match, and the update fails - the caller must retry.

Pseudocode Implementation:

function transferMoney(fromWalletId, toWalletId, amount):
    MAX_RETRIES = 3
    retries = 0

    while retries < MAX_RETRIES:
        // Read current state
        wallet = db.query("SELECT balance, version FROM wallets WHERE id = ?", fromWalletId)

        if wallet.balance < amount:
            throw InsufficientBalanceError

        newBalance = wallet.balance - amount
        newVersion = wallet.version + 1

        // Conditional update  -  only succeeds if version unchanged
        rowsAffected = db.execute(
            "UPDATE wallets SET balance = ?, version = ? WHERE id = ? AND version = ?",
            newBalance, newVersion, fromWalletId, wallet.version
        )

        if rowsAffected == 1:
            // Success  -  proceed with credit to receiver
            creditWallet(toWalletId, amount)
            return SUCCESS

        retries++
        sleep(exponentialBackoff(retries))

    throw ConflictError("Max retries exceeded")

When to Use:

  • Low contention scenarios (most wallets aren't being written to simultaneously)
  • Read-heavy workloads where most operations are balance checks
  • When you want higher throughput and can tolerate occasional retries
  • Suitable for wallets with infrequent transactions (most consumer wallets)

Pros: No lock held during processing, higher concurrency, no deadlock risk Cons: Retries under high contention, starvation possible for hot wallets


Pessimistic Locking

How It Works:

Pessimistic locking assumes conflicts are likely. Before reading or modifying a wallet, the transaction acquires an exclusive lock on the row. Other transactions attempting to access the same wallet block until the lock is released. This guarantees serial execution for a given wallet.

Pseudocode Implementation:

function transferMoney(fromWalletId, toWalletId, amount):
    db.beginTransaction()

    try:
        // Acquire exclusive lock  -  blocks other transactions
        wallet = db.query(
            "SELECT balance, version FROM wallets WHERE id = ? FOR UPDATE",
            fromWalletId
        )

        if wallet.balance < amount:
            db.rollback()
            throw InsufficientBalanceError

        // Safe to modify  -  we hold the lock
        db.execute(
            "UPDATE wallets SET balance = balance - ? WHERE id = ?",
            amount, fromWalletId
        )

        // Lock receiver wallet and credit
        db.query("SELECT * FROM wallets WHERE id = ? FOR UPDATE", toWalletId)
        db.execute(
            "UPDATE wallets SET balance = balance + ? WHERE id = ?",
            amount, toWalletId
        )

        // Record ledger entries
        insertLedgerEntries(fromWalletId, toWalletId, amount)

        db.commit()
        return SUCCESS

    catch Exception:
        db.rollback()
        throw

When to Use:

  • High contention scenarios (merchant wallets receiving many payments simultaneously)
  • Write-heavy workloads where conflicts are frequent
  • When correctness is more important than throughput
  • Critical financial operations where retries are unacceptable

Pros: Guaranteed consistency, no retries needed, simpler reasoning Cons: Reduced concurrency, potential deadlocks, lock wait timeouts

Deadlock Prevention: Always acquire locks in a consistent order (e.g., by walletId ascending) to prevent circular waits:

function transferWithOrderedLocks(walletA, walletB, amount):
    // Always lock lower ID first to prevent deadlocks
    firstLock = min(walletA, walletB)
    secondLock = max(walletA, walletB)

    db.query("SELECT * FROM wallets WHERE id = ? FOR UPDATE", firstLock)
    db.query("SELECT * FROM wallets WHERE id = ? FOR UPDATE", secondLock)
    // ... proceed with transfer

Distributed Locking

When the wallet system is distributed across multiple database instances or microservices, database-level locks are insufficient. We need distributed locks that work across nodes.

Redis-Based Locks (SETNX + TTL)

function acquireDistributedLock(walletId, requestId, ttlMs = 5000):
    lockKey = "wallet_lock:" + walletId

    // SETNX  -  Set if Not Exists, with expiry
    acquired = redis.execute(
        "SET", lockKey, requestId, "NX", "PX", ttlMs
    )

    return acquired == "OK"

function releaseDistributedLock(walletId, requestId):
    lockKey = "wallet_lock:" + walletId

    // Lua script for atomic check-and-delete (only release our own lock)
    script = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
    """
    redis.eval(script, lockKey, requestId)

function transferWithDistributedLock(fromWalletId, toWalletId, amount):
    requestId = generateUUID()

    if not acquireDistributedLock(fromWalletId, requestId):
        throw LockAcquisitionFailed("Wallet busy, retry later")

    try:
        // Perform the actual transfer within the lock
        wallet = db.query("SELECT balance FROM wallets WHERE id = ?", fromWalletId)

        if wallet.balance < amount:
            throw InsufficientBalanceError

        db.execute("UPDATE wallets SET balance = balance - ?", amount, fromWalletId)
        db.execute("UPDATE wallets SET balance = balance + ?", amount, toWalletId)
        return SUCCESS

    finally:
        releaseDistributedLock(fromWalletId, requestId)

ZooKeeper-Based Locks

ZooKeeper provides stronger guarantees through sequential ephemeral nodes:

function acquireZkLock(walletId):
    lockPath = "/locks/wallet/" + walletId
    // Create sequential ephemeral node
    myNode = zk.create(lockPath + "/lock-", EPHEMERAL_SEQUENTIAL)

    while true:
        children = zk.getChildren(lockPath)
        sortedChildren = sort(children)

        if myNode == sortedChildren[0]:
            return true  // We hold the lock

        // Watch the node just before ours
        previousNode = sortedChildren[indexOf(myNode) - 1]
        zk.exists(previousNode, watch=true)
        waitForWatch()  // Block until predecessor is deleted

Challenges with Distributed Locks

ChallengeDescriptionMitigation
Clock SkewTTL-based locks may expire prematurely if clocks driftUse fencing tokens; Redlock algorithm across multiple Redis nodes
Network PartitionsLock holder may be partitioned from the systemEphemeral nodes (ZK) auto-expire; TTL on Redis locks
Split BrainTwo nodes believe they hold the lockFencing tokens validated at the database level
Lock Holder CrashProcess dies while holding lockTTL ensures eventual release; ZK ephemeral nodes auto-delete
GC PausesLong GC pause causes lock to expire while still in useUse fencing tokens; keep TTL > max expected pause

Comparison Table: Locking Mechanisms

AspectOptimisticPessimisticDistributed (Redis)Distributed (ZK)
MechanismVersion check at writeExclusive row lockSETNX + TTLSequential ephemeral nodes
PerformanceHigh (no blocking)Medium (blocking)HighMedium
ConsistencyEventual (retry-based)StrongDepends on implementationStrong (with fencing)
ScalabilityExcellentLimited by DB connectionsExcellentGood
Failure ModeRetry exhaustionDeadlocks, timeoutsLock expiry raceSession expiry
ComplexityLowLowMediumHigh
Best ForConsumer wallets (low contention)Merchant wallets (high contention)Multi-service architecturesCritical financial ops
Deadlock RiskNoneYes (mitigatable)NoneNone
Throughput Under ContentionDegrades (retries)Stable (queued)HighMedium

Recommendation for Digital Wallet: Use optimistic locking for consumer wallets (95% of wallets) and pessimistic locking for merchant/hot wallets. Use distributed locks when the transfer spans multiple microservices.


Design Decisions & Trade-offs

1. Double-Entry Bookkeeping vs Single Balance

ApproachProsCons
Single BalanceSimple, fast readsHard to audit, reconciliation issues
Double-EntrySelf-auditing, regulatory compliance, easy reconciliationMore writes, slightly complex

Decision: Use double-entry bookkeeping. Every transaction creates two ledger entries (debit from one account, credit to another). The sum of all entries for a wallet must equal its current balance. This makes the system self-auditing - if SUM(credits) - SUM(debits) != current_balance, something is wrong.

2. Synchronous vs Asynchronous Transaction Processing

Decision: Synchronous for P2P transfers (user expects immediate confirmation), asynchronous for bank loads and refunds (external systems are slow). Use a saga pattern for multi-step operations with compensating transactions on failure.

3. Locking Strategy for Concurrent Transfers

Decision: Hybrid approach - optimistic locking by default with automatic escalation to pessimistic locking when retry count exceeds threshold. This gives best throughput for normal wallets while protecting hot wallets.

4. Idempotency for Payment Retries

Decision: Every mutation request carries a client-generated idempotencyKey. Before processing, check if this key exists in the idempotency store. If it does, return the cached response. Store entries with a 24-hour TTL.

5. Event Sourcing vs State-Based Balance

Decision: State-based balance (materialized) with event log for audit. Pure event sourcing requires replaying all events to compute balance - too slow for real-time balance checks. Instead, maintain a materialized balance updated atomically with each transaction, plus an append-only event log for reconstruction and auditing.


Design Patterns Used

PatternApplication
StrategyPayment method selection - BankTransferStrategy, CardPaymentStrategy, UPIStrategy all implement PaymentMethodStrategy interface
CommandEach transaction is a command object (DebitCommand, CreditCommand, TransferCommand) that can be executed, validated, and logged uniformly
ObserverTransaction state changes trigger notifications - SMS, push notification, email via observer subscribers
StateTransaction lifecycle: INITIATED → PENDING → PROCESSING → SUCCESS/FAILED → REVERSED. Each state defines valid transitions
FactoryTransactionFactory.create(type, params) produces the correct transaction subtype with appropriate validation rules
SagaMulti-step transfers use saga orchestrator with compensating actions for rollback

Transaction Flow

Scenario: User A sends ₹500 to User B

User A          API Gateway       Wallet Service        Database           Notification
  │                  │                  │                   │                    │
  │─── Send ₹500 ──▶│                  │                   │                    │
  │  (idempKey=X)    │                  │                   │                    │
  │                  │── Validate ─────▶│                   │                    │
  │                  │   Auth + Limits  │                   │                    │
  │                  │                  │                   │                    │
  │                  │                  │── BEGIN TXN ──────▶│                    │
  │                  │                  │                   │                    │
  │                  │                  │── SELECT ... FOR ─▶│                    │
  │                  │                  │   UPDATE walletA   │ (Lock Acquired)    │
  │                  │                  │◀── balance=2000 ──│                    │
  │                  │                  │                   │                    │
  │                  │                  │── Check: 2000≥500 │                    │
  │                  │                  │    Sufficient    │                    │
  │                  │                  │                   │                    │
  │                  │                  │── UPDATE walletA ─▶│                    │
  │                  │                  │   balance = 1500   │                    │
  │                  │                  │                   │                    │
  │                  │                  │── SELECT ... FOR ─▶│                    │
  │                  │                  │   UPDATE walletB   │ (Lock Acquired)    │
  │                  │                  │                   │                    │
  │                  │                  │── UPDATE walletB ─▶│                    │
  │                  │                  │   balance += 500   │                    │
  │                  │                  │                   │                    │
  │                  │                  │── INSERT ledger ──▶│                    │
  │                  │                  │   (2 entries)      │                    │
  │                  │                  │                   │                    │
  │                  │                  │── COMMIT ─────────▶│                    │
  │                  │                  │                   │ (Locks Released)    │
  │                  │                  │                   │                    │
  │                  │◀── Success ──────│                   │                    │
  │◀── 200 OK ──────│                  │                   │                    │
  │   (txnId=T1)    │                  │── Notify ─────────────────────────────▶│
  │                  │                  │                   │                    │

Edge Cases

Edge CaseHandling Strategy
Concurrent transfers draining same walletPessimistic lock ensures serial execution; second transfer sees updated (lower) balance and may fail with insufficient funds
Transfer to selfValidate fromWalletId != toWalletId at API layer; reject with 400
Refund of a refundTrack originalTxnId chain; reject if transaction type is already REFUND
Partial failure (debit succeeded, credit failed)Wrap in DB transaction - atomic commit/rollback. For distributed: saga with compensating credit-back
Race condition on balance checkNever check balance in application then update separately. Use UPDATE WHERE balance >= amount for atomic check-and-debit
Wallet frozen during KYC reviewCheck wallet status before any operation; return 403 with reason. Allow incoming credits but block debits
Duplicate request (network retry)Idempotency key lookup returns cached response
Amount overflowUse BIGINT for paise; validate max transaction amount at API layer
Negative amount in requestValidate amount > 0 at API layer

Extensibility Points

  1. Multi-Currency Support - Add currency field to wallet; use exchange rate service for cross-currency transfers; maintain separate balances per currency
  2. Cashback & Rewards - Separate "cashback balance" with expiry; credit via async event after successful transaction
  3. Scheduled Payments - Scheduler service picks up future-dated transfers; executes at specified time with retry logic
  4. Split Payments - SplitRequest entity divides amount among N wallets; executed as N individual transfers in a saga
  5. Wallet-to-Bank Withdrawal - Async operation: debit wallet immediately, initiate bank transfer via payment gateway, handle settlement callback
  6. Interest on Balance - Daily batch job calculates interest on average balance; credits wallet monthly

Interview Follow-ups

Q1: How do you prevent double-spending?

Answer: Multiple layers of defense:

  1. Database-level: Atomic UPDATE wallets SET balance = balance - amount WHERE id = ? AND balance >= amount - the WHERE clause makes check-and-debit atomic
  2. Application-level: Pessimistic locking (SELECT FOR UPDATE) serializes concurrent access to the same wallet
  3. Idempotency: Duplicate requests with the same idempotency key return cached results without re-executing
  4. Ledger reconciliation: Background job verifies SUM(ledger_entries) == wallet.balance and flags discrepancies

Q2: What happens if the system crashes between debit and credit?

Answer: If both operations are in the same database transaction, the crash causes an automatic rollback - neither debit nor credit persists. If the system is distributed (wallets in different databases), we use the Saga pattern: the debit is recorded with status PENDING, and a saga orchestrator ensures the credit completes. If the credit fails after retries, a compensating transaction reverses the debit. The saga state is persisted, so recovery after crash resumes from the last checkpoint.

Q3: How would you handle a wallet receiving 10,000 transactions per second (hot merchant wallet)?

Answer: Several strategies:

  1. Batching: Aggregate incoming credits in a buffer and apply as a single update every 100ms
  2. Sharding the balance: Split the merchant wallet into N sub-wallets; distribute incoming payments round-robin; total balance = sum of sub-wallets
  3. Async crediting: Accept payment synchronously (debit sender), queue credit to merchant, process in batches
  4. Dedicated pessimistic lock path: Hot wallets detected automatically and routed to optimized write path

Q4: How do you handle multi-region deployment? (Hint only)

Hint: Consider single-leader replication for wallet writes with regional read replicas. Think about how you'd handle a user traveling between regions - would you redirect writes to the home region or migrate the wallet?

Q5: How would you implement spending analytics and budgeting features? (Hint only)

Hint: Think about CQRS - separate the read model (analytics) from the write model (transactions). Consider how you'd categorize transactions and compute aggregates without impacting the hot transaction path.


Counter Questions to Ask Interviewer

  1. Scale: How many active wallets? What's the peak TPS we're designing for?
  2. Geography: Single region or multi-region? Do we need to handle cross-border transfers?
  3. Regulatory: Which country's regulations apply? Do we need to implement specific KYC/AML requirements?
  4. Currency: Single currency or multi-currency? Do we need real-time exchange rates?
  5. Settlement: Real-time settlement or batch? What's the SLA for bank transfers?
  6. Existing infrastructure: Are we building on an existing payment gateway or from scratch?
  7. Fraud: What level of fraud detection is expected? Real-time blocking or post-facto analysis?
  8. Recovery: What's the RPO/RTO? Can we lose any transactions?

References

  1. Double-Entry Bookkeeping - Every financial transaction records equal debits and credits; the system is self-balancing. Used by all major financial systems for auditability.
  2. Distributed Transactions & Two-Phase Commit (2PC) - Protocol for atomic commits across multiple databases. High latency and blocking nature make it unsuitable for high-throughput wallets; prefer Saga pattern.
  3. Saga Pattern - Sequence of local transactions with compensating actions for rollback. Preferred over 2PC for microservice architectures. Two variants: choreography (event-driven) and orchestration (central coordinator).
  4. Redlock Algorithm - Martin Kleppmann's analysis of distributed locking with Redis; highlights the need for fencing tokens to prevent unsafe lock usage after expiry.
  5. Event Sourcing - Storing state changes as immutable events. Useful for audit trails but expensive for real-time balance computation. Hybrid approach (materialized balance + event log) is practical.
  6. CQRS (Command Query Responsibility Segregation) - Separate read and write models; useful for analytics on transaction data without impacting write throughput.

Word count: ~3700 words