Design a Double-Entry Ledger
Design a Double-Entry Ledger
Every payments company eventually asks some version of this in an interview, and Stripe asks it almost verbatim: "design the system that tracks money." Most candidates reach for a balance column and an UPDATE statement, which works fine in the demo and quietly falls apart the first time a network call times out mid-transfer or an auditor asks "prove this number is right." The staff-level differentiator isn't knowing that ledgers exist — it's understanding why double-entry bookkeeping is a correctness mechanism, not an accounting formality: every transaction is a set of postings whose debits and credits sum to exactly zero, balances are derived by folding over an immutable append-only journal rather than stored as a number you trust, and money is structurally never created or destroyed. This kata makes you build that mechanism yourself — in Java and Go, from an empty file — reproduce the exact bug that makes naive ledgers lose money, and fix it with an atomic multi-leg transaction. It connects directly to the ledger design inside Designing a Payment System and to idempotency keys for payments, which this kata builds from scratch rather than citing.
1. The Trap
You're asked to track account balances for a small fintech app. The obvious first cut: one mutable number per account.
-- "obviously correct" schema
CREATE TABLE accounts (id TEXT PRIMARY KEY, balance_cents BIGINT);
-- transfer($30.00 from alice to bob) as two separate statements/service calls
UPDATE accounts SET balance_cents = balance_cents - 3000 WHERE id = 'alice';
-- ... network call succeeds, response is being written back to the caller ...
-- ... the process is killed / the connection drops / an unrelated exception fires here ...
UPDATE accounts SET balance_cents = balance_cents + 3000 WHERE id = 'bob'; -- NEVER RUNS
Alice's balance is now $30.00 lower. Bob's balance never moved. Nobody has that $30 — it isn't in Alice's account, it isn't in Bob's account, it isn't anywhere. Run the exact scenario later in this kata's Java reference implementation and the measured result is ledger total: -3000 where it must be 0 — not a rounding artifact, an exact $30.00 gone. Widen the aperture: even when nothing crashes, a single-column balance has no way to notice a bug that decrements one account without a matching increment somewhere else. There is no second signal to check against — the number simply is the truth, silently, until an accountant reconciles against the bank statement weeks later and finds a gap with no attached explanation. That's the trap: a mutable balance column plus a naive two-step "debit, then credit" transfer has no structural way to catch the bug that makes money vanish, and no audit trail to find it after the fact.
2. Scope it like a senior
Before writing a line of ledger code, pin the contract down out loud:
- Which account types? The five classic roots — Asset, Liability, Equity, Revenue, Expense — enough to model a real chart of accounts (cash is an asset, a merchant's payable balance is a liability, and so on). Multi-currency is a named extension, not core: single currency, no FX.
- What's the atomicity requirement? Every transaction is a set of postings across one or more accounts, and either all of them commit or none do — there is no such thing as a half-posted transaction. This is the whole kata.
- Are callers allowed to retry? Yes, and they will — a payment API timing out on the client side while the server actually succeeded is the normal case, not the exception. So posting must be idempotent: the same logical transaction retried with the same key must not double-post.
- Can a posted transaction ever be edited or deleted? No. Once committed, a journal entry is permanent — this is both a correctness property (nothing silently rewrites history under you) and, in real systems, a regulatory one (SOX-style controls forbid mutating financial records after the fact). A mistake is fixed by posting a new, reversing entry, never by touching the old one.
- How is money represented? Integer minor units (cents), decided up front — never a float. The reason is derived below, and it's the same reason Splitwise's ledger uses integer cents, just with a stricter invariant this time.
- Does the ledger need to be checked against an external source of truth? Yes — a bank statement, a card network settlement file. This is reconciliation, and it's exactly why the journal has to be a fully itemized, append-only log rather than a single denormalized number: you reconcile entry-by-entry, not total-vs-total.
- What about concurrent access to the same account? Two transfers touching "cash" at the same instant must not lose an update. Thread-safe, provably so.
Answer: a five-type chart of accounts, atomic multi-posting transactions, idempotent posting, reversal-only corrections, integer cents, a reconciliation-ready append-only journal, thread-safe commits.
3. Reason to the design
Simplest thing that could work is the trap itself: one mutable balance column per account, updated in place. Why it fails, beyond the vanishing-money bug above: (1) no audit trail — when a balance is wrong, there is no data to inspect, only the current wrong number; (2) no self-check — a single column is an isolated point of truth with nothing to cross-validate it against, so corruption is invisible until someone notices the symptom; (3) silent drift — a bug that debits without a matching credit (or vice versa) produces no error, no exception, nothing. The system cannot tell the difference between "correct" and "quietly wrong."
First iteration: stop mutating a single number; instead append every balance-changing event to a log, and derive the balance by summing it. That buys you history — you can now answer "how did we get here" — but it still doesn't self-check. Nothing stops a bug from appending a $3,000 credit to Bob's account that was never debited from anywhere. The log has a paper trail now, but the trail can still record something that shouldn't exist.
The double-entry insight (Luca Pacioli formalized this in 1494, and it has survived 500+ years of accounting practice for exactly this reason): define every transaction so that its postings — the individual account movements inside it — must sum to exactly zero. A debit to one account is only ever recorded alongside an equal, opposite credit to another. Once you enforce that at the transaction boundary, a much stronger property falls out for free: sum every account's balance across the ENTIRE ledger, and it must always be exactly zero too (this is the same statement as the accounting identity Assets = Liabilities + Equity, just rearranged so everything is on one side). That's not a nice-to-have — it's a machine-checkable invariant. If you ever compute that global sum and it isn't zero, you know with certainty that a bug, a race condition, or fraud has touched the ledger, without needing to know which account or which transaction. That single fact is what an auditor is literally checking when they say "the books balance," and it's the origin of the whole double-entry idea: it converts "is our accounting correct" from a matter of trust into an arithmetic fact you can verify in O(n).
One more consequence worth naming explicitly: a balance is not stored, it's derived — balanceOf(account) = Σ posting.amountCents for every posting touching account, folded over the immutable journal. The journal is the source of truth; a balance is a read-optimized cache over it that you can always recompute from scratch to verify. (If this smells like event sourcing / CQRS from the systems-design side, that's not a coincidence — double-entry bookkeeping is arguably the oldest production deployment of that exact pattern.)
The crux, and the reason this kata exists: "postings sum to zero" only protects you if it's enforced atomically across every leg of one transaction. If you check the invariant, then apply leg 1, then apply leg 2 as two separate steps — you've reintroduced the Movement 1 bug one layer up, just with an extra validation step that didn't actually prevent it. The invariant has to be: validate the whole transaction first, then apply every leg in a single, indivisible commit. All legs land, or none do. That's the mechanism Movement 4 builds.
4. Build it — milestones
Attempt-first: the contract is Ledger.post(Transaction) → TransactionResult (atomically commits a set of postings whose amounts sum to zero, or rejects the whole thing), Ledger.transfer(from, to, amountCents, idempotencyKey) (a convenience 2-posting transaction), Ledger.reverse(originalTxnId, idempotencyKey) (a correction, never an edit), and Ledger.balanceOf(account) / Ledger.totalBalance() (derived reads). Try each milestone before reading the reference implementation below.
- M1 — One balanced transaction, derived balance. Open a few accounts, post one 2-leg transfer, confirm both balances and prove
totalBalance() == 0. - M2 — Reject unbalanced transactions and unknown accounts. Validate BEFORE applying anything, so a rejected transaction is a true no-op — nothing partially lands.
- M3 — Idempotent posting. A retried transaction with the same idempotency key returns the original result instead of double-posting. This is the exact mechanism the idempotency keys for payments page names as required for any retried financial operation — here you implement it, not just cite it.
- M4 — Atomic, thread-safe commit. Guard "validate the whole transaction, then apply every leg" as ONE critical section. Prove it holds under concurrent transfers hammering the same accounts.
- M5 — Corrections are reversing entries.
reverse()posts a NEW transaction with every leg negated. The original entry is never edited or deleted — immutability by construction, not by convention.
Reference implementation — Java
Five small types — Account, Posting, Transaction, TransactionResult, and the Ledger itself — plus one deliberate design choice worth reading twice: post() fully validates a transaction (idempotency check, balance check, account-existence check) before it touches the balances map at all. Nothing below that point can fail, so a rejected transaction genuinely changes nothing. Ledger also ships an UNSAFE twin path (applyPostingUnsafe / transferNaive) purely so the break-it section below can reproduce the Movement 1 bug on the exact same class — never call it from real code.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
/** The five root account types from classic double-entry bookkeeping. */
enum AccountType { ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE }
final class Account {
final String id;
final String name;
final AccountType type;
Account(String id, String name, AccountType type) { this.id = id; this.name = name; this.type = type; }
}
/** One leg of a transaction. Positive amountCents = a debit; negative = a credit. */
final class Posting {
final String accountId;
final long amountCents;
Posting(String accountId, long amountCents) { this.accountId = accountId; this.amountCents = amountCents; }
}
/**
* An atomic, immutable unit of the journal. ALL its postings commit together
* or none do. By construction its postings must sum to exactly zero --
* money is only ever moved between accounts, never created or destroyed.
*/
final class Transaction {
final String id;
final String idempotencyKey;
final List<Posting> postings;
final String reversalOf; // null unless this transaction reverses another
Transaction(String id, String idempotencyKey, List<Posting> postings, String reversalOf) {
this.id = id; this.idempotencyKey = idempotencyKey; this.postings = postings; this.reversalOf = reversalOf;
}
}
/** Returned by post(): the committed (or idempotently-replayed) transaction id. */
final class TransactionResult {
final String transactionId;
final boolean replayed; // true if this idempotencyKey had already been posted
TransactionResult(String transactionId, boolean replayed) { this.transactionId = transactionId; this.replayed = replayed; }
}
/**
* The ledger: an append-only journal of Transactions + a balance cache derived
* from it. balanceOf(a) is always exactly sum(posting.amountCents for postings
* touching a) -- the cache is just a fold kept warm for O(1) reads.
*/
final class Ledger {
private final Map<String, Account> accounts = new HashMap<>();
private final List<Transaction> journal = new ArrayList<>(); // append-only, never edited
private final Map<String, Long> balances = new HashMap<>(); // accountId -> derived balance
private final Map<String, String> idempotency = new HashMap<>(); // key -> transactionId
private final Map<String, Transaction> byId = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
private long seq = 0;
void openAccount(Account a) { accounts.put(a.id, a); balances.put(a.id, 0L); }
/** The invariant every Transaction must satisfy before it may commit. */
private static boolean isBalanced(List<Posting> postings) {
long sum = 0;
for (Posting p : postings) sum += p.amountCents;
return sum == 0;
}
/**
* Atomically post a Transaction: validate first, THEN apply every posting
* in one pass. A rejected transaction touches nothing -- all legs commit
* or none do. Idempotent: replaying the same idempotencyKey is a no-op
* that returns the original result instead of re-applying.
*/
TransactionResult post(Transaction txn) {
lock.lock();
try {
String existing = idempotency.get(txn.idempotencyKey);
if (existing != null) return new TransactionResult(existing, true); // dedup: already posted
if (!isBalanced(txn.postings))
throw new IllegalArgumentException("unbalanced transaction: postings must sum to 0");
for (Posting p : txn.postings)
if (!accounts.containsKey(p.accountId))
throw new IllegalArgumentException("unknown account: " + p.accountId);
// Validation is fully separate from application -- nothing below this
// line can fail, so a rejected transaction is a true no-op above it.
for (Posting p : txn.postings)
balances.merge(p.accountId, p.amountCents, Long::sum);
journal.add(txn);
byId.put(txn.id, txn);
idempotency.put(txn.idempotencyKey, txn.id);
return new TransactionResult(txn.id, false);
} finally {
lock.unlock();
}
}
/** Convenience: a transfer is just a balanced 2-posting Transaction. */
TransactionResult transfer(String from, String to, long amountCents, String idempotencyKey) {
String id = "txn-" + (++seq);
List<Posting> postings = Arrays.asList(new Posting(from, -amountCents), new Posting(to, amountCents));
return post(new Transaction(id, idempotencyKey, postings, null));
}
/** Corrections are NEW transactions with every leg negated -- never edit or delete the original. */
TransactionResult reverse(String originalTxnId, String idempotencyKey) {
lock.lock();
try {
Transaction original = byId.get(originalTxnId);
if (original == null) throw new IllegalArgumentException("no such transaction: " + originalTxnId);
List<Posting> inverted = new ArrayList<>();
for (Posting p : original.postings) inverted.add(new Posting(p.accountId, -p.amountCents));
String id = "txn-" + (++seq);
Transaction reversal = new Transaction(id, idempotencyKey, inverted, originalTxnId);
return post(reversal); // ReentrantLock: safe to re-enter from the same thread
} finally {
lock.unlock();
}
}
long balanceOf(String accountId) {
lock.lock();
try { return balances.getOrDefault(accountId, 0L); } finally { lock.unlock(); }
}
/** The core invariant: the whole ledger always sums to zero (Assets = Liabilities + Equity, rearranged). */
long totalBalance() {
lock.lock();
try {
long sum = 0;
for (long v : balances.values()) sum += v;
return sum;
} finally { lock.unlock(); }
}
int journalSize() { lock.lock(); try { return journal.size(); } finally { lock.unlock(); } }
Transaction journalEntry(int i) { lock.lock(); try { return journal.get(i); } finally { lock.unlock(); } }
/* ---- UNSAFE twins, for the break-it demo ONLY -- never call from real code ---- */
/** Applies a SINGLE posting directly: no balance-invariant check, no atomicity, no lock. */
void applyPostingUnsafe(Posting p) {
balances.merge(p.accountId, p.amountCents, Long::sum);
}
/**
* The naive "debit, then credit" transfer candidates reach for first: two
* independent single-leg writes instead of one balanced Transaction. If
* anything throws between the two legs -- a crash, a dropped connection,
* a timeout -- the first leg is already applied and the second never
* happens. Money has left `from` and never arrived at `to`.
*/
void transferNaive(String from, String to, long amountCents, boolean simulateCrashMidway) {
applyPostingUnsafe(new Posting(from, -amountCents)); // leg 1: debit -- APPLIED
if (simulateCrashMidway)
throw new RuntimeException("simulated crash between the two legs");
applyPostingUnsafe(new Posting(to, amountCents)); // leg 2: credit -- never reached on crash
}
}
Reference implementation — Go
Same shape: Account, Posting, Transaction, a mutex-guarded Ledger, and the identical validate-then-apply Post. Save as ledger.go, package ledger, module name ledger (a go.mod with module ledger / go 1.21 is enough to build everything in this kata).
// Package ledger is the reference implementation for the "Design a
// Double-Entry Ledger" fintech kata: an append-only journal of atomic,
// balanced Transactions, with idempotent posting and reversal-only
// corrections.
package ledger
import (
"errors"
"fmt"
"sync"
)
// AccountType is one of the five root account types from classic
// double-entry bookkeeping.
type AccountType int
const (
Asset AccountType = iota
Liability
Equity
Revenue
Expense
)
// Account is a named bucket in the chart of accounts.
type Account struct {
ID string
Name string
Type AccountType
}
// Posting is one leg of a Transaction. Positive AmountCents is a debit;
// negative is a credit.
type Posting struct {
AccountID string
AmountCents int64
}
// Transaction is an atomic, immutable unit of the journal. ALL its postings
// commit together or none do. By construction its postings must sum to
// exactly zero -- money is only ever moved between accounts, never created
// or destroyed.
type Transaction struct {
ID string
IdempotencyKey string
Postings []Posting
ReversalOf string // "" unless this transaction reverses another
}
// TransactionResult is returned by Post: the committed (or
// idempotently-replayed) transaction id.
type TransactionResult struct {
TransactionID string
Replayed bool // true if this IdempotencyKey had already been posted
}
// Ledger is the journal: an append-only list of Transactions plus a balance
// cache derived from it. BalanceOf(a) is always exactly the sum of every
// posting's AmountCents touching a -- the cache is just a fold kept warm
// for O(1) reads.
type Ledger struct {
mu sync.Mutex
accounts map[string]Account
journal []Transaction // append-only, never edited
balances map[string]int64 // accountID -> derived balance
idempotent map[string]string // idempotencyKey -> transactionID
byID map[string]Transaction // transactionID -> Transaction
seq int64
}
// NewLedger returns an empty, ready-to-use Ledger.
func NewLedger() *Ledger {
return &Ledger{
accounts: make(map[string]Account),
balances: make(map[string]int64),
idempotent: make(map[string]string),
byID: make(map[string]Transaction),
}
}
// OpenAccount registers a new account with a zero starting balance.
func (l *Ledger) OpenAccount(a Account) {
l.mu.Lock()
defer l.mu.Unlock()
l.accounts[a.ID] = a
l.balances[a.ID] = 0
}
// isBalanced is the invariant every Transaction must satisfy before it may commit.
func isBalanced(postings []Posting) bool {
var sum int64
for _, p := range postings {
sum += p.AmountCents
}
return sum == 0
}
// Post atomically commits a Transaction: validate first, THEN apply every
// posting in one pass. A rejected transaction touches nothing -- all legs
// commit or none do. Idempotent: replaying the same IdempotencyKey is a
// no-op that returns the original result instead of re-applying.
func (l *Ledger) Post(txn Transaction) (TransactionResult, error) {
l.mu.Lock()
defer l.mu.Unlock()
if existing, ok := l.idempotent[txn.IdempotencyKey]; ok {
return TransactionResult{TransactionID: existing, Replayed: true}, nil // dedup: already posted
}
if !isBalanced(txn.Postings) {
return TransactionResult{}, errors.New("unbalanced transaction: postings must sum to 0")
}
for _, p := range txn.Postings {
if _, ok := l.accounts[p.AccountID]; !ok {
return TransactionResult{}, fmt.Errorf("unknown account: %s", p.AccountID)
}
}
// Validation is fully separate from application -- nothing below this
// line can fail, so a rejected transaction is a true no-op above it.
for _, p := range txn.Postings {
l.balances[p.AccountID] += p.AmountCents
}
l.journal = append(l.journal, txn)
l.byID[txn.ID] = txn
l.idempotent[txn.IdempotencyKey] = txn.ID
return TransactionResult{TransactionID: txn.ID}, nil
}
// Transfer is a convenience: a transfer is just a balanced 2-posting Transaction.
func (l *Ledger) Transfer(from, to string, amountCents int64, idempotencyKey string) (TransactionResult, error) {
l.mu.Lock()
l.seq++
id := fmt.Sprintf("txn-%d", l.seq)
l.mu.Unlock()
postings := []Posting{
{AccountID: from, AmountCents: -amountCents},
{AccountID: to, AmountCents: amountCents},
}
return l.Post(Transaction{ID: id, IdempotencyKey: idempotencyKey, Postings: postings})
}
// Reverse corrects a past transaction by posting a NEW transaction with
// every leg negated -- it never edits or deletes the original.
func (l *Ledger) Reverse(originalTxnID, idempotencyKey string) (TransactionResult, error) {
l.mu.Lock()
original, ok := l.byID[originalTxnID]
if !ok {
l.mu.Unlock()
return TransactionResult{}, fmt.Errorf("no such transaction: %s", originalTxnID)
}
inverted := make([]Posting, len(original.Postings))
for i, p := range original.Postings {
inverted[i] = Posting{AccountID: p.AccountID, AmountCents: -p.AmountCents}
}
l.seq++
id := fmt.Sprintf("txn-%d", l.seq)
l.mu.Unlock()
return l.Post(Transaction{ID: id, IdempotencyKey: idempotencyKey, Postings: inverted, ReversalOf: originalTxnID})
}
// BalanceOf returns one account's derived balance.
func (l *Ledger) BalanceOf(accountID string) int64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.balances[accountID]
}
// TotalBalance is the core invariant: the whole ledger always sums to zero
// (Assets = Liabilities + Equity, rearranged).
func (l *Ledger) TotalBalance() int64 {
l.mu.Lock()
defer l.mu.Unlock()
var sum int64
for _, v := range l.balances {
sum += v
}
return sum
}
// JournalSize returns the number of committed transactions.
func (l *Ledger) JournalSize() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.journal)
}
// JournalEntry returns the i-th committed transaction (0-indexed, in commit order).
func (l *Ledger) JournalEntry(i int) Transaction {
l.mu.Lock()
defer l.mu.Unlock()
return l.journal[i]
}
/* ---- UNSAFE twins, for the break-it demo ONLY -- never call from real code ---- */
// ApplyPostingUnsafe applies a SINGLE posting directly: no balance-invariant
// check, no atomicity, no lock.
func (l *Ledger) ApplyPostingUnsafe(p Posting) {
l.balances[p.AccountID] += p.AmountCents
}
// TransferNaive is the naive "debit, then credit" transfer candidates reach
// for first: two independent single-leg writes instead of one balanced
// Transaction, with NO lock guarding either write. If anything happens
// between the two legs -- a crash, a dropped connection, a timeout -- the
// first leg is already applied and the second never happens. Money has
// left `from` and never arrived at `to`. simulateCrashMidway lets a
// single-goroutine test reproduce that deterministically; called
// concurrently from many goroutines with no lock, it also reproduces the
// classic shared-map data race (see cmd/breakit).
func (l *Ledger) TransferNaive(from, to string, amountCents int64, simulateCrashMidway bool) (err error) {
l.ApplyPostingUnsafe(Posting{AccountID: from, AmountCents: -amountCents}) // leg 1: debit -- APPLIED
if simulateCrashMidway {
return fmt.Errorf("simulated crash between the two legs")
}
l.ApplyPostingUnsafe(Posting{AccountID: to, AmountCents: amountCents}) // leg 2: credit -- never reached on crash
return nil
}
5. Break it
The reference impl ships two paths on the exact same Ledger: transfer (validated, atomic, locked) and transferNaive (identical intent, two independent unlocked writes, kept only for this demonstration). Two separate failure modes reproduce the Movement 1 bug concretely: a deterministic crash mid-transfer, and concurrent unlocked writes. Both are checked against the ledger's core invariant: the sum of every balance in the ledger must always be exactly 0.
(a) The deterministic crash. Fund cash with $500.00, then call transferNaive("cash", "payable", 3000, true) — the boolean simulates a crash landing between leg 1 (debit) and leg 2 (credit):
l.transfer("revenue", "cash", 50_000, "opening-balance"); // give "cash" something to move
long before = l.totalBalance(); // 0 -- the ledger is balanced
l.transferNaive("cash", "payable", 3_000, true); // throws after debiting "cash" only
long after = l.totalBalance();
// after == before - 3_000 == -3000 : $30.00 has left the ledger entirely
Measured in this session: after was -3000 on every run — the exact debited amount, with no matching credit anywhere. Not a race, not a rounding artifact: a structural consequence of splitting one atomic operation into two independent ones.
(b) Concurrent unlocked writes. Hammer transferNaive (no crash flag, just no lock) from 16 threads × 500 iterations each, cycling transfers between the same 3 accounts, and compare against 16 threads doing the identical volume through the locked, atomic transfer():
public final class LedgerTests {
static int pass = 0, fail = 0;
static void check(String name, boolean cond) {
if (cond) { pass++; System.out.println("PASS: " + name); }
else { fail++; System.out.println("FAIL: " + name); }
}
static Ledger freshLedger() {
Ledger l = new Ledger();
l.openAccount(new Account("cash", "Cash", AccountType.ASSET));
l.openAccount(new Account("payable", "Accounts Payable", AccountType.LIABILITY));
l.openAccount(new Account("revenue", "Revenue", AccountType.REVENUE));
return l;
}
public static void main(String[] args) throws InterruptedException {
// --- M1/M2: a balanced transaction commits; balances derive correctly ---
{
Ledger l = freshLedger();
l.transfer("revenue", "cash", 10_000, "seed-1"); // $100.00 of revenue lands as cash
check("transfer: cash is +10000", l.balanceOf("cash") == 10_000);
check("transfer: revenue is -10000", l.balanceOf("revenue") == -10_000);
check("invariant: ledger total is always 0", l.totalBalance() == 0);
check("journal has exactly 1 entry", l.journalSize() == 1);
}
// --- M2: an unbalanced transaction is rejected and touches NOTHING ---
{
Ledger l = freshLedger();
l.transfer("revenue", "cash", 5_000, "seed-2");
long before = l.totalBalance();
int journalBefore = l.journalSize();
boolean threw = false;
try {
java.util.List<Posting> bad = java.util.Arrays.asList(new Posting("cash", 100), new Posting("payable", 50));
l.post(new Transaction("txn-bad", "bad-1", bad, null));
} catch (IllegalArgumentException e) { threw = true; }
check("unbalanced transaction is rejected", threw);
check("rejected transaction changed nothing: total unchanged", l.totalBalance() == before);
check("rejected transaction changed nothing: journal unchanged", l.journalSize() == journalBefore);
}
// --- M4: idempotency -- replaying the same key is a no-op, not a double-post ---
{
Ledger l = freshLedger();
TransactionResult r1 = l.transfer("revenue", "cash", 2_000, "order-42");
TransactionResult r2 = l.transfer("revenue", "cash", 2_000, "order-42"); // retry, same key
check("idempotency: same id returned on replay", r1.transactionId.equals(r2.transactionId));
check("idempotency: first call is not a replay", !r1.replayed);
check("idempotency: second call IS a replay", r2.replayed);
check("idempotency: cash only moved once, not twice", l.balanceOf("cash") == 2_000);
check("idempotency: journal has exactly 1 entry, not 2", l.journalSize() == 1);
}
// --- M5: corrections are reversing entries -- the original is NEVER mutated ---
{
Ledger l = freshLedger();
TransactionResult original = l.transfer("revenue", "cash", 7_500, "invoice-9");
Transaction beforeReverse = l.journalEntry(0);
long amtBefore = beforeReverse.postings.get(0).amountCents;
l.reverse(original.transactionId, "invoice-9-void");
check("reversal: journal grew to 2 entries (append, not edit)", l.journalSize() == 2);
check("reversal: the ORIGINAL entry is byte-for-byte unchanged",
l.journalEntry(0).postings.get(0).amountCents == amtBefore);
check("reversal: balances net back to zero", l.balanceOf("cash") == 0 && l.balanceOf("revenue") == 0);
check("invariant still holds after a reversal", l.totalBalance() == 0);
}
// --- Break it (a): the naive two-step transfer, crash injected between the legs ---
{
Ledger l = freshLedger();
l.transfer("revenue", "cash", 50_000, "opening-balance"); // give "cash" something to move
long before = l.totalBalance();
check("sanity: ledger balanced before the naive transfer", before == 0);
boolean threw = false;
try {
l.transferNaive("cash", "payable", 3_000, true); // simulate a crash after leg 1
} catch (RuntimeException e) { threw = true; }
check("naive transfer: the simulated crash actually threw", threw);
long after = l.totalBalance();
System.out.println("naive transfer after simulated crash -- ledger total: " + after + " (want 0)");
check("BREAK-IT: a crash mid-naive-transfer VIOLATES the ledger invariant", after != 0);
check("BREAK-IT: the exact amount debited from cash is unaccounted for",
after == before - 3_000);
}
// --- Break it (b): concurrent naive transfers lose updates; atomic transfers don't ---
{
final int threads = 16, itersPerThread = 500;
String[] acct = { "cash", "payable", "revenue" };
java.util.List<Long> unsafeDrifts = new java.util.ArrayList<>();
for (int run = 0; run < 3; run++) {
Ledger l = freshLedger();
l.transfer("revenue", "cash", 1_000_000, "seed-run-" + run); // plenty of float in the system
Thread[] pool = new Thread[threads];
for (int t = 0; t < threads; t++) {
final int tt = t;
pool[t] = new Thread(() -> {
String from = acct[tt % acct.length];
String to = acct[(tt + 1) % acct.length];
for (int i = 0; i < itersPerThread; i++) {
l.transferNaive(from, to, 10, false); // NO lock, NO atomic transaction
}
});
}
for (Thread th : pool) th.start();
for (Thread th : pool) th.join();
unsafeDrifts.add(l.totalBalance());
}
System.out.println("UNSAFE concurrent naive-transfer drifts across 3 runs (want all 0): " + unsafeDrifts);
boolean anyDrift = unsafeDrifts.stream().anyMatch(v -> v != 0);
check("BREAK-IT: concurrent naive transfers VIOLATE the invariant at least once in 3 runs", anyDrift);
// Now the SAME hammering through the atomic, locked transfer() path.
Ledger safe = freshLedger();
safe.transfer("revenue", "cash", 1_000_000, "seed-safe");
Thread[] pool2 = new Thread[threads];
for (int t = 0; t < threads; t++) {
final int tt = t;
pool2[t] = new Thread(() -> {
String from = acct[tt % acct.length];
String to = acct[(tt + 1) % acct.length];
for (int i = 0; i < itersPerThread; i++) {
safe.transfer(from, to, 10, "safe-" + tt + "-" + i);
}
});
}
for (Thread th : pool2) th.start();
for (Thread th : pool2) th.join();
check("FIX: identical concurrent load through atomic transfer() holds invariant at 0",
safe.totalBalance() == 0);
check("FIX: every atomic transfer landed in the journal (no lost postings)",
safe.journalSize() == 1 + threads * itersPerThread);
}
System.out.println();
System.out.println(pass + " passed, " + fail + " failed");
if (fail > 0) System.exit(1);
}
}
Measured in this session (javac Ledger.java LedgerTests.java && java LedgerTests): 23/23 assertions pass on every run. The single-shot crash demo measured -3000 every time (deterministic, as expected). The concurrent-drift demo measured real, nonzero totals across independent 3-run batches — for example one session's three runs came back [-8980, 630, -1410] and another's came back [-3800, -2720, -4010]; every batch observed at least one nonzero drift. The locked transfer() path held the invariant at exactly 0 under identical concurrent load in every run, with every one of the 1 + 16×500 = 8001 transactions landing in the journal.
For Go, the deterministic crash test and the concurrent-atomic-path test both belong in ledger_test.go (next to ledger.go above) — they must pass clean under go test -race. The concurrent-naive-path demo is deliberately a separate program, because Go's map implementation reacts to genuine concurrent writers by crashing the process outright rather than silently losing updates:
package ledger
import (
"fmt"
"sync"
"testing"
)
func freshLedger() *Ledger {
l := NewLedger()
l.OpenAccount(Account{ID: "cash", Name: "Cash", Type: Asset})
l.OpenAccount(Account{ID: "payable", Name: "Accounts Payable", Type: Liability})
l.OpenAccount(Account{ID: "revenue", Name: "Revenue", Type: Revenue})
return l
}
func TestTransferAndBalance(t *testing.T) {
l := freshLedger()
if _, err := l.Transfer("revenue", "cash", 10_000, "seed-1"); err != nil {
t.Fatal(err)
}
if got := l.BalanceOf("cash"); got != 10_000 {
t.Errorf("cash = %d, want 10000", got)
}
if got := l.BalanceOf("revenue"); got != -10_000 {
t.Errorf("revenue = %d, want -10000", got)
}
if got := l.TotalBalance(); got != 0 {
t.Errorf("invariant violated: total = %d, want 0", got)
}
if got := l.JournalSize(); got != 1 {
t.Errorf("journal size = %d, want 1", got)
}
}
func TestUnbalancedTransactionRejected(t *testing.T) {
l := freshLedger()
if _, err := l.Transfer("revenue", "cash", 5_000, "seed-2"); err != nil {
t.Fatal(err)
}
before := l.TotalBalance()
journalBefore := l.JournalSize()
bad := Transaction{
ID: "txn-bad",
IdempotencyKey: "bad-1",
Postings: []Posting{
{AccountID: "cash", AmountCents: 100},
{AccountID: "payable", AmountCents: 50}, // doesn't sum to 0
},
}
if _, err := l.Post(bad); err == nil {
t.Error("expected an error for an unbalanced transaction, got nil")
}
if got := l.TotalBalance(); got != before {
t.Errorf("rejected transaction changed total: got %d, want %d", got, before)
}
if got := l.JournalSize(); got != journalBefore {
t.Errorf("rejected transaction changed journal size: got %d, want %d", got, journalBefore)
}
}
func TestIdempotency(t *testing.T) {
l := freshLedger()
r1, err := l.Transfer("revenue", "cash", 2_000, "order-42")
if err != nil {
t.Fatal(err)
}
r2, err := l.Transfer("revenue", "cash", 2_000, "order-42") // retry, same key
if err != nil {
t.Fatal(err)
}
if r1.TransactionID != r2.TransactionID {
t.Errorf("replay returned a different id: %s vs %s", r1.TransactionID, r2.TransactionID)
}
if r1.Replayed {
t.Error("first call should not be marked replayed")
}
if !r2.Replayed {
t.Error("second call (same idempotency key) should be marked replayed")
}
if got := l.BalanceOf("cash"); got != 2_000 {
t.Errorf("cash moved more than once: got %d, want 2000", got)
}
if got := l.JournalSize(); got != 1 {
t.Errorf("journal has %d entries, want exactly 1 (no double-post)", got)
}
}
func TestReversalIsImmutable(t *testing.T) {
l := freshLedger()
original, err := l.Transfer("revenue", "cash", 7_500, "invoice-9")
if err != nil {
t.Fatal(err)
}
amtBefore := l.JournalEntry(0).Postings[0].AmountCents
if _, err := l.Reverse(original.TransactionID, "invoice-9-void"); err != nil {
t.Fatal(err)
}
if got := l.JournalSize(); got != 2 {
t.Errorf("journal size = %d, want 2 (append, not edit)", got)
}
if got := l.JournalEntry(0).Postings[0].AmountCents; got != amtBefore {
t.Errorf("the ORIGINAL entry changed: got %d, want %d", got, amtBefore)
}
if l.BalanceOf("cash") != 0 || l.BalanceOf("revenue") != 0 {
t.Errorf("balances did not net back to zero: cash=%d revenue=%d", l.BalanceOf("cash"), l.BalanceOf("revenue"))
}
if got := l.TotalBalance(); got != 0 {
t.Errorf("invariant violated after reversal: total = %d, want 0", got)
}
}
// TestNaiveTransferCrashLosesMoney is the deterministic, single-goroutine
// break-it demo: inject a "crash" between the naive transfer's two legs and
// watch the ledger's core invariant break. No concurrency needed to see
// this failure -- it is a pure atomicity bug.
func TestNaiveTransferCrashLosesMoney(t *testing.T) {
l := freshLedger()
if _, err := l.Transfer("revenue", "cash", 50_000, "opening-balance"); err != nil {
t.Fatal(err)
}
before := l.TotalBalance()
if before != 0 {
t.Fatalf("sanity check failed: ledger not balanced before the naive transfer, total = %d", before)
}
err := l.TransferNaive("cash", "payable", 3_000, true) // simulate a crash after leg 1
if err == nil {
t.Fatal("expected the simulated crash to return an error")
}
after := l.TotalBalance()
t.Logf("naive transfer after simulated crash -- ledger total: %d (want 0)", after)
if after == 0 {
t.Fatal("BREAK-IT FAILED TO REPRODUCE: expected the invariant to break, but total is still 0")
}
if want := before - 3_000; after != want {
t.Errorf("total after crash = %d, want %d (the debited leg with no matching credit)", after, want)
}
}
// TestConcurrentAtomicTransferHoldsInvariant hammers the LOCKED Transfer
// path from many goroutines and asserts the conservation invariant holds.
// Run with `go test -race`: it must report zero races here (contrast with
// cmd/breakit, which calls TransferNaive with no lock and is a SEPARATE
// program precisely because it is expected to race).
func TestConcurrentAtomicTransferHoldsInvariant(t *testing.T) {
const goroutines = 16
const itersEach = 500
acct := []string{"cash", "payable", "revenue"}
l := freshLedger()
if _, err := l.Transfer("revenue", "cash", 1_000_000, "seed-safe"); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
from := acct[g%len(acct)]
to := acct[(g+1)%len(acct)]
wg.Add(1)
go func(from, to string, g int) {
defer wg.Done()
for i := 0; i < itersEach; i++ {
key := fmt.Sprintf("safe-%d-%d", g, i)
if _, err := l.Transfer(from, to, 10, key); err != nil {
t.Error(err)
}
}
}(from, to, g)
}
wg.Wait()
if got := l.TotalBalance(); got != 0 {
t.Errorf("locked ledger under concurrent load: invariant violated, total = %d, want 0", got)
}
wantJournal := 1 + goroutines*itersEach
if got := l.JournalSize(); got != wantJournal {
t.Errorf("journal size = %d, want %d (no lost postings)", got, wantJournal)
}
}
And this is the actual break-it program — put it at cmd/breakit/main.go in the same module (module ledger) that the ledger.go package above lives in:
// Command breakit hammers Ledger.TransferNaive (no lock) from many
// goroutines. Expect a crash: Go's map implementation detects concurrent
// writers and calls fatal() -- "fatal error: concurrent map writes" --
// which is NOT recoverable. Run it with the race detector to see the
// underlying data race reported first:
//
// go run ./cmd/breakit # crashes: fatal error: concurrent map writes
// go run -race ./cmd/breakit # prints "WARNING: DATA RACE" then crashes
package main
import (
"fmt"
"sync"
"ledger"
)
func main() {
const goroutines = 16
const itersEach = 500
acct := []string{"cash", "payable", "revenue"}
l := ledger.NewLedger()
l.OpenAccount(ledger.Account{ID: "cash", Name: "Cash", Type: ledger.Asset})
l.OpenAccount(ledger.Account{ID: "payable", Name: "Accounts Payable", Type: ledger.Liability})
l.OpenAccount(ledger.Account{ID: "revenue", Name: "Revenue", Type: ledger.Revenue})
if _, err := l.Transfer("revenue", "cash", 1_000_000, "seed"); err != nil {
panic(err)
}
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
from := acct[g%len(acct)]
to := acct[(g+1)%len(acct)]
wg.Add(1)
go func(from, to string) {
defer wg.Done()
for i := 0; i < itersEach; i++ {
_ = l.TransferNaive(from, to, 10, false) // no lock -- the whole point
}
}(from, to)
}
wg.Wait()
// Reached only if you got lucky and the runtime didn't catch the race
// this run -- still wrong, just not caught THIS time. That's the danger.
fmt.Printf("no crash this run, but ledger total = %d (want 0) -- still racy\n", l.TotalBalance())
}
Measured in this session: go build ./... and go vet ./... are clean; go test -race ./... passes 6/6 (0 races), including the deterministic crash test (ledger total: -3000, exactly matching the Java run) and the concurrent atomic-path test. go run ./cmd/breakit crashed with fatal error: concurrent map writes on every one of 3 runs; go run -race ./cmd/breakit additionally printed WARNING: DATA RACE, pinpointing the exact unsynchronized read/write inside ApplyPostingUnsafe before the crash. The same missing lock produces two different failure signatures across languages — Java's HashMap silently drops updates (wrong answer, no error, the dangerous one), Go's map detects the concurrent write and kills the process outright (no answer at all, at least it's loud). Neither is "safer" than the other; both mean the same thing: this code needed one atomic critical section around the whole transaction and didn't have one.
6. Optimise — with trade-offs
| Decision | Option A | Option B | When A wins | When B wins |
|---|---|---|---|---|
| Balance bookkeeping | Single mutable balance column (update in place) | Double-entry (paired debit/credit postings, self-validating) | A throwaway prototype where nobody will ever audit the numbers and a bug just means "reset the demo data" — genuinely never in a system that touches real money | Anything that has to survive an audit, a regulator, or a "why doesn't this add up" conversation. The self-checking sum == 0 invariant is the entire reason double-entry has outlived 500 years of alternative bookkeeping schemes |
| Where the balance lives | A mutable field, the source of truth | An append-only journal + a derived balance cache (as built above) | Never for money you need to reconcile or investigate — a mutable field has no history to fall back on when it's wrong | Always for a real ledger: the journal is the source of truth, the cached balance is a read-optimization you can always recompute from scratch to verify (a fold, not a trust exercise). This is the same shape as event sourcing / CQRS |
| Money representation | double / float | Integer minor units (cents), or an arbitrary-precision decimal type | Never, for anything you intend to sum and reconcile exactly — binary floating point can't represent most decimal fractions, and per-transaction rounding compounds into an unattributable drift | Integer cents: exact, fast, native arithmetic — the industry standard (Stripe, TigerBeetle). Arbitrary-precision decimal: also exact, worth the extra allocation/perf cost only when you genuinely need sub-cent precision (interest accrual, FX conversion) that integer cents can't express |
| Commit granularity / locking | One global lock over the whole ledger (built above) | Per-account striped locks, or a database transaction with row-level locking | Simplicity wins until measured contention says otherwise — one critical section is trivially correct and fast enough for most real transaction volume | High concurrent throughput across unrelated account pairs — but every transaction must now acquire ALL its accounts' locks in a fixed global order (e.g. sorted by account ID) to avoid a lock-ordering deadlock, the same rule Splitwise's ledger needs. Real complexity; earn it with a profiler, don't guess it |
| How a real ledger scales this | General-purpose RDBMS-backed ledger (postings table + ACID transactions) | Purpose-built ledger engine (TigerBeetle-style) | Any system below tens of thousands of transactions/sec — a normal relational database with row-level locking and ACID transactions gets you correctness fast, with tooling and operational maturity you don't have to build yourself | Once throughput genuinely demands it: TigerBeetle gets there by running a single-threaded, deterministic state machine per shard (no lock contention at all, because nothing is shared across threads), batching thousands of transfers per fsync/network round-trip, and using fixed-point 128-bit integers for amounts instead of a general-purpose numeric type. You're trading general-purpose flexibility for raw ledger throughput — don't reach for it before an RDBMS-backed ledger has actually run out of headroom |
7. Defend under drilling
- "Why double-entry, specifically?" — Because it turns "is the ledger correct" from something you trust into something you can check. Every transaction's postings sum to zero by construction, which means the whole ledger's total balance is always exactly zero — a single arithmetic fact, checkable in O(n), that catches any bug, race, or fraud that would otherwise be invisible in a single mutable balance column. A single-column ledger has no analogous self-check; it can only tell you the number it currently believes, never whether that number is actually right.
- "How do you guarantee both legs of a transfer post atomically?" — Validate the entire transaction first (balanced? every account exists? not a replay?) with zero side effects, THEN apply every posting in one pass under a single critical section. Nothing can partially land, because nothing is written until every check has already passed. The measured break-it demo shows exactly what happens when you skip this: a naive two-step debit-then-credit lost $30.00 to a simulated crash, every single run. At a single node, one lock (or one DB transaction) is enough. The moment a transfer's two legs live on different shards or services, the same "all legs or none" requirement becomes a distributed problem — that's exactly the territory of 2PC vs Saga vs TCC and Saga in Depth, and it's worth naming that escalation explicitly rather than pretending one mutex scales forever.
- "Float vs int?" — Never float, for the same reason Splitwise's split-the-bill ledger avoids it, but with a stricter bar here: this kata's core invariant is
totalBalance() == 0, and floating-point rounding error would make that assertion flaky by construction — sometimes off by a fraction of a cent, sometimes not, for no code reason. Integer cents make the invariant exact, every time, which is the entire point of building a self-checking ledger in the first place. - "How do you reconcile this against the bank's own record?" — You never compare two single numbers ("our balance" vs "their balance") and call it done — a match there tells you nothing about whether the two sides agree on how they got to that number, and a mismatch tells you nothing about where it diverged. You walk the append-only journal entry-by-entry against the bank's statement, matching each posting to its counterpart and flagging anything present on one side but not the other. That's precisely why the journal has to be fully itemized and immutable rather than a denormalized balance — it's the only data structure reconciliation can actually operate on. Designing a Payment System shows this ledger-entry-per-movement shape at the HLD level; here you've built the mechanism that makes it possible.
- "What breaks at 100× the transaction volume?" — The single global mutex around the whole ledger becomes the bottleneck first — every transaction, even ones touching completely unrelated accounts, serializes behind it. The fix is per-account (or sharded) locking with a fixed lock-acquisition order to avoid deadlock, or moving the atomicity guarantee into a database's row-level transaction. But the moment accounts live on different shards or different services entirely, "acquire every account's lock" stops being possible in one process — you're now choosing between 2PC, TCC, and Saga for the cross-shard transfer, each with a different consistency/availability/complexity trade-off (see the pages linked above). It's the exact same "shared mutable state needs a synchronization boundary" lesson from Movement 5, just re-derived one layer up, in a distributed setting instead of a single process.
8. You can now defend
- You can explain why double-entry bookkeeping is a correctness mechanism — a machine-checkable invariant (all balances sum to zero) — not an accounting convention, and derive it from the failure of a single mutable balance column.
- You've implemented atomic multi-leg transaction commit (validate-then-apply, one critical section) and personally reproduced, measured, and fixed the exact bug that makes a naive two-step transfer lose money — both under a deterministic crash and under real concurrency, in both Java and Go.
- You can justify integer minor units over float or arbitrary-precision decimal for money, and you've built idempotent posting plus reversal-only corrections — the two features every real payments API needs for retries and audits.
- You can reason about scaling a ledger from a single mutex to sharded locks to a distributed 2PC/Saga/TCC transaction, and connect the append-only, itemized journal directly to how a real reconciliation process checks a ledger against a bank of record.
Re-authored/Deepened for this guide. Related theory: Designing a Payment System · What Are Idempotency Keys and How to Implement Them Safely for Payments · 2PC vs Saga vs TCC · Saga in Depth. Reference implementations (Java + Go) compiled and tested in-session: javac clean, 23/23 assertions pass (including the deliberately-adversarial break-it assertions, which measured a deterministic $30.00 conservation-invariant violation on every run and real concurrent-drift examples such as [-8980, 630, -1410] and [-3800, -2720, -4010] cents across independent batches); go build + go vet + go test -race clean, 6/6 tests pass (0 races) on the locked path, matching the Java crash demo exactly (-3000); the separate unsafe break-it program crashed with fatal error: concurrent map writes on every run, with go run -race additionally reporting the exact data race before the crash.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design a Double-Entry Ledger? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design a Double-Entry Ledger** (Hands-On Builds) and want to truly understand it. Explain Design a Double-Entry Ledger from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design a Double-Entry Ledger** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design a Double-Entry Ledger** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design a Double-Entry Ledger** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.