A ledger is the heart of any system that moves money — bank, fintech, marketplace, in-game currency. Every transfer, deposit, fee, refund lives in the ledger. This article covers the design patterns that make ledgers fast, correct, and auditable.

Core data model

At its simplest:

CREATE TABLE accounts (
    id          UUID PRIMARY KEY,
    type        TEXT NOT NULL,   -- 'USER_CASH', 'FEES', 'PENDING', 'HOLD', etc.
    currency    CHAR(3) NOT NULL,
    owner_id    UUID,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE transfers (
    id              UUID PRIMARY KEY,
    external_ref    TEXT,
    status          TEXT NOT NULL,
    initiated_by    UUID,
    initiated_at    TIMESTAMPTZ NOT NULL,
    idempotency_key TEXT UNIQUE NOT NULL
);

CREATE TABLE postings (
    id              UUID PRIMARY KEY,
    transfer_id     UUID NOT NULL REFERENCES transfers(id),
    account_id      UUID NOT NULL REFERENCES accounts(id),
    amount_cents    BIGINT NOT NULL CHECK (amount_cents != 0),
    currency        CHAR(3) NOT NULL,
    posted_at       TIMESTAMPTZ NOT NULL,
    CHECK (amount_cents != 0)
);

CREATE INDEX idx_postings_account_time ON postings (account_id, posted_at DESC);
CREATE INDEX idx_postings_transfer ON postings (transfer_id);

Accounts are buckets. Transfers are business events. Postings are the individual ledger entries that make each transfer balance.

The core invariant

For every transfer, sum of postings = 0. Enforce it:

-- At application level, within the transaction
SELECT SUM(amount_cents) FROM postings WHERE transfer_id = ?
-- Must equal zero

-- Or a DB constraint using a trigger

This is the one invariant that must never be violated. Alerts fire loudly if it ever is.

Balances

Balance of an account = sum of postings for that account:

SELECT COALESCE(SUM(amount_cents), 0) AS balance
FROM postings
WHERE account_id = ?;

With index on (account_id, posted_at), this is fast — tens of ms even for accounts with millions of postings.

For even higher performance, cache balances in a separate table:

CREATE TABLE account_balances (
    account_id   UUID PRIMARY KEY REFERENCES accounts(id),
    balance_cents BIGINT NOT NULL,
    last_posting_id UUID,
    updated_at   TIMESTAMPTZ NOT NULL
);

Update in the same transaction as posting inserts. Reconcile periodically against the ledger truth.

Transfer types

Every meaningful business event is a transfer type. Common ones:

  • Deposit — external cash to user_cash account (postings: +user, -external)
  • Withdrawal — user_cash to external (postings: -user, +external)
  • Transfer — user to user (postings: -sender, +receiver)
  • Fee — user to fees account (postings: -user, +fees)
  • Refund — reverse of a prior transfer
  • Adjustment — manual correction (always reviewed)

Model each as a specific type. Code path per type keeps business logic clear.

Multi-currency

If accounts are mono-currency, cross-currency transfers need FX:

Transfer $100 USD to €85 EUR:
  Posting 1: -$100 from user USD account
  Posting 2: +$100 to FX clearing USD account
  Posting 3: -€85 from FX clearing EUR account
  Posting 4: +€85 to recipient EUR account

All four postings in one transfer. FX clearing accounts hold the net position per currency pair — reconciled and rebalanced externally.

Cardinal rule: no single posting spans currencies. Each is a single-currency entry.

Holds and reservations

For async flows (pending authorization, overnight processing), use hold accounts:

  • Authorize: transfer $100 from user_cash to user_hold
  • Capture: transfer $100 from user_hold to merchant
  • Cancel: transfer $100 from user_hold back to user_cash

Hold accounts make intermediate state explicit. Available balance = user_cash balance; total balance includes user_hold. UIs show both.

Preventing overdraft

If an account shouldn’t go negative:

@Transactional
public void transfer(UUID fromAccount, UUID toAccount, long amount) {
    long balance = ledgerRepo.balanceOf(fromAccount);
    if (balance < amount) throw new InsufficientFundsException();

    postingsRepo.save(new Posting(transferId, fromAccount, -amount, ...));
    postingsRepo.save(new Posting(transferId, toAccount,    amount, ...));
}

Problem: race condition. Two concurrent transfers both see balance 100, both spend 80, balance goes to -60.

Solutions:

  • Pessimistic lock. SELECT ... FOR UPDATE on the account row before balance check.
  • Optimistic lock. Version column; retry on conflict.
  • Balance constraint. Precomputed balance with a CHECK constraint; transaction fails if balance would go negative.

Most ledgers I’ve built use row locks — reliable, easy to reason about.

Immutability and corrections

Postings are never updated or deleted. Mistakes are corrected with reversing entries:

Original: +$100 to account A, -$100 from account B (transfer T1)
Correction: -$100 from A, +$100 to B (transfer T2, references T1 as reversal)

T1 and T2 both remain in the ledger. Balance is correct. History is complete. Auditors are happy.

Reconciliation

Regular jobs verify:

  • Transfer invariant (sum of postings per transfer = 0)
  • Balance cache vs ledger truth (cached balance == sum of postings)
  • External rail vs internal ledger (what Stripe says we charged == what our ledger says)

Discrepancies alert immediately. Root cause is always investigated, never hand-waved.

Performance patterns

At tens of millions of postings:

  • Partition by posted_at month
  • Archive old months to cheaper storage
  • Keep hot postings (last 90 days) on fast storage
  • Account balance cache to avoid summing every read

At hundreds of millions: account-based sharding becomes necessary. Each shard handles a subset of accounts.

Regulatory considerations

Banking specifically — depending on jurisdiction:

  • Double-entry is often mandated
  • Retention (7-10 years typical)
  • Audit trail of every change (including reads in some regulators’ view)
  • Reporting formats (SEPA, ACH, SWIFT-specific)

These shape the ledger. Design with compliance requirements in mind from day one.

Closing note

A good ledger is boring. Posts balance. Invariants hold. Balances reconcile. Reversals are explicit. It’s not the place for clever designs or novel abstractions. The ledger patterns that have held up in banking for decades are the ones modern fintech still uses — for good reason. Build yours with the same conservatism, and you’ll never lose sleep over money movements.