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 triggerThis 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 accountAll 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 UPDATEon 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_atmonth - 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.