Stackbook Logo
← Back to System Architectures

Designing a Banking Ledger

Design a double-entry banking ledger with immutable entries, strict balance invariants, idempotent transfers and auditable reconciliation.

5 min read

A ledger is the system of record for who owns what. Its guarantees are stricter than most applications: entries are immutable, every transaction balances, and the history can be replayed to prove any balance at any moment.

Requirements

Functional

  • Open accounts; post transfers, deposits, withdrawals and fees.
  • Query balances and statements; support holds on funds.
  • Reverse transactions without deleting history.

Non-functional

  • Strong consistency: no lost updates, no negative balance where forbidden.
  • 5,000 postings per second at peak; balance read p99 under 50 ms.
  • Durability with zero data loss; multi-AZ synchronous replication.
  • 7 to 10 year retention with full audit trail.

Capacity Estimation

With 50M accounts and 20M transactions per day, each with two entries, that is 40M entries per day, about 460 per second on average. Peak at 10x gives roughly 5,000 per second. An entry is about 200 bytes, so 8 GB per day and about 3 TB per year. Table partitioning by month with archival keeps hot data small. Balances: 50M rows at 100 bytes is 5 GB, easily cached and indexed.

High-Level Design

API (idempotent) -> Posting Service -> Ledger DB primary (serializable txns)
                                         |-> sync replica (other AZ)
                                         '-> Outbox -> Kafka -> Statements, Notifications,
                                                               Reconciliation, Reporting
Read path: Balance Service (reads balance table, replica for statements)

Data Model

  • accounts(account_id, currency, type, status, overdraft_limit)
  • transactions(txn_id, idempotency_key UNIQUE, type, state, created_at, reverses_txn_id)
  • entries(entry_id, txn_id, account_id, direction, amount, currency, seq) which is append-only
  • balances(account_id, available, pending, version, last_seq)
  • holds(hold_id, account_id, amount, expires_at, state)

Amounts are integers in minor units, such as cents, never floating point. Each transaction contains two or more entries whose debits equal credits per currency. That invariant is checked in code and by a deferred database constraint or a periodic audit query.

Deep Dives

Double entry and immutability

Money never appears or disappears; it moves between accounts. A transfer of 100 from A to B writes a debit on A and a credit on B in a single transaction. Corrections are new transactions that reverse earlier ones, linked by reverses_txn_id. Revoke UPDATE and DELETE privileges on the entries table, and chain hashes of entries for tamper evidence if regulators require it.

Concurrent balance updates

Two withdrawals racing on one account must not both pass the check. Options are row-level locks on the balance row (select for update), or a conditional update that decrements available where available minus amount is at least minus overdraft limit. The conditional update is a single atomic statement and keeps lock time short. To avoid deadlocks on transfers, always lock or update accounts in ascending account_id order.

Hot accounts

A merchant or fee account receiving thousands of credits per second becomes a hotspot. Split it into N sub-accounts, route postings randomly, and sum on read, or batch credits into periodic aggregated postings. Because credits cannot overdraw, they can be applied asynchronously with entries still written immediately.

Idempotency

Every posting request supplies an idempotency key, with a unique index on transactions. A duplicate returns the original result. The key insert, the entries and the balance updates commit in one database transaction, so a crash leaves either everything or nothing.

Sharding

Single-node relational databases handle several thousand postings per second, so shard only when needed. Shard by account_id; same-shard transfers remain local ACID. Cross-shard transfers use a two-phase approach: debit the source into an in-transit suspense account in one transaction, then credit the destination in another, driven by a durable saga with retries. Suspense balances must sum to zero, which reconciliation verifies.

Balance derivation

The balances table is a cache of the sum of entries. A nightly job recomputes sums and compares them with the stored balance; any difference is a severity-one incident. This catches software bugs and rare storage corruption.

Failure Modes and Trade-offs

  • Crash mid-transfer: single-shard transactions are atomic; cross-shard sagas resume from persisted state.
  • Replica lag: balance checks for debits always read the primary; statements may read replicas.
  • External rail failure: funds sit in a pending state with a hold, then are completed or reversed when the rail answers.
  • Serializable versus read committed with conditional updates: serializable is easier to reason about but aborts more under contention; conditional updates scale better with discipline.
  • Event sourcing versus balance table: pure derivation from entries is clean but slow to read, so keep both and verify them against each other.
  • Currency and rounding: store currency per entry, never mix, and define rounding rules explicitly.

Key takeaways

  • Use double-entry with immutable entries; fix mistakes with reversing transactions.
  • Store money as integers in minor units.
  • Apply balance changes with atomic conditional updates and ordered locking.
  • Commit idempotency key, entries and balances in one transaction.
  • Split hot accounts and use suspense accounts for cross-shard transfers.
  • Reconcile stored balances against summed entries continuously.