If you are storing a user's balance in a column and updating it, you have a bug. Not a bug you have hit yet — a bug that is structurally present and will surface the first time two writes race, a job retries, or an auditor asks how a balance got to where it is.
Double-entry bookkeeping is five hundred years old and solves exactly this. It is worth twenty minutes of your time even if you never touch a financial system again.
The model in one paragraph
Money never appears or disappears; it moves between accounts. Every movement is a journal entry with at least two lines — one debit, one credit — and the lines must sum to zero. A balance is not stored; it is the sum of all lines touching that account. Journals are append-only. You never edit an entry; a correction is a new entry that reverses the old one.
What immutability buys you
- Reconciliation becomes a query. Sum the ledger, compare to the bank, and any difference is a specific set of entries rather than a mystery.
- Audit is free. 'Why is this balance 4,312?' has an answer that is a list of rows, each with a timestamp, a cause and a correlation ID.
- Concurrency stops being terrifying. Appends do not conflict the way in-place updates do, and idempotency keys make retries safe by construction.
- Bugs become visible instead of silent. A balance column absorbs an error. A ledger that must sum to zero refuses to.
The parts engineers get wrong
Signs. Debit and credit are not positive and negative — they are sides. Whether a debit increases or decreases an account depends on the account type. Get this wrong once and every report is subtly inverted. Write the account-type table down and test it.
Money types. Never use a float. Store minor units as an integer with an explicit currency, and make the type impossible to construct without both. Most rounding incidents we have investigated trace back to a division that lost a unit and a system that had no invariant to catch it.
Pending versus settled. A card authorisation is not a settlement. Model them as distinct entries against distinct accounts so an available balance and a cleared balance are both derivable rather than approximated.
A balance column is a cache. Treat it like one: derived, invalidatable, and never the source of truth.
Performance, honestly
The obvious objection is that summing every entry to read a balance does not scale. It does not, and you do not do that. You keep periodic snapshot balances — end of day, end of month — and sum forward from the most recent snapshot. Reads stay fast, the ledger stays authoritative, and a snapshot that disagrees with a recomputation is an alert rather than a silent corruption.
In Ledgerline the snapshot job runs continuously and recomputes a rolling window from scratch. It has caught three integration bugs in production that no test would have found, because the failure was in a partner's retry behaviour rather than in our code.
Working on this?
We run a paid two-week diagnostic that ends with an architecture record, a risk register and a costed plan — yours to keep either way.
Talk to an engineer