Design a payment ledger
The capstone prompt, and the one where 'eventually consistent, cache it, fan it out' is exactly wrong — a ledger must be correct to the last unit, forever, under retries and concurrency. This case study assembles the answer from the whole curriculum: money as integers never floats, balances as a projection over an append-only log never stored as state, idempotency keys so a retried transfer applies once, and the strong-consistency single-writer concurrency control that a feed would never need. It's also, not coincidentally, exactly what Fable is — so this is the Fable case study delivered as an interview answer.
Design a payment ledger
"Design a payment system / wallet / ledger" is the capstone, and it's the prompt that inverts everything the feed and shortener taught you to reach for. There, the instinct was cache aggressively, accept staleness, fan out, go eventually-consistent. A ledger forbids almost all of it: money must be exactly right, forever, under retries and concurrency, and "the balance was stale for a few seconds" is not a minor UX issue — it's a correctness bug that lets someone overspend or double-pay. This is where you demonstrate that you know when not to reach for the scalable-but-loose tools. Happily, it's also exactly what Fable is, so the whole Fable, Deconstructed series is this answer, built.
Requirements
Functional: hold balances for accounts; record transfers/transactions between them; compute an account's balance; do it correctly under concurrent operations and network retries. Non-functional — and these are unusually strict: strong consistency (a read reflects every committed write — no eventual), durability (a committed transaction is never lost), auditability (you can prove how any balance came to be), and correctness under retries and concurrency (a flaky network or two simultaneous writes must never corrupt a balance). Note what's absent from the usual list: we do not optimize for eventual consistency or aggressive caching of the truth, because the truth here can't be stale.
The four principles that define a correct ledger
Before any boxes, the design is really four non-negotiable modeling decisions, each drawn from a lesson:
1. Money is integer minor units, never a float. 0.1 + 0.2 ≠ 0.3 in IEEE 754, and a ledger that drifts by fractions of a unit is a ledger nobody trusts. Store the count of the smallest unit (paise, cents) as an integer (bigint), and never let a float touch a monetary value anywhere in the stack. Say the word IEEE 754 out loud — it signals you've been bitten.
2. Balances are a projection over an append-only log, never stored as mutable state. You do not keep a balance column you increment and decrement — that's how ledgers drift over years, because any missed or double-applied update silently corrupts a number with no way to detect it. Instead the ledger is append-only: every transaction is an immutable entry, and a balance is computed by summing the entries. The log is the truth; the balance is a derived value you may cache but always recompute, ideally with a periodic assertion that the cached balance still equals the re-derived one. This also gives auditability for free — the full history is the record.
3. Every write is idempotent, via a key with a unique constraint. Networks retry, and a retried transfer must apply exactly once. The client generates an idempotency key per logical transaction; the ledger enforces a unique constraint on it, so a duplicate insert loses to the database rather than to application logic that might race. This is at-least-once delivery + idempotent effect = exactly-once effect, the curriculum's central reliability idea, applied where it matters most.
4. Strong consistency and real concurrency control — not eventual. This is the CAP inversion. A feed picks AP/eventual; a ledger picks CP/strong, because two concurrent debits that both read the old balance and both succeed is a lost update that lets an account overspend. You prevent it with locks (pessimistic) or version/compare-and-swap (optimistic), or serializable isolation — and you accept the latency and reduced availability that strong consistency costs, because for money it's non-negotiable.
High-level design & the double-entry model
- Data model: an
accountstable and an append-onlyentries(ortransactions) table. The professional pattern is double-entry: every transaction records a matched debit and credit — money leaves one account and enters another — so that the sum of all entries is always zero (money is conserved, never created or destroyed). A balance isSUM(entries)for an account; a system-wideSUMof everything should be zero, which is a powerful invariant you can assert to catch bugs. - Flow: transfer request (with idempotency key) → validate → in one ACID transaction, under the right lock, check sufficient balance and append the debit+credit entries → commit → invalidate the cached balances.
Deep-dive: concurrency, and the overspend guard
The interviewer will push on "two people spend from the same account at once." Both transactions read balance = ₹100, both try to spend ₹80, and without protection both commit → the account is ₹60 overdrawn, a lost update. The fixes are the locks lesson: lock the account row (SELECT … FOR UPDATE) so the second transaction waits and re-reads the reduced balance, or an optimistic version check that makes the second write fail and retry, or serializable isolation that aborts one. Whichever you choose, the guard must validate against the invariant ("balance can't go negative" / "net can't pass zero"), computed inside the locked transaction — the exact lesson of Fable's over-settle guard.
Deep-dive: scaling a ledger is hard (and that's the point)
Scaling is where a ledger is genuinely harder than a feed, because you can't just cache and go eventual. Sharding by account works for single-account operations, but a transfer spans two accounts that may live on different shards — and a distributed transaction (2PC) across shards is slow and blocking. The elegant escape is to model the transfer itself as an append-only object with its own idempotency key, recorded as two linked single-account entries reconciled asynchronously — trading instant cross-account atomicity for an auditable, saga-like eventually-settled transfer, within strong per-account consistency. The honest interview note: most ledgers should not shard until forced, because a single well-provisioned Postgres handles enormous transaction volume, and the complexity of a sharded strongly-consistent ledger is precisely the kind you defer as long as you can.
Go deeper
- Modern Treasury — "Accounting for Developers" — The best developer-facing explanation of double-entry, append-only ledgers, and why balances are derived not stored — principles 2 and the double-entry model, in depth from a payments company.
- Martin Fowler — the Money pattern — Why money is an integer-safe type carrying amount + currency with explicit arithmetic — principle 1, and exactly what Fable's @hisaab/money implements.
- Stripe — idempotent requests — The reference implementation of idempotency keys for money movement — principle 3, from a payments API that cannot afford to double-charge.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- A ledger inverts the instincts a feed teaches. Name the non-functional requirements a ledger prioritizes that a feed does not, and explain why "eventually consistent, cache the balance" is exactly wrong here.
- State the four defining principles of a correct ledger, and name the specific bug each one prevents (float drift, stored-balance rot, double-apply on retry, concurrent overspend).
- Explain the double-entry, append-only model: what a balance is, what invariant the sum of all entries satisfies, and why this gives auditability for free.
- Two concurrent debits both read balance ₹100 and both spend ₹80. Name the anomaly, and give three ways to prevent it, all validating against the invariant inside the transaction.
- Why is scaling a ledger harder than scaling a feed? Explain the cross-shard transfer problem and the append-only-transfer modeling that avoids a 2PC across shards.
- State the two cardinal sins of ledger design and why each passes every demo before failing unfixably. How does Fable avoid both?