all_lessons / system_design / cases / C33 C33 / C44

Design a digital wallet

A wallet is not a balance — it is a ledger. The balance is a derived view of an append-only history of debits and credits, and that single design choice is what lets you prove where every cent came from and never lose track of one.

Source: Vol. 2 Ch. 12 Anchor: double-entry ledger Trade-off first
First principle
Money has a conservation law: it is never created or destroyed by a transfer, only moved. If you model a wallet as a mutable balance field and do balance -= 10, you have thrown away that law — there is no record that the 10 went somewhere specific, no way to prove the books are consistent, and an off-by-one bug silently mints or burns money. The fix is double-entry bookkeeping: every movement is recorded as two entries — a debit on one account and a credit on another — that sum to zero. The balance is then not a stored number but a fold over the entry log. This is the same insight as lesson 12's event sourcing applied to money: the log is the source of truth, the balance is a projection.

0. The hinge

The hinge of a wallet design is the question "where does the balance live?" A junior answer stores a mutable number and mutates it on every transaction. A senior answer stores an immutable ledger of entries and treats the balance as derived. Everything forcing about this problem — auditability, reversibility, the double-entry invariant, the available-vs-posted distinction, overdraft prevention under concurrency — follows from getting that one decision right. Pick the mutable balance and you will re-derive a ledger badly under pressure when the auditors arrive; pick the ledger and the hard parts become principled.

1. Clarify the contract

Treat the prompt as a product contract before a box diagram. The wallet must:

What it need not do: it is not an exchange (no matching engine — see C37), not a general OLAP store, and it does not need sub-millisecond latency. Correctness and auditability dominate; a transfer taking 50 ms is fine, a transfer that loses a cent is a catastrophe. That asymmetry sets every trade-off below.

2. Put numbers on it

The numbers here are not about QPS — they are about read cost, and they drive the single most important design decision. Take a moderately active wallet: 5 years of history at roughly ~5–6 transactions per day10,000 entries. Two ways to answer "what is your balance?":

Entries / 5-yr account
~10,000
Read cost — fold full log
10,000 rows
Snapshot interval
1,000 entries
Read cost — with snapshot
≤ 1,000 rows

The point is the shape of the curve. Folding the whole log is O(N) in history and grows without bound — a wallet gets slower to read the longer a customer stays with you, which is exactly backwards. Snapshotting makes read cost O(k) where k is the snapshot interval, a constant. Here is the trade made concrete across account ages:

Account historyFold full log (rows read)Snapshot every 1k (rows read)Speedup
1,000 entries1,000≤ 1,0001× (snapshot not yet earning)
10,000 entries (5 yr)10,000≤ 1,000~10×
100,000 entries (merchant)100,000≤ 1,000~100×
1,000,000 entries (heavy merchant)1,000,000≤ 1,000~1000×

The snapshot is a classic CQRS projection (DDIA Ch. 11): the write side appends events to the ledger; the read side maintains a cached balance derived from them. The crucial property is that the snapshot is never the source of truth — it is a cache you can always rebuild by replaying the log, which is why snapshot drift is recoverable rather than fatal.

3. Data model & API

Three tables carry the whole product. The discipline is that ledger_entry is append-only and immutable — there is no UPDATE and no DELETE on it, ever.

TableKey columnsRole
accountaccount_id, currency, type (user / liability / fee)The node. Every wallet and every system account (fees, float, settlement).
ledger_transactiontxn_id, idempotency_key, created_at, statusThe atomic unit of movement. Groups the entries that must balance.
ledger_entryentry_id, txn_id, account_id, direction (DR/CR), amountAppend-only. Two-or-more rows per txn, summing to zero.
holdhold_id, account_id, amount, status (active/captured/released)Reserved-but-not-yet-debited funds. Drives available balance.
balance_snapshotaccount_id, posted_balance, as_of_entry_idDerived cache. Rebuildable from the log; never authoritative.
API / operationWhy it exists
POST /transfers {idempotency_key, from, to, amount}The core movement. Idempotency key (C35) makes a retried transfer a no-op, not a double-send.
POST /holds {account, amount} · POST /holds/{id}/capture|releaseAuthorize-then-capture flow; reduces available without touching posted.
GET /accounts/{id}/balanceReturns both posted and available — the read path the snapshot optimizes.

