Knowledge Guide
HomeHands-On BuildsFintech Labs

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:

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 derivedbalanceOf(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.

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

DecisionOption AOption BWhen A winsWhen B wins
Balance bookkeepingSingle 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 moneyAnything 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 livesA mutable field, the source of truthAn 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 wrongAlways 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 representationdouble / floatInteger minor units (cents), or an arbitrary-precision decimal typeNever, 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 driftInteger 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 / lockingOne global lock over the whole ledger (built above)Per-account striped locks, or a database transaction with row-level lockingSimplicity wins until measured contention says otherwise — one critical section is trivially correct and fast enough for most real transaction volumeHigh 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 thisGeneral-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 yourselfOnce 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

8. You can now defend


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes