Round one: “design a wallet”
The first round is not about scale. It is about getting the model right, because every later round inherits it. We will spend most of this part on one decision: whether a balance is a number you store or a number you compute. By the end you will see why that single choice determines how the next twelve rounds go. The design we finish with is deliberately small: one service, one database, and an honest list of everything it cannot survive.
The prompt, deliberately underspecified
“Design a wallet system. Users should be able to hold money and send it to each other.”
Two sentences. Notice what is absent: no currency, no scale, no latency target, no mention of where money enters the system from, no definition of what "hold" means legally or technically. That absence is the first exercise.
The temptation is to fill the gaps with assumptions and start drawing. The correct move is to fill them with questions, pick the three or four whose answers would change your architecture, and ask those.
What we are building toward
For the rest of this module the system has a name and a shape: a core banking platform at 20 million customers and 10 million transactions a day, with wallets, loans, cards, six payment rails and four jurisdictions. But the interviewer has not said any of that yet. They will reveal it one round at a time, which is exactly how product requirements actually arrive.
The clarifying questions worth asking
Here are the questions that earn their forty seconds, with what each answer actually decides. Ask four; hold the rest in reserve.
balance >=
amount or balance - amount >= -credit_limit, and
whether you need the three-balance model from Part 5. A surprising amount of
schema hangs off this.
The answers for this module
The interviewer, being reasonable, answers the first four and defers the rest:
- Single currency for now: NGN. "Assume we might add more later."
- Closed loop for now: money is already in the system. "Funding comes later."
- Zero tolerance for lost or duplicated money.
- Start small: "design it for a few thousand users, we'll scale it up as we go."
Note the two "for now"s and the "later". The interviewer is telling you the roadmap. A design that makes currency hard to add is already wrong, even though currency is out of scope today. This is the difference between scoping and ignoring.
Requirements: functional
Written as verbs, with an actor. The discipline of the verb form is what surfaces the missing pieces.
- A customer can have one or more wallets.
- A customer can read the balance of a wallet.
- A customer can transfer from their wallet to another customer's wallet.
- A transfer is atomic, so it either completes fully or not at all.
- A customer can list the history of movements on a wallet.
- The system can prove, at any moment, that money was neither created nor destroyed.
Requirement 6 is the one candidates omit, and it is the most important sentence in the round. It is not a feature a user asked for; it is the property that makes the system a ledger rather than a spreadsheet. Everything structural in this part exists to satisfy it.
- Onboarding, KYC, identity verification.
- Funding from and withdrawal to external banks.
- Authentication and session management.
- Notifications, statements, customer support tooling.
- Multi-currency and FX.
Requirements: non-functional, with numbers
The interviewer said "a few thousand users". Take them at their word for the design, but state the numbers so that later rounds have something to break.
| Property | Round-one target | Why this number |
|---|---|---|
| Correctness | Absolute. Zero lost or duplicated postings. Sum of all entries = 0, always. | Non-negotiable for money. This is the requirement that licenses synchronous commits and double-entry. |
| Consistency | Strong on balance reads and all writes. Read-your-own-writes guaranteed. | A customer who sends money and immediately refreshes must not see the old balance. This rules out reading from an async replica on that path. |
| Latency | p99 < 300 ms for a transfer. p99 < 100 ms for a balance read. | Human perception of "instant" is roughly 300ms. Balance reads are on every screen, so they get the tighter budget. |
| Throughput | ~50 transfers/s, ~500 balance reads/s. | A few thousand users. Comfortably inside one node, which is the point of round one. |
| Durability | A committed transfer survives immediate process and node loss. | Forces synchronous_commit = on and a replica with synchronous acknowledgement. We pay latency for this deliberately. |
| Availability | 99.9% (~8.8 h/year). Prefer refusing a transfer to mis-recording one. | Explicitly choosing C over A for the money path. Worth saying in exactly those words. |
| Auditability | Every movement reconstructible for 7 years. No destructive updates. | CBN retention. Drives append-only storage and corrections-as-new-entries. |
The tradeoff to name now, out loud
Durability and latency are in direct tension, and the resolution should be stated as a choice rather than discovered later:
Why money is never a float
Before any schema, one rule that is not negotiable and is worth the thirty seconds it takes to justify.
IEEE 754 binary floating point cannot represent most decimal fractions exactly,
because it stores a binary fraction. One tenth is 0.0001100110011… repeating in
binary, so 0.1 is stored as the nearest representable value, which
is slightly off. Arithmetic then compounds that error:
0.1 + 0.2 === 0.30000000000000004
0.1 * 3 === 0.30000000000000004
after 10,000 additions of 0.01 the drift is visible in the 7th decimal
across 10M transactions a day, that drift becomes real money
you cannot account forThe rule
Store money as an integer count of the currency's smallest unit,
its minor unit. ₦1,250.75 is stored as 125075 kobo.
$10.00 is 1000 cents. All arithmetic is integer arithmetic, which
is exact.
| Approach | Exact? | Verdict |
|---|---|---|
FLOAT / DOUBLE | No | Never. Not for money, not for intermediate calculations, not "just for display". |
NUMERIC(20,4) / DECIMAL | Yes | Correct, and what many banks use. Slower than integers, and invites accidental rounding at the application boundary. |
BIGINT minor units | Yes | Preferred. Exact, fast, and the unit is unambiguous. Range to ±9.2×10¹⁸, which is 92 quadrillion kobo, which is enough. |
100, not 10000. KWD and BHD have three. So the currency
table must carry an exponent, and formatting must read it rather than
hard-coding /100. Part 7 returns to this; getting it wrong is a
classic multi-currency bug.In the language we will use for examples, that means bigint
throughout, never number:
// exact, integer kobo
const balance: bigint = 125075n; // ₦1,250.75
const debit: bigint = 50000n; // ₦500.00
const after = balance - debit; // 75075n, exact always
// and formatting reads the exponent, never assumes 2
function format(minor: bigint, exponent: number) { ... }Balance as a stored column: the naive design
Start where most people start, because understanding precisely why it fails is what makes the alternative feel inevitable rather than arbitrary.
CREATE TABLE wallets ( id UUID PRIMARY KEY, customer_id UUID NOT NULL, balance BIGINT NOT NULL DEFAULT 0, -- kobo currency CHAR(3) NOT NULL ); CREATE TABLE transfers ( id UUID PRIMARY KEY, from_id UUID NOT NULL REFERENCES wallets(id), to_id UUID NOT NULL REFERENCES wallets(id), amount BIGINT NOT NULL CHECK (amount > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now() );
And the transfer:
BEGIN; UPDATE wallets SET balance = balance - 50000 WHERE id = 'A'; UPDATE wallets SET balance = balance + 50000 WHERE id = 'B'; INSERT INTO transfers (...) VALUES (...); COMMIT;
This is not stupid. Inside one transaction it is atomic, the two updates are applied together, and the balance read is a single indexed lookup, the fastest possible. For a few thousand users it would run for years.
Three defects, in increasing order of seriousness
One: it does not check the balance. Easily fixed, and the fix is instructive:
UPDATE wallets SET balance = balance - 50000 WHERE id = 'A' AND balance >= 50000; -- then: if rows affected = 0, the balance was insufficient. Roll back.
Putting the check in the WHERE clause rather than in a prior
SELECT is the difference between a correct program and a race
condition. We will formalise why in the next chapter.
Two: the history is not the truth. The
transfers table is a log that happens to sit beside the balance,
but nothing structurally forces them to agree. A bug that updates
balance without inserting a transfer leaves no trace. There is no
way to answer "why is this balance what it is?" except to trust it.
Three: there is no way to prove conservation. Requirement 6 said the system must be able to demonstrate that money was neither created nor destroyed. With balances as independent columns, the only check available is "does the sum of all balances equal what we think it should?", and "what we think it should" has no independent source. The invariant is unverifiable, which means in practice it is violated silently.
The lost update, demonstrated
Before we fix the model, see the failure that makes read-then-write unsafe. This is the single most common money bug in the world, and it is worth being able to draw it from memory.
What just happened, in words
Two withdrawals of ₦600 arrive at the same moment against a wallet holding
₦1,000. Both transactions read 1000. Both compute
1000 - 600 = 400. Both write 400. The second write
overwrites the first. It is not lost in the sense of vanishing, it is lost in
the sense of being forgotten. The account has paid out ₦1,200 and shows
a balance of ₦400. ₦600 has been created from nothing.
start: 1000
T1 reads 1000, T2 reads 1000 ← both see the same
value
T1 writes 400, T2 writes 400
end: 400, but 1200 was paid out
600 kobo created from nothing. the ledger no longer sums to zeroWhy SELECT then UPDATE cannot fix it
The instinct is to check first. It does not help, because the check and the write are two separate points in time:
-- UNSAFE. The gap between these two statements is the bug.
SELECT balance FROM wallets WHERE id = 'A'; -- 1000. Fine, proceed.
-- ← T2 commits here
UPDATE wallets SET balance = 400 WHERE id = 'A'; -- overwritesUnder the default isolation level of most databases, READ
COMMITTED, nothing prevents this. Both transactions are individually
valid. Neither observes anything inconsistent. The database is behaving exactly
as documented.
The three fixes, in order of preference
| Fix | Mechanism | When to reach for it |
|---|---|---|
| Conditional update (shown above) | Read and write in one atomic statement. The database takes a row lock for the duration; the second transaction blocks, re-evaluates, and matches zero rows. | Default choice. Simple, no extra round trip, no application-level retry logic. |
| Explicit row lock | SELECT … FOR UPDATE takes the lock early and holds it to commit. | When you must read, compute something complex in the application, then write. Costs a held lock for that whole window. |
| Optimistic version | A version column in the WHERE. Mismatch means someone else moved first; retry. | Low-contention rows, or when the write travels across a service boundary and you cannot hold a lock. |
All three are covered properly in Part 2, including what the lock manager is actually doing. For now the point is narrower: the balance must never be computed in application memory and then written back.
Double-entry: the model banks actually use
Double-entry bookkeeping is roughly eight hundred years old, invented by Italian merchants and formalised by Luca Pacioli in 1494. It survived because it has a property no other model has: it makes errors visible arithmetically.
The rule
Money is never created or destroyed; it only moves between accounts. Therefore every movement is recorded twice, once as a debit and once as a credit, and for any transaction, the entries must sum to zero.
Debit and credit, without the accounting confusion
The words are counter-intuitive because they are stated from the bank's perspective, not the customer's. Skip the vocabulary argument entirely by treating them as signs:
debit = − (value leaves this account)
credit = + (value arrives at this account)
invariant: Σ signed amounts, per transaction, per currency = 0The schema
-- an account is anything money can sit in: a customer wallet, -- a company account, a fee revenue account, a suspense account. CREATE TABLE accounts ( id UUID PRIMARY KEY, kind TEXT NOT NULL, -- customer_wallet | company | fee_revenue | suspense owner_id UUID, -- null for internal accounts currency CHAR(3) NOT NULL, CHECK (kind <> 'customer_wallet' OR owner_id IS NOT NULL) ); -- the journal: one row per business event. The narrative. CREATE TABLE journal ( id UUID PRIMARY KEY, kind TEXT NOT NULL, -- transfer | fee | reversal | disbursement idempotency_key TEXT UNIQUE, -- the duplicate-proof, see ch 28 created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- the entries: the money itself. Append-only. Never updated, never deleted. CREATE TABLE entries ( id BIGSERIAL PRIMARY KEY, journal_id UUID NOT NULL REFERENCES journal(id), account_id UUID NOT NULL REFERENCES accounts(id), amount BIGINT NOT NULL, -- signed minor units. negative = debit. currency CHAR(3) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ON entries (account_id, id DESC); -- history, newest first CREATE INDEX ON entries (journal_id); -- all legs of one event
A transfer of ₦500 from A to B becomes one journal row and two entries:
| journal_id | account | amount (kobo) | meaning |
|---|---|---|---|
j-8f1 | wallet A | −50000 | debit, value leaves A |
j-8f1 | wallet B | +50000 | credit, value arrives at B |
| sum | 0 | invariant holds ✓ |
Why this makes errors visible
Because the invariant is now a query anyone can run:
-- must return zero rows. If it ever returns one, money was created. SELECT journal_id, currency, SUM(amount) AS drift FROM entries GROUP BY journal_id, currency HAVING SUM(amount) <> 0;
That is requirement 6, satisfied. Not by trust, by arithmetic. Part 10 turns this query into a continuously-running alarm.
−1000 against wallet A, +1000
to the fee revenue account. Four entries, still summing to zero. Every product
in Part 5 (loans, overdraft, interest, liens) is this same move. That
generality is why the model is worth the extra table.Balance as derived state
With entries as the source of truth, a balance is no longer stored. It is computed:
SELECT COALESCE(SUM(amount), 0) AS balance FROM entries WHERE account_id = $1 AND currency = $2;
This is strictly better in three ways, and strictly worse in one. Both halves matter.
| Stored balance | Derived balance | |
|---|---|---|
| Read cost | O(1), one indexed row | O(n) in the entry count, grows forever |
| Can it disagree with history? | Yes, silently | No, it is the history |
| "Why is the balance this?" | Unanswerable | Answerable, entry by entry |
| Balance at a past moment | Impossible | Trivial, add AND id <= $cursor |
| Audit trail | Separate, unverified | Structural |
The one defect, and why we accept it for now
An account with 400,000 entries requires summing 400,000 rows to answer "what is my balance". At round-one scale (thousands of users, thousands of entries) this is a few milliseconds and entirely fine. At 10 million transactions a day it is catastrophic, and the fix is snapshots plus a cache, which is chapter 38 in Part 3.
Time-travel, for free
The property that repays the cost more than any other. Because entries are append-only and monotonically ordered, any historical balance is one clause away:
-- balance as it stood at any past instant SELECT SUM(amount) FROM entries WHERE account_id = $1 AND created_at <= '2026-03-14 09:00:00+01';
When a customer disputes a balance from three months ago, or a regulator asks for a position at year-end, or an incident requires knowing what the system believed at 03:47: this is the difference between an afternoon's work and an impossible question.
Sketch v1: one service, one database
The whole round-one design. Deliberately, almost aggressively simple.
The posting transaction in full
BEGIN;
-- 1. duplicate-proof. UNIQUE violation here means we have seen this before.
INSERT INTO journal (id, kind, idempotency_key)
VALUES ($jid, 'transfer', $key);
-- 2. the debit, with the balance check fused into the write.
-- the sub-select is evaluated under the row locks this statement takes.
INSERT INTO entries (journal_id, account_id, amount, currency)
SELECT $jid, $from, -$amt, $ccy
WHERE (SELECT COALESCE(SUM(amount),0) FROM entries
WHERE account_id = $from AND currency = $ccy) >= $amt;
-- 0 rows inserted ⇒ insufficient funds ⇒ raise and roll back.
-- 3. the credit.
INSERT INTO entries (journal_id, account_id, amount, currency)
VALUES ($jid, $to, +$amt, $ccy);
-- 4. belt and braces: assert the invariant before we allow the commit.
-- cheap (two rows), and it converts a silent corruption into a loud failure.
DO $$ BEGIN
IF (SELECT SUM(amount) FROM entries WHERE journal_id = $jid) <> 0
THEN RAISE EXCEPTION 'ledger imbalance on %', $jid;
END IF;
END $$;
COMMIT;What each piece buys
| Component | Responsibility | Why it is here |
|---|---|---|
| API gateway | TLS, auth, rate limit, request id | One place for cross-cutting concerns. Keeps the ledger service focused on money. |
| Ledger service | Sole writer to entries | One writer means one place where the invariant can be violated, therefore one place to guard it. |
| Postgres primary | Journal + entries + accounts | ACID transactions across multiple rows. The entire correctness argument rests on this. |
| Sync replica | Durability and failover | A commit acknowledged by two nodes survives the loss of one. Costs ~5ms per commit. |
Why RDBMS here: the guarantees, and how they are kept
"I'd use Postgres" is an assertion. What follows is the justification, and it is the kind of depth that distinguishes a senior answer: not just which guarantee, but what the database is physically doing to provide it.
The guarantee we need, precisely stated
Two INSERTs into entries and one into
journal must become visible to every reader at the same instant,
and must survive a power failure occurring one microsecond after
acknowledgement. That is atomicity plus
durability across multiple rows in multiple tables.
How atomicity is actually achieved
Not by holding the writes in memory until commit. Postgres writes tuples into
shared buffers as it goes, and each tuple carries the transaction id
(xmin) that created it. Visibility is then a function of that
id:
every row carries xmin (creating txid) and xmax (deleting txid)
a row is visible to a reader if:
xmin is committed and xmin < my snapshot
and xmax is unset, aborted, or > my snapshot
so "atomic" = flipping ONE bit in the commit logThe commit status of a transaction lives in the commit log
(pg_xact), at two bits per transaction. Before commit, those bits say
in progress and every one of our three rows is invisible to everyone
else. At commit, the bits flip to committed, and all three rows become
visible simultaneously, because visibility was always derived
from that single location. Atomicity across rows is achieved by making
multi-row visibility depend on a single-bit write. That is the trick, and it is
elegant.
How durability is actually achieved
Write-ahead logging. Before any data page is modified on disk, the intention is written to a sequential log and flushed:
1. modify pages in shared buffers (memory, fast but not durable)
2. append WAL records describing the change (sequential)
3. fsync the WAL ← the durability point. ~1–10 ms on NVMe
4. acknowledge the commit to the client
5. flush dirty data pages later, lazily, at a checkpoint
crash between 4 and 5 → replay the WAL on startup, no lossTwo properties make this fast enough to be acceptable. The WAL is
sequential, and sequential writes are roughly an order of
magnitude cheaper than random ones. And commits from concurrent sessions are
group-committed, so one fsync can durably commit
dozens of transactions, so throughput does not fall linearly with the flush
cost.
synchronous_commit = off skips waiting for the flush and makes
commits dramatically faster. It also means a crash can lose the last fraction of
a second of acknowledged transactions. For analytics events: fine. For
money: never. Knowing the knob exists and choosing not to turn it, out
loud, is a better answer than not knowing.Why not the alternatives, for this table, at this scale
| Option | What it offers | Why not for the ledger |
|---|---|---|
| DynamoDB | Managed, horizontal, single-digit-ms, TransactWriteItems across up to 100 items | Genuinely viable, and this is the right answer at a DynamoDB-first shop. Costs: no ad-hoc query, so the invariant check and every reconciliation report needs a secondary path; 100-item transaction ceiling constrains batch postings; GSI design freezes your access patterns early. |
| Cassandra | Enormous write throughput, multi-region active-active | No multi-row atomicity. Lightweight transactions are per-partition Paxos and slow. You would be hand-building the guarantee the ledger exists to provide. |
| MongoDB | Flexible documents, multi-document transactions since 4.0 | Works, but the flexibility is a liability here, because a ledger wants a rigid schema and a database that refuses malformed money. Transactions are newer and less battle-tested for this workload. |
| Redis | Sub-millisecond, atomic Lua scripts | Durability is best-effort. AOF everysec can lose a second of acknowledged writes. Correct as a cache of balances, which is exactly where Part 3 puts it. Never as the ledger. |
| Event store / Kafka as truth | Append-only by nature, perfect audit | No conditional write. You cannot express "append this debit only if the balance permits" without an external consistency mechanism. Part 4 uses Kafka for propagation, not for truth. |
The honest summary to give in the room:
TransactWriteItems and
accept that the invariant check moves to a Streams-driven verifier. That is a
real design, just a different one. What I would not do is put the ledger in a
store with no multi-row atomicity."What v1 cannot survive
Close the round by listing your own design's failure modes before the interviewer does. Each line here becomes a later part. This list is the rest of the module.
| Breaks when… | Symptom | Fixed in |
|---|---|---|
| Two transfers hit one wallet simultaneously | Contention, deadlocks, or a lost update if the conditional write is ever loosened | Part 2 |
| A client retries a request it never saw a response to | Duplicate posting, money created | Part 2 |
| Accounts accumulate hundreds of thousands of entries | Balance reads degrade from milliseconds to seconds | Part 3 |
| Write volume exceeds one primary | Queue depth grows, p99 collapses, no headroom | Part 3 |
| The company account is on every transaction | One hot row serialises the entire system | Part 3 |
| Six other teams need to react to postings | Either synchronous coupling or lost events | Part 4 |
| Balances need a hold that is not yet a debit | No model for available-vs-ledger balance | Part 5 |
| Money must enter or leave the system | No connectors, no settlement, no saga | Part 6 |
| A second currency appears | Invariant is global, not per-currency; no FX model | Part 7 |
| A card network initiates the debit | 2-second budget, auth holds, stand-in processing | Part 8 |
| Someone attacks it deliberately | No risk scoring on the path | Part 9 |
| An external statement disagrees with us | No reconciliation, drift is silent | Part 10 |
| The business asks a question | Analytical queries compete with the money path | Part 11 |
| Something breaks at 3am | No SLOs, no tracing, no runbook | Part 13 |
The closing line for round one
You have now named the next round yourself. The interviewer's follow-up is almost always one of the two you flagged, and Part 2 takes the second.