4. The double-entry invariant, drawn

A transfer of 50 from Alice to Bob is one ledger_transaction containing two ledger_entry rows. The debit and the credit must sum to zero — that is the invariant a reviewer must validate before commit. Read the T-accounts: a debit to Alice's wallet decreases a user liability, an equal credit to Bob's increases his.

TRANSFER txn#7741 "Alice pays Bob 50" (single atomic ledger_transaction) ════════════════════════════════════════════════════════════════════════════ ALICE (wallet) BOB (wallet) ┌────────────┬────────────┐ ┌────────────┬────────────┐ │ DEBIT │ CREDIT │ │ DEBIT │ CREDIT │ ├────────────┼────────────┤ ├────────────┼────────────┤ │ −50 ◀───┼─────────────┼────────────┼────────────┼───▶ +50 │ └────────────┴────────────┘ └────────────┴────────────┘ entry A (DR 50) entry B (CR 50) Invariant: Σ entries in txn = (−50) + (+50) = 0 ✔ balanced a transfer is ALWAYS exactly two rows summing to zero ── balances ────────────────────────────────────────────────────────────── posted = Σ(CR) − Σ(DR) over settled entries ← the fold available = posted − Σ(active holds) ← what you can spend Alice: posted 200, hold 30 (pending purchase) ⇒ available = 200 − 30 = 170

The reason double-entry is not bureaucratic ceremony: it gives you a continuous, cheap correctness check. Sum every entry across the entire system and it must equal zero (money in user accounts is balanced by liability/float accounts). Any single buggy code path that writes one leg without the other breaks that global sum, and a reconciliation job catches it. This is DDIA Ch. 7's canonical account-transfer example: the textbook reason you wrap both legs in one transaction is to avoid write skew, where two operations each read a valid state and write a combination that violates an invariant the database can't see.

5. Linearized design — follow a transfer

  1. 1. Request arrives with an idempotency key. The wallet service does insert-if-absent on that key (C35). If the key already exists, return the prior result — a retried transfer never moves money twice.
  2. 2. Open a database transaction. Lock the source account's balance (the concurrency control below) and read its available balance — folding from its latest snapshot forward.
  3. 3. Conditional check: is available ≥ amount? If not, abort with insufficient-funds. This check and the write that follows must be atomic, or two debits race (deep dive 3).
  4. 4. Append the two ledger_entry rows (DR source, CR destination) and the ledger_transaction. Validate Σ = 0 before commit.
  5. 5. Commit. The entries are now immutable history. Update the source's balance_snapshot in the same transaction (or let the projection catch up async).
  6. 6. A reconciliation job periodically recomputes balances from the log and asserts the global sum is zero, alerting on any drift.

The first bottleneck surfaces at step 2: the per-account lock. All of an account's debits serialize on it. For a normal user that is invisible (one transfer at a time). For a hot account — a popular merchant, the system fee account — it becomes contention, which is the partitioning problem (lesson 07): you can shard the ledger by account so each account's entries live together, but a single hot account is still a single lock. The honest answer in an interview is that for a fee/float account you batch movements rather than locking per-transfer.

6. Deep dives

(1) Compute-on-read vs snapshot, and the available-vs-posted subtlety

The read-cost trade is settled above: snapshot to bound the fold to O(k). The genuinely subtle part is that there are two balances, and conflating them is the most common wallet bug. Posted is the fold over settled entries — money that has actually moved. Available is posted minus the sum of active holds — money you are allowed to spend right now. When you tap your card and the merchant authorizes 30 but hasn't captured, your posted balance is unchanged (no entry yet — nothing has moved) but your available drops by 30 (a hold exists). Capture later converts the hold into real entries; release (a cancelled order) just drops the hold with no entries at all.

The bug this prevents
If you debit available by writing ledger entries at authorization time, then a cancelled authorization leaves phantom entries you must reverse, and your posted balance lies. If you check overdraft against posted instead of available, a user with 200 posted, 200 in holds, can spend another 200 they don't have. The rule: holds gate spending (check available); only capture/settlement writes ledger entries (changes posted). Keep the two strictly separate.

