System Design · step by stepDesign a Digital Wallet
Step 1 / 9

Design a Digital Wallet — the walkthrough in full

A written version of the interactive walkthrough above — the same steps, decisions and trade-offs, laid out for reading, reference and search.

The big idea

What makes a wallet hard?

Moving a number from one account to another sounds like a -= 10; b += 10. But this is money: it must never be created, lost, or spent twice — even when requests retry, servers crash mid-transfer, two payments race for the same balance, or a bank takes three days to confirm. Correctness isn't a feature here; it's the whole product.

A digital wallet holds balances and moves money with absolute correctness and a complete audit trail. The core is not a balance you edit — it's an append-only double-entry ledger where balance is derived, every transfer is an atomic debit+credit, and every action is idempotent and auditable.

How to read this: Each step opens with a real design decision — make the call before I show you what ships. Watch the design grow, and at the end crash mid-transfer and drop the balance snapshot to see why money stays exact. Hit Begin.

Step 1 · The dangerous shortcut

A mutable balance column

The naive model: an accounts table with a balance column you UPDATE. A transfer is "subtract from A, add to B." Name everything that can go wrong.

Design decision: A balance column you UPDATE for each transfer. What's wrong for *money*?

The call: Races lose updates, a crash half-completes a transfer, and there's no history to audit or reverse. — Two concurrent transfers can both read balance 100 and both subtract, losing money; a crash after the debit but before the credit destroys money; and overwriting the balance leaves no record of how it got there. Money demands atomic, isolated, append-only accounting.

