Part 1 · 12 chapters · ~20 min

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.

9

The prompt, deliberately underspecified

interviewer

“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.

the filter for a good question
Ask only what would change your design. "What's the tech stack?" changes nothing, since you can design on any stack. "Is this single-currency or multi-currency?" changes the entire ledger schema. Ask the second, skip the first.

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.

10

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.

“Single currency, or multi-currency from day one?” Decides whether currency is a column or a dimension. If multi-currency, the zero-sum invariant must hold per currency, a transfer can involve two currencies and an FX rate, and no balance is ever a single number. Getting this wrong means a migration later that touches every row.
“Is this a closed loop, or does money enter and leave to external banks?” Decides whether you need connectors, settlement and reconciliation at all. A closed-loop wallet is a database problem. The moment money crosses to NIBSS or SEPA, you inherit asynchrony you do not control, and the design grows a saga.
“What is the tolerance for a lost or duplicated transaction?” Almost always zero, and saying it out loud licenses every expensive choice that follows: synchronous commits, idempotency keys, double-entry, reconciliation. If the interviewer says "eventual is fine", you are designing something other than a bank.
“Roughly how many customers, and how many transactions a day?” Decides whether one database is enough. It is the input to every estimate. Ask it early but do not over-engineer for the answer. Start simple even at large numbers, then let the interviewer push.
“Do balances ever go negative (overdraft, credit)?” Decides whether the balance check is 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.
“One jurisdiction or several?” Decides data residency and compliance. Several means regional partitioning is a requirement rather than an optimisation, because you cannot simply replicate European customer data to Lagos.

The answers for this module

The interviewer, being reasonable, answers the first four and defers the rest:

established
  1. Single currency for now: NGN. "Assume we might add more later."
  2. Closed loop for now: money is already in the system. "Funding comes later."
  3. Zero tolerance for lost or duplicated money.
  4. 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.

11

Requirements: functional

Written as verbs, with an actor. The discipline of the verb form is what surfaces the missing pieces.

functional, in scope for round one
  1. A customer can have one or more wallets.
  2. A customer can read the balance of a wallet.
  3. A customer can transfer from their wallet to another customer's wallet.
  4. A transfer is atomic, so it either completes fully or not at all.
  5. A customer can list the history of movements on a wallet.
  6. 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.

explicitly out of scope, say this out loud
  1. Onboarding, KYC, identity verification.
  2. Funding from and withdrawal to external banks.
  3. Authentication and session management.
  4. Notifications, statements, customer support tooling.
  5. Multi-currency and FX.
why announce the exclusions
Two reasons. It stops the interviewer wondering whether you forgot KYC, and it buys you permission to spend your forty minutes on the ledger instead of sketching eleven shallow boxes. Scoping is how you earn depth.
12

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.

PropertyRound-one targetWhy this number
CorrectnessAbsolute. 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.
ConsistencyStrong 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.
Latencyp99 < 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.
DurabilityA committed transfer survives immediate process and node loss.Forces synchronous_commit = on and a replica with synchronous acknowledgement. We pay latency for this deliberately.
Availability99.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.
AuditabilityEvery 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:

latency ↔ durability
We will fsync the write-ahead log before acknowledging a transfer, and we will wait for a synchronous replica to acknowledge too. That costs roughly 5–15 ms per commit. We accept it, because a transfer that was acknowledged and then lost is a category of bug a bank cannot have. If Z changed, if this were analytics events rather than money, I would relax both immediately.
13

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:

worked numbers
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 for

The 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.

ApproachExact?Verdict
FLOAT / DOUBLENoNever. Not for money, not for intermediate calculations, not "just for display".
NUMERIC(20,4) / DECIMALYesCorrect, and what many banks use. Slower than integers, and invites accidental rounding at the application boundary.
BIGINT minor unitsYesPreferred. Exact, fast, and the unit is unambiguous. Range to ±9.2×10¹⁸, which is 92 quadrillion kobo, which is enough.
the exponent trap
Minor units are not always two decimal places. JPY has zero, so ¥100 is 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:

code
// 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) { ... }
14

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.

code
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:

code
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:

code
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 sentence to use in the room
"This works, and I want to name exactly why I'm going to replace it: the balance and the history are two separate facts that the database does not force to agree. In a ledger they have to be one fact."
15

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.

worked numbers
          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 zero

Why 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:

code
-- 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';   -- overwrites

Under 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

FixMechanismWhen 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 lockSELECT … 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 versionA 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.

lost update
read-then-write vs conditional write
swipe the figure sideways, or tap expand for full screen
1/8
two arrive
A wallet holds 1000. Two withdrawals of 600 each arrive at the same instant.
16

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:

worked numbers
          debit  = −  (value leaves this account)

          credit = +  (value arrives at this account)


          invariant:  Σ signed amounts, per transaction, per currency = 0

The schema

code
-- 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_idaccountamount (kobo)meaning
j-8f1wallet A−50000debit, value leaves A
j-8f1wallet B+50000credit, value arrives at B
sum0invariant holds ✓

