Every cent reconciles: building a banking ledger from zero
Why double-entry bookkeeping is the most under-appreciated data structure in software, and what building one under regulatory supervision taught me about correctness.
There is a moment, early in building any banking system, where you realise the ledger is not a feature. It is the product. Everything else, the app, the card, the interest rate, is a view over a single question: does every cent reconcile?
I have built this from zero, under BaFin supervision, at a bank that grew to millions of customers. Most of what I know about correctness in software I learned there, at the point where a rounding error is not a bug ticket but a regulatory finding. This is the long version of what I wish someone had told me before I wrote the first migration.
The oldest data structure still wins
Double-entry bookkeeping is roughly seven hundred years old. Every transaction is two entries, equal and opposite, and the sum of all entries is always zero. It is a data structure with a built-in invariant, and that invariant is the whole point: if the books don’t balance, the system is wrong, loudly, immediately.
People coming from application development want to model an account as a row with
a balance column and update it in place. It feels obvious. It is also the single
most expensive mistake you can make, because the moment you store the balance as
the truth, you have thrown away the reason it has that value. You cannot answer
“why is this account 42.17 short?” from a number. You can only answer it from the
history that produced the number.
So the ledger is append-only. You never update a balance. You write entries, and the balance is a fold over those entries. Every posting is at least two lines that net to zero: money leaves one account and arrives in another, same amount, same instant, same transaction. If you cannot write both sides, you write neither.
Balances are derived, never stored (until they can’t be)
The purist version says: never store a balance, always sum the entries. That is correct and it does not survive contact with production. At a few hundred million entries, summing from the beginning of time to answer “can this card payment go through” in 50 milliseconds is not going to happen.
The answer is not to abandon the invariant. It is to treat the stored balance as a cache with a proof. You keep running balances, but they are derived values you can rebuild from the entries at any time, and you reconcile them against a full replay on a schedule. The entries remain the source of truth. The balance is an optimisation that must always agree with the truth, and you have a job that fails loudly the moment it doesn’t. Snapshots, checkpoints, whatever you call them: they are performance, not authority.
This is the part teams get wrong. They start with a derived balance, then under deadline pressure they start writing to it directly “just this once”, and now the balance and the entries disagree and nobody can say which is right. The discipline is not “never cache”. It is “the cache can never become the authority”.
Money is not a float, and it is barely an integer
Never use floating point for money. This is table stakes and I will not belabour
it: 0.1 + 0.2 is not 0.3, and a bank that is off by fractions of a cent a
million times a day is off by real money and a compliance problem.
Integers of minor units (cents) get you most of the way. But money is not just an
amount, it is an amount in a currency, and the type system should refuse to let
you add euros to dollars or compare them without an explicit conversion. Model
Money as a value object: an amount, a currency, and constructors that make an
invalid or mixed-currency value impossible to build. Once you have that, entire
categories of bug simply cannot be written down. That idea, making illegal states
unrepresentable, is the thread that runs through everything I build.
Then there is rounding. The instant you compute interest, split a payment, or convert a currency, you produce fractions you cannot store. Someone has to eat the remainder, and it has to be deterministic and it has to conserve the total. Decide your rounding rule once, centralise it, test it against the sum, and never let it leak into a dozen call sites each rounding their own way.
Idempotency is not optional
Networks retry. Clients retry. Your own queue redelivers. If a transfer can be submitted twice and posted twice, you will double-charge someone, and in banking that is not a support ticket, it is a breach.
Every money movement carries an idempotency key from the edge, and the ledger enforces uniqueness on it. Submit the same key twice, get the same result once. This sounds simple and it reshapes your whole write path: the key has to be generated before the first attempt, persisted with the intent, and checked inside the same transaction that writes the entries. Bolting it on afterwards as a cache in front of the API does not work, because the failure you are guarding against is precisely the one where the API responded but the client never heard it.
Correctness you can prove
The through-line from ZFS filesystem drivers, where every block is checksummed, to a banking ledger is the same idea wearing different clothes: make incorrect states unrepresentable, and make the correct state cheap to verify.
Concretely, in a ledger that means:
- Append-only entries, never mutated. Corrections are new entries that reverse and re-post, so the history of the mistake is part of the record. You do not edit the past; you add to it.
- Balances derived, reconciled against a full replay, never trusted as authority.
- Every movement is two sides that must net to zero, written in one transaction or not at all.
- Every movement idempotent, so the same intent can be safely retried forever.
- Money as a typed value object, so currency and rounding bugs cannot compile.
None of this is clever. That is the point. A ledger should be the most boring, most predictable part of the system, because it is the part where “mostly correct” is indistinguishable from broken.
What supervision actually teaches you
Building under a regulator changes how you think, and mostly for the better. You cannot “eventually” be correct. You cannot reconcile overnight and hope. You have to be able to stand in front of an auditor and show, entry by entry, where every cent came from and where it went, for any account, on any day in the past.
That requirement forces good architecture whether you like it or not. Append-only history is not a nicety, it is the audit trail. Idempotency is not an optimisation, it is the guarantee you didn’t move money twice. Derived-and-reconciled balances are how you prove the number on the screen is the number the history implies. The regulation is demanding the same things good engineering would demand anyway; it just refuses to let you skip them under deadline.
That discipline is not banking-specific. It is how I think about the AI systems I build now, where a model’s output has to be trusted with real money and “usually right” is the same as wrong. Decide the invariant. Make violating it impossible or loud. Make the correct state cheap to verify. Everything else is a view.