Course Content
System Design Interview
31 sections · 71 lessons
Digital Wallet: requirements, scale and double-entry bookkeeping
"Design a digital wallet." Users hold balances; money moves between them. Two sentences of requirements, and one of the hardest correctness problems in this course.
This lesson states the invariant, runs the numbers that break the simple design, and introduces the data model everything else rests on. The second lesson rejects the tempting shortcuts and builds the event-sourced core; the third takes it across shards and covers verification and the follow-up questions.
Why a wallet is harder than a payment system
Section 28 (Design a Payment System) moved money between a customer and a merchant through an external provider. The provider held the funds and produced an authoritative record you could reconcile against.
A wallet holds the funds itself. There is no external counterparty whose settlement file tells you what is true. Your own records are the truth, which means an error is not a discrepancy to be resolved — it is money that either appeared or vanished.
That changes the emphasis. Section 28 was about safe interaction with an unreliable external system. This section is about making an internally inconsistent state impossible to represent in the first place.
The invariant
Write this down before anything else:
For any transfer, the sum of all wallet balances after it equals the sum before it.
Money is conserved. A transfer moves value; it does not create or destroy it. Top-ups and withdrawals are the only operations that change the total, and they are matched by movements in an external bank account, so the conservation holds across the wider boundary too.
Everything in this section exists to make that sentence true under crashes, retries, concurrency, and partial failure. Having it stated as a checkable proposition is what allows Verification and follow-ups to audit it continuously.
The questions that shape everything after
- Transfers between wallets only, or top-up and withdrawal too? Interacting with the outside banking world adds asynchronous, slow, sometimes-reversible operations.
- Multi-currency? If yes, is a transfer between currencies allowed, and who bears the exchange difference? Multi-currency also means a wallet is several balances, not one.
- What transaction rate? This is the question that decides whether one database is enough, and the arithmetic below shows the answer changes the architecture completely.
- Can a balance go negative? Almost always no, and that "no" is the constraint that forces ordering on debits.
- What are the latency expectations? Users expect a transfer to appear instant, which constrains how much asynchronous processing is acceptable on the write path.
- What must be auditable and reportable? Financial regulators typically require immutable records and specific reports. Treat the specifics as a legal question and design for immutability regardless.
The assumptions this section uses
| Question | Assumption |
|---|---|
| Operations | Transfer, top-up, withdrawal |
| Currency | Multi-currency, one balance per wallet per currency |
| Rate | Starts at 1,000 transfers per second; the interviewer raises it to 1,000,000 |
| Negative | Never — a debit that would overdraw is rejected |
| Latency | Under 500 ms for the sender to see the transfer applied |
| Audit | Full immutable history, reconstructible to any past instant |
Requirements and scale
Functional requirements
- Transfer an amount from one wallet to another, atomically.
- Top up a wallet from an external funding source, and withdraw to one.
- Read a wallet's current balance.
- Read a wallet's transaction history, paginated.
- Produce an auditable record of every movement, permanently.
Non-functional requirements
- The invariant holds always. Money is never created or destroyed by a transfer.
- No double-spend. A retried transfer moves the money once.
- No negative balances, unless the product explicitly permits credit.
- Reproducibility. The state at any past instant can be reconstructed exactly.
- Consistency over availability: refuse the transfer rather than risk it being wrong.
Starting small, then being pushed
At 1,000 transfers per second, this is a solved problem. One relational database, one transaction per transfer, two balanced rows written and two balances updated. A well-provisioned node handles it, the invariant is enforced by the transaction, and you could stop here.
Say that out loud. Reaching for a complicated architecture when a database would do is a negative signal, and demonstrating that you know where the simple answer stops working is a positive one.
Then the interviewer raises it. "Now make it a million transfers per second." That is the real question, and here is what the number does.
The arithmetic at one million transfers per second
Ledger entries. Double-entry bookkeeping, introduced later in this lesson, writes two entries per transfer. 1,000,000 × 2 = 2 million entries per second.
Storage rate. An entry needs a transfer identifier, an account, a direction, an amount, a currency, a sequence number, and a timestamp — call it 100 bytes. 2 million × 100 bytes = 200 MB/s, or 17 TB per day, or 6.2 PB per year. Retained for years, because financial records are.
Database capacity. A relational node committing durably to disk manages on the order of 10,000 to 50,000 write transactions per second, depending on hardware, group commit, and how much work each transaction does — an order of magnitude, not a benchmark. At 1 million transfers per second you need somewhere between 20 and 100 shards purely for throughput, before accounting for the fact that a transfer touches two wallets that may be on different shards.
Cross-shard fraction. With wallets distributed randomly across 100 shards, the chance both ends of a transfer are on the same shard is 1 in 100. So 99% of transfers are cross-shard, each requiring coordination. That single number is why Distributed correctness exists and why the naive "shard it" answer is incomplete.
Read load. Balance checks are far more frequent than transfers — users open the application and look. Assume 10 reads per transfer: 10 million balance reads per second. That number alone rules out computing a balance by summing history on every read, and it is the argument for the read/write split in Event sourcing and the command-query split.
Double-entry bookkeeping
Before any architecture, the data model. It is from the fifteenth century, and it is the correct one. Understanding why is worth more than knowing that.
The single-number model, and why it fails
The obvious model stores one balance per wallet and updates it:
wallet(user_id, balance)A transfer runs two updates. It has three problems, and the third is fatal.
No history. The balance tells you what it is, not how it got there. "Why is my balance 480?" is unanswerable.
No correction path. A wrong balance can only be fixed by writing a different number, which is indistinguishable from a fraud and leaves no evidence of what happened.
An inconsistent state is representable. If the process dies between the two updates, the database happily holds a state where A has been debited and B has not been credited. Money has vanished, the schema is satisfied, and nothing about the data indicates a problem. A transaction prevents this on one machine; nothing prevents it across shards.
The double-entry model
transaction(txn_id, created_at, type, idempotency_key)entry(entry_id, txn_id, account_id, currency, amount_minor, seq)with the rule: for every txn_id, the sum of amount_minor per currency is zero.
Now a partial write is not an inconsistent balance — it is a transaction whose entries do not sum to zero, which is detectable by a query anyone can run:
SELECT txn_id, currency, SUM(amount_minor) AS imbalanceFROM entry GROUP BY txn_id, currency HAVING SUM(amount_minor) <> 0;That query returning zero rows is a proof of internal consistency across the entire system. That is the property the model buys, and it is why it is the right choice: it converts a class of silent corruption into a class of detectable defect.
Accounts that are not user wallets
The model needs internal accounts, and naming them is part of a good answer:
- Clearing accounts for money in flight — funds that have left a bank and not yet reached a wallet. Both halves of a slow external movement stay balanced by parking value here.
- Revenue accounts for fees.
- Suspense accounts for money whose correct destination is not yet known, so it is recorded rather than dropped while a human investigates.
Every unit of value is in exactly one account at every instant. That is what makes the invariant from the start of this lesson checkable: the sum of every entry in the system, across all accounts, is zero.
The honest cost
Double-entry roughly doubles write volume and makes reading a balance more expensive, since a balance is a sum rather than a column. Event sourcing and the command-query split and Verification and follow-ups address both. The trade is more writes and more read machinery in exchange for corruption that announces itself, and in a wallet that trade is not close.