(2) The ledger is append-only — corrections are new entries, never mutations

You will be tempted to UPDATE an entry to fix a mistake. Don't — ever. If a transfer was wrong, you do not delete it; you append a reversing transaction (DR/CR in the opposite direction) that nets it to zero, with a reference back to the original. The audit trail then shows both the error and the correction, which is precisely what an auditor, a dispute, or a debugging session needs. An immutable log is also what makes the snapshot safe: because entries never change, a snapshot computed at entry_id = 9000 is valid forever — you can always trust it and fold forward. The moment entries become mutable, every snapshot is suspect and you've lost the whole benefit. This is DDIA Ch. 11's event-sourcing discipline: the log of facts is immutable; current state is a fold; corrections are new facts. It also means the ledger replicates cleanly (DDIA Ch. 5) — an append-only stream is the friendliest possible thing to replicate to followers, since there are no update conflicts to resolve.

(3) Concurrency control on debit — preventing the double-spend

This is the sharpest part. Two requests both try to debit the same account at the same moment, each reads the balance, each sees enough, each commits — and the account goes negative. This is a consistency failure, specifically DDIA Ch. 7's write skew: neither transaction overwrote the other's write, so a naive snapshot-isolation database won't flag a conflict, yet together they violated the no-overdraft invariant. The cure is to make check-and-debit a single atomic operation against available balance:

All three converge on the same principle: the comparison and the mutation must be indivisible at the place the data lives. Choose pessimistic locking when accounts are individually hot (it avoids retry storms); optimistic CAS when contention is rare and you'd rather not hold locks.

THE OVERDRAFT RACE available = 100, two debits of 80 arrive ─────────────────────────────────────────────────────────────────────────── WITHOUT atomicity (BROKEN) WITH SELECT…FOR UPDATE (CORRECT) T1 read avail = 100 T1 LOCK acct ─ read 100 T2 read avail = 100 ← stale T2 LOCK acct ─ BLOCKS ........... T1 100−80 ≥ 0 ✔ commit −80 T1 100−80 ≥ 0 ✔ commit, unlock T2 100−80 ≥ 0 ✔ commit −80 T2 acquires lock, re-read = 20 ─────────────────────────── T2 20−80 ≥ 0 ✗ reject (insufficient) balance = −60 OVERDRAFT! ✗ balance = 20 ✔

7. Trade-offs

ChoiceBuysCostsChoose when
Compute balance on readAlways-correct, no cache to maintainO(N) read, slow for long historiesLow-volume accounts, or as the rebuild path
Snapshot + fold-forwardO(k) bounded reads at any history lengthCache to maintain & reconcile; possible driftAny account that accumulates history (default)
Strong transfer transactionNo imbalance, no write skewCoordination/lock cost; per-account serializationWallet-to-wallet movement (almost always)
Holds vs direct debitAuth-then-capture safety; reversibleHold state machine to manage; available ≠ postedCard auth, checkout, escrow flows
One ledger DB vs shardedTrivial cross-account invariantsWrite/scale ceilingSingle DB until it hurts; shard by account_id later

The sharpest trade is snapshot vs. compute-on-read, because it's tempting to dismiss snapshotting as premature optimization. It isn't: without it, the read cost grows linearly with account age, so your oldest, most loyal customers get the slowest balance reads — a pathology that gets worse precisely as the business succeeds. The cost you accept is a second source of "balance" that can drift; you pay it down with a reconciliation job that rebuilds from the immutable log, which is cheap because the log can't lie.

8. Failure modes

FailureMitigation
Concurrent debits overdraw the accountAtomic conditional debit against availableFOR UPDATE or CAS (deep dive 3).
Ledger imbalance (one leg written, not the other)Validate Σ(entries) = 0 before commit; both legs in one DB transaction; reconciliation asserts global sum = 0.
Duplicate transfer from a client retryIdempotency key on the transfer intent — insert-if-absent, return prior result (C35).
Snapshot drift from the true balanceRecompute from the immutable log, alert on mismatch; snapshot is a cache, log is truth.
Mutating an entry to "fix" a mistakeForbidden. Append a reversing transaction referencing the original; history stays intact.
Hold leaked (auth never captured or released)Holds expire on a TTL and auto-release, restoring available balance.

