How would you design a ledger so that any balance can always be proved?
Store money as immutable double-entry rows where every movement writes at least two balanced entries, derive balances by summing those entries, correct mistakes with compensating entries instead of updates, and keep each currency in its own denominated account so nothing is ever summed across units.
What the interviewer is scoring
- Does the candidate reach for entries as the source of truth rather than a mutable balance column
- Whether the two-row invariant is stated as something enforced, not merely as a convention the code follows
- That a materialised balance is offered with the specific invariant and reconstruction rule that makes it safe
- Whether corrections are handled by posting new rows, with the audit consequence of an UPDATE named explicitly
- Does the candidate refuse to add amounts in different currencies, and say where the FX difference lands
Answer
Money is a log, not a number
The design decision that everything else follows from is that the ledger stores movements, not state. An account does not have a balance field that code adds to and subtracts from. It has a stream of entries, and its balance is the sum of that stream. That inversion is what makes a balance provable: you can point at the exact set of rows that produced the number, and anyone recomputing from those rows gets the same answer.
A mutable balance column cannot be proved. If accounts.balance reads 4,200 and the customer says it should be 4,250, there is nothing to compare against. The row records the outcome of every write that ever touched it and none of the reasons, so a bug that double-applied one debit six months ago is indistinguishable from correct history. You are left arguing about the number rather than examining the evidence.
Two rows, one movement
Double-entry is the mechanism that makes the log self-checking. Every movement is recorded as a transaction containing at least two entries whose signed amounts sum to zero: money leaves one account and arrives in another, in the same write. Paying a merchant 50 debits the customer's account 50 and credits the merchant's payable account 50. There is no such thing as a single-sided entry.
The value of this is a global invariant you can test at any moment. Sum every entry in the ledger, per currency, and the result must be exactly zero. If it is not, you have either a bug that wrote one leg without the other or a partially committed transaction, and you know that before a customer tells you. A single-sided design has no equivalent check — every possible set of numbers is internally consistent, which is another way of saying no error is ever detectable.
This is also why you need internal accounts that are not customer accounts. When cash arrives from a card acquirer but has not yet been attributed to a user, it does not vanish for a day; it sits credited to a suspense account and is later moved out of it. Every pending, in-flight or unattributed state gets its own account, so that no operation ever needs to be unbalanced.
-- Entries are append-only: no UPDATE, no DELETE, no nullable status column.
CREATE TABLE entry (
id bigserial PRIMARY KEY,
transaction_id uuid NOT NULL REFERENCES ledger_transaction(id),
account_id uuid NOT NULL REFERENCES account(id),
currency char(3) NOT NULL,
-- Integer minor units. A float balance is a rounding bug with a schema.
amount_minor bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The account fixes the currency, so no query can add USD to JPY by accident.
CREATE TABLE account (
id uuid PRIMARY KEY,
currency char(3) NOT NULL,
kind text NOT NULL -- customer, suspense, fee_income, fx_position
);
Amounts are integers in minor units with the currency fixed on the account, never floating point and never a bare decimal whose scale you have to remember. Currencies do not all have two decimal places, so the scale belongs to the currency code, not to the column.
When a stored balance is allowed
Summing a million entries on every balance read is not viable, and the answer is not to abandon derivation but to cache it under an invariant strong enough to be checked. Store a snapshot: an account, a balance, and the id or sequence number of the last entry included in it. The current balance is then the snapshot plus the sum of entries after that point, which is a bounded scan, and the snapshot is verifiable because you can recompute it from the log and compare.
Two rules keep this safe. First, the snapshot must be written in the same database transaction as the entries it accounts for, so it can never reflect a state the log does not contain. Second, nothing may write the snapshot except the code path that appends entries — the moment an operational script "fixes" a balance directly, the invariant is gone and you are back to an unprovable number. A periodic job recomputes snapshots from entries and alerts on any difference; that job existing is what turns the cache from a risk into an optimisation, because a drift becomes an alarm instead of a support ticket.
Interviewers press here because plenty of candidates recite immutability and then quietly reintroduce a mutable balance for performance without saying what protects it. Offering the snapshot with its invariant and its verification job is the difference between having read about ledgers and having operated one.
Fixing mistakes without rewriting history
Corrections are new entries. If a transfer posted the wrong amount, you post a reversing transaction that cancels it and then post the correct one, leaving all three visible and linked by a corrects reference on the later transaction. The account's history now says what happened: a wrong posting, a reversal, a right posting.
Deleting or updating the original is tempting because the resulting balance is identical, and it is exactly the thing that destroys the ledger's purpose. A statement already sent to the customer, a regulatory report already filed, and a reconciliation already signed off all referenced the old rows; mutating them makes every prior artefact unreproducible. It also erases the evidence needed to find out how the wrong amount got posted in the first place. Auditors look for this specifically, and a schema with no UPDATE grant on the entry table is a much stronger answer than a code review convention.
Multi-currency
The rule is that you never add amounts denominated in different currencies, and the schema should make that impossible rather than merely discouraged. Each account holds exactly one currency, so the zero-sum invariant is checked per currency and a query that tried to total a customer's holdings across currencies would have to name the conversion explicitly.
An FX conversion is therefore not one transaction with two currencies in it; it is a debit of the source currency and a credit of the target currency, balanced on each side by an FX position account that absorbs the difference between the rate you quoted the customer and the rate you obtained. That account is where trading gain and loss shows up, and if it drifts in one direction you have a pricing problem rather than a bookkeeping one. Revaluing multi-currency positions for reporting is a read-time concern using a stated rate and timestamp, not something you write back into the ledger as though the customer's balance had changed.
Anyone can recite double-entry; what an interviewer is listening for is a design where a wrong balance is detectable. Immutable entries give you the evidence, the per-currency zero-sum gives you the test, and the snapshot invariant is what lets you keep both while still answering a balance query in a millisecond.
Likely follow-ups
- Where does the rounding remainder go when you split a payment three ways?
- How do you enforce "no account may go negative" without serialising every write to that account?
- How would you shard this ledger, and what breaks first when you do?
- What does your daily proof job compare, and what do you do when it disagrees?
Related questions
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on immutability7 min
- When is a factory the right answer, when is a builder, and when should you just call the constructor?mediumAlso on immutability7 min
- If you override equals but not hashCode, what breaks and when?mediumAlso on immutability4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min
- How do you test a Node service, and what do you refuse to mock?mediumSame kind of round: concept5 min
- How does Node load your modules, and how would you make a Node service use more than one core?mediumSame kind of round: concept6 min
- Angular, Vue and React are answers to the same problems. Compare how each handles change detection, reactivity and dependency injection.hardSame kind of round: concept5 min
- How do you turn what the business tells you into a domain model that holds up once the exceptions arrive?hardSame kind of round: design7 min