A mutable balance is unsafe for money on three fronts: concurrency (two transfers both read the old balance and one update is lost), partial failure (a crash after debiting A but before crediting B creates or destroys money), and no audit trail (overwriting a number erases how it got there — you can't reconcile or reverse). Money needs atomicity, isolation, and an immutable history.

Money is different: For most data, "eventually roughly right" is fine. For money it is not: a lost or duplicated cent is a bug that shows up in someone's bank statement. Every design choice below buys correctness — even at the cost of throughput.

Step 2 · Record movements, don't overwrite

The wallet service + an append-only log

If overwriting a balance is the sin, what do you store instead? You still need to know each account's balance, but without ever mutating a number in place.

Design decision: If you never overwrite a balance, how do you know what it is?

The call: Keep the balance column but back it up before each change. — Backups of a mutable column are clumsy, racy, and still lose the per-movement history and atomicity you need. The clean model is an immutable log of movements from which balance is computed.

Introduce a wallet service over an append-only log of money movements. You never edit a balance; you append an immutable entry for each movement. An account's balance is derived as the sum of its entries. Now you have a complete, tamper-evident history, no destructive updates, and a single source of truth — the log — from which everything else (balances, statements) is computed.

Events, not state: This is event sourcing applied to money: the log of movements is the truth; balance is a projection of it. Immutable entries mean you can always replay, audit, and reconcile — and you can never silently corrupt a balance by overwriting it.

Step 3 · The accounting invariant

Double-entry, atomically

A transfer moves money between two accounts. How do you record it so that money is provably conserved — never created or destroyed — even if the system crashes halfway through?

Design decision: How do you record a transfer so money is provably conserved through a crash?

The call: Write a balanced debit+credit pair in one atomic transaction (they sum to zero). — Double-entry: every transfer is a debit of A and an equal credit of B, written together in a single ACID transaction so both commit or neither. The entries always sum to zero, so total money is conserved by construction, and a crash leaves no half-transfer.

Use double-entry accounting inside an atomic transaction. Every transfer writes a debit of account A and an equal credit of account B as one unit — an ACID transaction so both commit or neither does. The two entries sum to zero, so total money is conserved by construction. A crash between them is impossible: the transaction either fully lands or fully rolls back.

Debits = credits, always: Double-entry's invariant — every credit has an equal, opposite debit — means the sum of all entries is always zero, so you can prove no money leaked. Atomicity makes the pair indivisible. Together they turn "don't lose money" from a hope into a guarantee.

Step 4 · Retries that don't double-charge

Idempotency

Networks fail after a request succeeds but before the response arrives, so clients retry. If a retry re-runs the transfer, you've charged someone twice. How do you make "send $10" safe to retry?

Design decision: A client retries a transfer whose response was lost. How do you avoid charging twice?

The call: Ask the user to confirm they didn't already pay. — Pushing correctness onto the user is unreliable and terrible UX, and races still happen. The server must guarantee exactly-once itself via an idempotency key.

Require an idempotency key on every transfer. The server records the key with its result in the same transaction as the ledger entries; a retry carrying the same key returns the original outcome without re-applying the transfer. So "send $10" can be retried any number of times and still moves $10 exactly once — even if a crash struck at the worst moment.

Exactly-once via keys: Money operations must be idempotent, and "add/subtract" isn't naturally so. The idempotency key is the fix: dedupe on it, commit the dedupe record atomically with the effect, and retries become free. This is non-negotiable for correctness.

Step 5 · Fast reads & no overspend

Materialized balances

Summing an account's entire ledger history on every balance read (and every "do they have enough?" check) gets expensive as history grows. But you also must not let two concurrent transfers both spend the same funds. How?

Design decision: Summing all ledger entries per read is slow, and you must block overspend. How?

The call: Maintain a materialized balance updated in the ledger transaction; check-and-debit atomically (with locking). — Keep a per-account balance updated in the same atomic transaction as the entries, so reads are O(1) and always consistent with the ledger. Guard overspend by checking-and-debiting under a per-account lock (or serialized single-writer), so concurrent transfers can't both pass the check.

Keep a materialized balance per account, updated in the same atomic transaction as the ledger entries — so reads are O(1) and can never disagree with the log (and it's rebuildable from the ledger if lost). Prevent overspend by making the check-and-debit atomic: hold a per-account lock (or serialize each account through a single writer) so two concurrent transfers can't both read "enough funds" and both spend it.

Serialize per account: The overspend race is a classic lost-update. The fix is isolation on the account: pessimistic row locks, an optimistic version check, or routing all of one account's transactions through one writer. Money correctness needs real serializability where balances are decremented.

Step 6 · Beyond one database

Sharding & cross-account transfers

Millions of accounts and their histories outgrow one database. But sharding accounts across databases means a transfer between two accounts can span two shards — and you can't use a single local transaction across them. How do you keep transfers atomic?

Shard by account id so each account's ledger lives on one shard (an account's own transactions stay a local ACID transaction). For a cross-shard transfer, you can't do one local transaction across both, so use a saga (or two-phase commit): model the transfer as a sequence of steps with compensating actions, often via a pending/holds mechanism — reserve funds on A's shard, credit B's shard, then confirm, and compensate (release the hold) if any step fails. Same-shard transfers stay simple and fast; cross-shard ones pay for coordination.

Sagas for distributed money: Atomicity across shards can't come free from one DB transaction. A saga breaks the transfer into steps each with an undo, driven to completion or rollback by an orchestrator — with idempotency making every step retry-safe. Holds/reservations make the money "in flight" explicit and recoverable.

Step 7 · The outside world is slow

Pending vs settled & reconciliation

Top-ups and withdrawals touch banks and card networks that are asynchronous and can take days — and might silently disagree with your books. How do you model in-flight external money, and trust your ledger?

Model money in two states: a pending (authorized/held) entry when an external transfer is initiated, promoted to settled when the bank confirms (or reversed on failure) — so the ledger reflects reality at each stage without pretending an unconfirmed transfer is final. And run continuous reconciliation: match your internal ledger against external bank/rail statements to catch any discrepancy (a settlement that never arrived, a duplicate) and flag it for repair. Trust, but verify against the source of funds.

Pending → settled: External rails are eventually-consistent and fallible, so you make the intermediate state explicit (pending holds) and treat the bank statement as an independent oracle. Reconciliation is the safety net that catches the rare case where your books and the real world drift apart.

Step 8 · The sharp edges

Precision, negatives & compliance

Money has its own footguns: floating-point rounding that leaks fractions of a cent, balances that must never go negative, multi-currency, and a mountain of audit/compliance requirements.

Store money as integer minor units (cents) or fixed-point decimals — never floats — so no cent is ever rounded away. Enforce a hard non-negative invariant (or explicit, controlled overdraft) at the check-and-debit. Handle multi-currency as separate ledgers with explicit FX conversion entries (never mix currencies in one balance). And lean on the append-only ledger for audit/compliance: it's immutable, complete, and reconstructable — exactly what auditors, regulators, and disputes require. Reversals are new compensating entries, never edits.

Design for the unhappy path: Floats → integer/decimal money. Overdraft → enforced invariant. Multi-currency → separate ledgers + FX entries. Audit → the immutable ledger is your evidence. Mistake → append a reversing entry, never delete. In money systems, "never mutate history" is the rule everything else hangs on.

You did it

You just designed a digital wallet.

  • A
  • S — t
  • E — v
  • I — d
  • M — a
  • S — h
  • M — o
built so money is never created, lost, or double-spent — make the calls, crash mid-transfer, run the gauntlet.
Finished this one? 0 / 50 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.