9. Interview Q&A

  1. Why a ledger instead of a mutable balance field? (senior answer) A mutable field throws away the conservation law — you can't prove where money came from, can't audit, and a bug silently mints or burns it. A double-entry ledger records every movement as two entries summing to zero; the balance is a fold over that immutable log. You get auditability, reversibility, and a continuous correctness check (global sum = 0) for free.
  2. Two concurrent debits each see enough balance and both commit — how do you prevent the overdraft? (senior answer) This is write skew (DDIA Ch. 7). Make check-and-debit atomic against available: either SELECT … FOR UPDATE to serialize debits on the account row, or an atomic conditional update SET available = available − amt WHERE available ≥ amt and treat 0 rows affected as insufficient funds. The comparison and mutation must be indivisible where the data lives — reading then writing in separate steps is the bug.
  3. What's the difference between available and posted balance? (senior answer) Posted is the fold over settled ledger entries — money that actually moved. Available is posted minus active holds — what you can spend now. Authorizations create holds (drop available, posted unchanged); only capture/settlement writes entries (changes posted). Check overdraft against available; never write entries at auth time.
  4. A balance read scans 10,000 rows and it's getting slower. Fix it. (senior answer) Snapshot. Materialize (account, balance, as_of_entry_id) every ~1,000 entries; a read folds only entries after the latest snapshot, bounding it to ≤1,000 rows regardless of history. The snapshot is a CQRS projection (DDIA Ch. 11), never the source of truth — rebuildable from the log, so drift is recoverable.
  5. You discover a transfer was wrong. How do you correct it? (senior answer) Never UPDATE or DELETE the entry. Append a reversing transaction (DR/CR opposite) referencing the original, netting to zero. The audit trail shows both the error and the fix. Immutability is also what keeps snapshots valid forever — a snapshot at entry 9000 can be trusted because entries never change.
  6. How do you guarantee a transfer doesn't run twice when the client retries? (senior answer) Idempotency key on the transfer intent: insert-if-absent before doing the work; if the key exists, return the prior result. See C35 for the mechanics (key TTL, request-hash to detect a reused key with different params).
  7. How do you scale the ledger past one database? (senior answer) Shard by account_id so an account's entries co-locate (lesson 07). Within-account invariants stay trivial; cross-account transfers now span shards, so you need a two-phase commit or an outbox/saga to keep both legs atomic (lesson 12). A hot account (fees, float) is still a single lock — batch its movements rather than per-transfer locking.
  8. How do you detect that money has leaked despite all this? (senior answer) Reconciliation: periodically recompute every account from the immutable log and assert the system-wide sum of all entries is exactly zero. Double-entry makes this a single global check; any imbalance points at a code path writing one leg without the other. Alert and freeze, don't auto-correct.

Related lessons

This case is the anchor for the double-entry ledger — debit/credit, available-vs-posted, balance snapshotting. C32 (payment system) orchestrates the flow that lands money into these wallets; C34 (money movement & reconciliation) is the reconciliation job that audits this ledger against external statements; C36 (payment security, SWIFT & FX) settles cross-border and FX movements as entries here. For the idempotency-key mechanics every transfer relies on, see the anchor C35. On foundations: the atomic two-leg write is lesson 12 (transactions)'s territory, and the overdraft race is a lesson 09 (consistency) write-skew problem.

C32 · Payment system C34 · Reconciliation C35 · Idempotency C36 · Security & FX Transactions Consistency
Takeaway
A wallet is a double-entry ledger: store an append-only log of debit/credit entries that sum to zero per transfer, and derive the balance as a fold over it. Snapshot every ~1,000 entries to keep reads at O(k) instead of O(history). Keep two balances — posted (settled entries) and available (posted minus holds) — and check overdraft against available with an atomic conditional debit so concurrent debits can't both pass. Never mutate an entry; correct with reversing entries. The log is truth, the balance is a view, and the global sum being zero is your continuous proof that no money was lost.