00. Design a Digital Wallet¶
~20 min read · Level: intermediate → advanced · Part 1 of 4 (Overview → HLD → LLD → Q&A)
Problem¶
A digital wallet holds a user's money as a balance and moves it between accounts: top up from a bank card, pay a merchant, send cash to a friend, withdraw back to a bank. This is the product behind Paytm, Venmo, PayPal balance, and the "wallet" tab in every super-app. A user opens the app, sees ₹100.00 (or $100.00), taps to pay, and the balance drops the instant the payment clears. Behind that single number is the hard part: the money must be exactly right, every time, under concurrency, forever, and it must survive an audit years later.
The problem looks like a CRUD app over a balance column, and that framing is the trap. The moment two operations touch the same balance at once, a naive balance = balance - amount loses money or invents it. The moment a transfer crosses two accounts, you need both legs to move together or neither. And the moment a regulator or a customer disputes a charge, you need an immutable trail of who moved what, when, and why. A wallet is an accounting system wearing a mobile-app costume.
Thread one scenario through the whole design to keep the reasoning honest: a user, Priya, has exactly $100.00 available, and two $70.00 transfers fire at the same instant — say a double-tapped "Pay" button, or two merchant checkouts racing on two devices. The only correct outcome is that exactly one succeeds, the other is rejected for insufficient funds, and the balance never dips below zero. If both succeed, the platform is out $40 and someone has to reconcile it by hand. That one race, repeated a million times a day across a million accounts, is the system.
Functional requirements¶
- Balance: show a user's available balance, accurately and near-instantly.
- Top up / withdraw: move money in from a funding source (card, bank) and out to a bank account.
- Transfer: move money from one wallet to another (peer-to-peer or user-to-merchant), atomically.
- Hold / settle: reserve funds now and capture (or release) them later — the authorization/capture pattern behind card-style payments and pending transactions.
- History: list a user's transactions, with enough detail to support disputes and refunds.
- Fraud gating: block or challenge a suspicious movement before the money leaves.
De-scoped for this round, and worth naming so the interviewer sees it as a choice: the actual card-network and bank rails (we treat "top up from bank" as an external call that eventually confirms), KYC/onboarding, interest-bearing accounts, multi-currency FX conversion, and lending. The core money-movement engine is the same regardless, and these bolt onto it.
Non-functional requirements¶
The dominant constraint is correctness of the balance under concurrent writes — which in practice means strong consistency and per-account write serialization. Everything else bends to that.
- Consistency: balances are strongly consistent. A read that decides whether a payment is allowed must see every committed debit. There is no "eventually" acceptable here — an overdraft is real money lost.
- Durability: once a transfer is committed, it is never lost and never silently altered. The ledger is append-only; nothing is deleted or overwritten, only reversed by a new compensating entry. Retention is measured in years (commonly 7–10) for regulatory audit.
- Atomicity: a transfer's debit and credit either both commit or neither does. A partial transfer that debits the sender but never credits the receiver is the worst failure in the system.
- Latency: a payment should confirm in a few hundred milliseconds end to end so checkout feels immediate — but latency yields to correctness. A rejected-but-correct payment beats a fast double-spend.
- Availability: high, but this is a CP system, not AP. Under a partition that threatens correctness, the right move is to refuse the write, not to accept it optimistically and reconcile later.
Scale estimation¶
Assume a mid-to-large wallet: 100 million users and 20 million money-movement transactions per day.
Writes work out to 20M / 86,400 s ≈ 230 transfers/second on average. Apply a 10× peak factor for paydays, festival sales, and lunch-hour merchant traffic, and design the write path for ~2,300 transfers/second at peak. Each transfer is double-entry — one debit leg and one credit leg — so the ledger absorbs ~4,600 entry writes/second and roughly the same number of balance-row updates. Unlike a feed or a shortener, this is not a wildly read-heavy system; writes are first-class and every one of them must be correct.
Balance reads dominate only modestly. If each of 100M users opens the app and reads a balance about five times a day, that is 500M / 86,400 s ≈ 5,800 reads/second average, ~58,000 reads/second at peak — call it a 10:1 read-to-write ratio, an order of magnitude tamer than a social feed. Reads are cacheable but the cache must never serve a balance that lets an overdraft through, which shapes the whole caching story later.
For storage, each ledger entry is small — an id, transfer id, account id, direction, amount in integer minor units, timestamp — call it ~250 bytes with indexes. At two entries per transfer, 20M × 2 × 250 B ≈ 10 GB/day, or ~3.7 TB/year of immutable ledger. Held for seven years that is ~25 TB, plus a much smaller balance table of one row per account (100M × ~64 B ≈ 6 GB, which fits in memory). The ledger is large but append-only and shardable; the balance table is tiny but hot. That asymmetry drives the storage split.
The number that actually shapes the architecture is not the aggregate 4,600 writes/second — that spreads across shards easily — but the per-account write rate on a hot account. Priya's account can only process transfers serially, because each one must read-modify-write the same balance row. A single popular merchant during a flash sale might take thousands of payments a second, all serialized on one balance. That contention on a single account, not the global throughput, is the wallet's equivalent of the hot key.
API sketch¶
POST /api/v1/transfers
Idempotency-Key: <client-generated-uuid> # required
body: { "from": 1001, "to": 2002, "amount": 7000, "currency": "USD" } # amount in minor units (cents)
201: { "transfer_id": "…", "status": "committed", "balance_after": 3000 }
409: { "error": "insufficient_funds", "available": 3000 }
200: (idempotent replay) same body as the original result
POST /api/v1/holds
body: { "account": 1001, "amount": 7000, "expires_in": 900 }
201: { "hold_id": "…", "available": 3000, "held": 7000 }
POST /api/v1/holds/{hold_id}/capture # settle: convert hold into a committed transfer
POST /api/v1/holds/{hold_id}/release # cancel: return held funds to available
GET /api/v1/accounts/{id}/balance
200: { "available": 3000, "held": 7000, "currency": "USD" }
GET /api/v1/accounts/{id}/transactions?cursor=…
200: { "items": [ { "transfer_id": "…", "direction": -1, "amount": 7000, "ts": … } ], "next": … }
Solutioning¶
Start from the one non-negotiable — the balance must never go negative and money must never be created or destroyed — and the shape follows. The first decision is what the balance is. It is tempting to make it a mutable column you increment and decrement, but that throws away the audit trail and makes every bug unrecoverable. Instead the source of truth is an append-only, double-entry ledger: every money movement writes two immutable entries that sum to zero (debit one account, credit another), and a balance is the running sum of an account's entries. Because summing millions of entries per read is impractical, you keep a materialized balance row updated in the same transaction as the ledger append. The ledger is truth; the balance row is a fast, authoritative cache of that truth, reconciled against it continuously. The reframing to carry into the room: a wallet is not a CRUD app on a balance column; it is an append-only ledger with a derived balance.
The second decision is how to make the balance correct under Priya's two simultaneous $70 transfers, and this is the defining tradeoff: consistency versus availability on the balance. You choose consistency without hesitation, because the cost of getting it wrong is real money, not a stale view. The mechanism is per-account write serialization: the two transfers must line up and execute one after another against Priya's balance row, and the debit must be guarded — applied only if funds are sufficient — so the second one is rejected rather than driving the balance negative. In SQL that guard is a conditional update (UPDATE … SET available = available - 70 WHERE account = :priya AND available >= 70); the first transfer takes the row lock, drops 100 → 30, commits; the second finds 30 < 70, changes zero rows, and returns 409 insufficient_funds. The memory hook: a double-spend is not a read-the-balance problem; it is a serialize-the-write problem. Reading the balance first and then deciding is exactly the race that loses money — the decision and the deduction have to be one atomic step.
The third decision is single global ledger versus sharded, and it forces the transfer-atomicity question. A single logical ledger is simplest to reason about but cannot hold 4,600 writes/second and 25 TB forever, so you shard by account_id. That instantly complicates transfers, because a transfer touches two accounts that may live on two shards. When both accounts share a shard, the whole transfer — two ledger entries plus two balance updates — is one local ACID transaction, clean and fast. When they cross shards, you cannot get a cheap distributed transaction, so you fall back to a reserve-then-commit flow: the sender's shard first holds the funds (moves available → held atomically, which already enforces the no-overdraft guard), then the receiver's shard credits, then the hold is captured; any failure compensates by releasing the hold. This is the same hold/settle machinery the API exposes, reused as the cross-shard atomicity primitive. Choose to keep the common peer-and-merchant case single-shard where possible, and pay the two-phase cost only when accounts genuinely span shards.
The fourth tension is synchronous fraud checking versus latency. Fraud has to gate the money — you cannot let a stolen-account drain complete and then "reconcile" — but a deep ML risk model can take 100–300 ms, which is a lot on a payment path. The resolution is a two-speed design: a fast synchronous gate (velocity limits, blocklists, amount thresholds, device reputation) runs inline in under ~20 ms and returns allow / deny / challenge before the debit commits, while a heavier asynchronous scorer consumes the ledger event stream and can freeze an account or post a compensating reversal within seconds if it later decides a committed transfer was fraudulent. Fast rules protect the hot path; slow models catch what rules miss, using the ledger's reversibility as the safety net.
The result is a system whose write path is a fraud gate plus a single guarded, serialized ledger transaction; whose balance is a materialized sum kept honest by continuous reconciliation; whose cross-shard transfers ride the hold/settle rail; and whose fraud defense is split across a synchronous gate and an asynchronous scorer. The following files take each of these down to components (HLD) and then to schemas, the guarded-update algorithm, and the concurrency corners (LLD).