← System Design Simulator

Digital Wallet System Design Interview Question

By Rahul Kumar · Senior Software Engineer · Updated · 10 components · 3 operations ·Source: Alex Xu, System Design Interview Vol 2, Chapter 12

Problem: Design a digital wallet (PayPal/Venmo-style) that supports top-up from bank, ACID peer-to-peer transfers, and fast balance reads.

Overview

A digital wallet looks deceptively like a bank account: a user has a balance, they top up from a bank, they send money to someone else, they withdraw. The deception is that every one of those operations touches at least two balances atomically, and the entire product collapses the moment balances drift even by a cent. Unlike a payment system that brokers between a customer and an external PSP, a wallet owns the money on both sides of most transactions, which means the wallet service is the source of truth for its own ledger — there is no external settlement report to reconcile against for intra-wallet transfers. That concentrates every correctness concern into one place: how do you guarantee that a transfer from Alice to Bob either fully succeeds or fully fails, even when the process crashes between the debit and the credit, and how do you serve a billion balance reads per day without asking Postgres for a SUM every time. The design below answers both with a double-entry ledger plus a materialized balance row protected by optimistic locking.

Digital Wallet — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive walkthrough for Digital Wallet — animated architecture diagram, step-by-step flow with real payloads, component swap, and a discrete-event stress simulator.

Summary

A strongly consistent wallet built on a MySQL double-entry ledger, fronted by a Redis balance cache and a 2-phase-commit transfer coordinator that guarantees A→B money movements are atomic across two account shards. The dominant design choice is putting the canonical balance in ACID SQL (not in Redis) and using 2PC when sender and receiver live on different shards — accepting the ~30ms latency cost in exchange for zero reconciliation debt. Kafka is the audit spine: every state transition produces an immutable event that downstream fraud, analytics, and compliance systems consume independently.

Requirements

Functional

Non-functional

Capacity Assumptions

Back-of-Envelope Estimates

High-level architecture

The wallet service sits behind an API gateway that authenticates users and rate-limits per-UID. Every write — top-up, transfer, withdrawal — flows through a WalletService that opens a single SERIALIZABLE transaction against Postgres. Inside the transaction we (a) read-lock the involved wallet_balance rows in a stable order (lowest account_id first, to prevent deadlocks), (b) insert balanced double-entry rows into the ledger_entries table, and (c) apply the delta to the materialized wallet_balances.balance_minor column using optimistic concurrency (a version column + WHERE version = ? CAS). If two transfers race on the same wallet, one commits and the other retries at the service layer — bounded to three retries before returning 409. Top-ups are two-phase: we record a pending ledger entry immediately and a balance_pending column bumps, then the bank rail webhook (or settlement poll) flips the entry to posted and moves the amount from balance_pending to balance_available. This gives users instant visual feedback while respecting rail finality. Balance reads hit a thin read API that goes to a read replica for wallet_balances — we never SUM the ledger on the read path because that would O(N) per read. A separate audit job runs nightly and recomputes SUM(ledger_entries) per wallet, comparing against the materialized balance; any drift pages on-call. Sharding is by user_id hash so a transfer between two users routes through a coordinator that uses 2PC or a saga across shards; we keep popular users (merchants) unsharded on a hot tier to avoid cross-shard writes for most transfers.

Architecture Components (10)

Operations Walked Through (3)

Implementation

WalletService — ACID transfer with optimistic lock
@Service
public class WalletService {
  private final WalletRepo wallets;
  private final LedgerRepo ledger;

  @Transactional(isolation = Isolation.SERIALIZABLE)
  public TransferResult transfer(UUID from, UUID to, long amountMinor, String currency, String idemKey) {
    if (amountMinor <= 0) throw new IllegalArgumentException("amount must be positive");
    // lock in canonical order to avoid deadlock
    UUID first = from.compareTo(to) < 0 ? from : to;
    UUID second = first.equals(from) ? to : from;
    Wallet w1 = wallets.loadForUpdate(first);
    Wallet w2 = wallets.loadForUpdate(second);
    Wallet src = first.equals(from) ? w1 : w2;
    Wallet dst = first.equals(to)   ? w1 : w2;
    if (src.available() < amountMinor) throw new InsufficientFundsException(from);

    UUID txnId = UUID.randomUUID();
    ledger.insert(txnId, from, -amountMinor, currency, idemKey);
    ledger.insert(txnId, to,    amountMinor, currency, idemKey);

    int u1 = wallets.applyDelta(from, -amountMinor, src.version());
    int u2 = wallets.applyDelta(to,    amountMinor, dst.version());
    if (u1 != 1 || u2 != 1) throw new OptimisticLockException("wallet version changed");
    return new TransferResult(txnId, src.version() + 1, dst.version() + 1);
  }
}
Balance read path — materialized read from replica
@Service
public class BalanceReadService {
  private final JdbcTemplate replicaJdbc; // points at read replica

  public BalanceView read(UUID walletId) {
    return replicaJdbc.queryForObject(
      "SELECT wallet_id, currency, balance_available_minor, balance_pending_minor, version, updated_at " +
      "FROM wallet_balances WHERE wallet_id = ?",
      (rs, i) -> new BalanceView(
        UUID.fromString(rs.getString("wallet_id")),
        rs.getString("currency"),
        rs.getLong("balance_available_minor"),
        rs.getLong("balance_pending_minor"),
        rs.getLong("version"),
        rs.getTimestamp("updated_at").toInstant()),
      walletId);
  }

  /** Read-your-writes: after a write, the client can pass the expected version and we redirect to primary if replica is behind. */
  public BalanceView readWithVersion(UUID walletId, long minVersion) {
    BalanceView v = read(walletId);
    if (v.version() < minVersion) return primaryRead(walletId);
    return v;
  }
}
Top-up from bank rail (two-phase)
@Service
public class TopUpService {
  private final WalletRepo wallets;
  private final LedgerRepo ledger;
  private final BankRailClient rail;

  @Transactional
  public TopUpReceipt initiate(UUID walletId, long amountMinor, String currency, String idemKey) {
    BankTransfer pending = rail.initiate(walletId, amountMinor, currency, idemKey);
    UUID txnId = UUID.randomUUID();
    ledger.insertPending(txnId, walletId, amountMinor, currency, pending.railRef(), idemKey);
    wallets.bumpPending(walletId, amountMinor);
    return new TopUpReceipt(txnId, pending.railRef(), TopUpStatus.PENDING);
  }

  @Transactional
  public void onRailSettled(String railRef, boolean success) {
    LedgerRow row = ledger.findByRailRef(railRef);
    if (row.status() != LedgerStatus.PENDING) return; // idempotent
    if (success) {
      ledger.markPosted(row.txnId());
      wallets.movePendingToAvailable(row.walletId(), row.amountMinor());
    } else {
      ledger.markFailed(row.txnId());
      wallets.clearPending(row.walletId(), row.amountMinor());
    }
  }
}

Key design decisions & trade-offs

Interview follow-ups

Related