Why this makes errors visible

Because the invariant is now a query anyone can run:

code
-- 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.

a fee is not a special case
Charging ₦10 on the transfer does not need new machinery. It is two more entries on the same journal row: −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.
double-entry
one transfer, two entries, sum zero
swipe the figure sideways, or tap expand for full screen
1/7
the event
One business event: a transfer of ₦500 from wallet A to wallet B. It becomes a single journal row, the narrative of what happened.
17

Balance as derived state

With entries as the source of truth, a balance is no longer stored. It is computed:

code
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 balanceDerived balance
Read costO(1), one indexed rowO(n) in the entry count, grows forever
Can it disagree with history?Yes, silentlyNo, it is the history
"Why is the balance this?"UnanswerableAnswerable, entry by entry
Balance at a past momentImpossibleTrivial, add AND id <= $cursor
Audit trailSeparate, unverifiedStructural

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.

the move that earns credit
Say this: "I'm choosing derived balances, and I want to name the cost up front. Reads become O(n) in entry count. At today's scale that's milliseconds. The moment accounts have six-figure entry counts I'd add periodic snapshots so the sum only covers entries since the last snapshot, plus a cache for the hot read. I'd rather add that later than store a balance that can silently disagree with its own history." You have pre-answered the interviewer's next question.

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:

code
-- 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.

18

Sketch v1: one service, one database

The whole round-one design. Deliberately, almost aggressively simple.

The posting transaction in full

code
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

ComponentResponsibilityWhy it is here
API gatewayTLS, auth, rate limit, request idOne place for cross-cutting concerns. Keeps the ledger service focused on money.
Ledger serviceSole writer to entriesOne writer means one place where the invariant can be violated, therefore one place to guard it.
Postgres primaryJournal + entries + accountsACID transactions across multiple rows. The entire correctness argument rests on this.
Sync replicaDurability and failoverA commit acknowledged by two nodes survives the loss of one. Costs ~5ms per commit.
one writer is a deliberate constraint
It will be tempting, three rounds from now, to let the loans service write entries directly. Resist it. Every service that can write to the ledger is a service that can break the invariant. Loans, cards and FX will all end up calling the ledger service rather than the database, and that decision is made here, in round one, when it is still cheap.
architecture v1
a transfer, end to end
swipe the figure sideways, or tap expand for full screen
1/9
request
A transfer request arrives at the gateway with an idempotency key supplied by the client.
19

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:

worked numbers
          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 log

The 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:

worked numbers
          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 loss

Two 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.

the knob, and why we do not touch it
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

OptionWhat it offersWhy not for the ledger
DynamoDBManaged, horizontal, single-digit-ms, TransactWriteItems across up to 100 itemsGenuinely 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.
CassandraEnormous write throughput, multi-region active-activeNo multi-row atomicity. Lightweight transactions are per-partition Paxos and slow. You would be hand-building the guarantee the ledger exists to provide.
MongoDBFlexible documents, multi-document transactions since 4.0Works, 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.
RedisSub-millisecond, atomic Lua scriptsDurability 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 truthAppend-only by nature, perfect auditNo 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:

the answer
"Postgres, because the ledger's core requirement is a conditional multi-row atomic write, insert two entries only if the balance permits, and that is precisely what an ACID relational engine provides natively. The invariant check also wants ad-hoc aggregation, which a SQL engine gives me for free. If the house standard were DynamoDB I would use 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."
20

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…SymptomFixed in
Two transfers hit one wallet simultaneouslyContention, deadlocks, or a lost update if the conditional write is ever loosenedPart 2
A client retries a request it never saw a response toDuplicate posting, money createdPart 2
Accounts accumulate hundreds of thousands of entriesBalance reads degrade from milliseconds to secondsPart 3
Write volume exceeds one primaryQueue depth grows, p99 collapses, no headroomPart 3
The company account is on every transactionOne hot row serialises the entire systemPart 3
Six other teams need to react to postingsEither synchronous coupling or lost eventsPart 4
Balances need a hold that is not yet a debitNo model for available-vs-ledger balancePart 5
Money must enter or leave the systemNo connectors, no settlement, no sagaPart 6
A second currency appearsInvariant is global, not per-currency; no FX modelPart 7
A card network initiates the debit2-second budget, auth holds, stand-in processingPart 8
Someone attacks it deliberatelyNo risk scoring on the pathPart 9
An external statement disagrees with usNo reconciliation, drift is silentPart 10
The business asks a questionAnalytical queries compete with the money pathPart 11
Something breaks at 3amNo SLOs, no tracing, no runbookPart 13

The closing line for round one

how to hand back the floor
"So that's v1: double-entry ledger, append-only entries, derived balances, one service as sole writer, one Postgres primary with a synchronous replica. It is correct, it is auditable, and it will hold a few thousand users comfortably. The two things I'd expect to break first are balance read cost as entry counts grow and contention on shared accounts. Where would you like to push?"

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.