Every few years a new engineer joins one of my teams, looks at our ledger code for the first time, and asks the same question: why are there two rows for every transaction, and why can't we just store a single balance column and update it? It is a fair question. The double-entry ledger feels like accounting ceremony left over from quill pens and leather-bound books. But the more money you move, the more you come to understand that this seven-hundred-year-old pattern is not ceremony at all. It is one of the most robust data integrity mechanisms ever invented, and it maps remarkably well onto the constraints of modern software.
This post is the introduction I wish I had been handed early in my career. I want to explain what a double-entry ledger actually is, why it matters in regulated fintech, and how to model one in software without drowning in either accounting jargon or premature abstraction. I will use concrete examples from systems I have helped build, and I will be honest about the places where the textbook model meets the messier reality of databases, currencies, and time.
What Double-Entry Actually Means
The core idea is deceptively simple. Money never appears or disappears; it only moves from one place to another. Every economic event is therefore recorded as at least two entries: a debit to one account and a credit to another, equal in magnitude. If a customer funds their wallet with one hundred euros from their bank, we debit a cash account and credit the customer's wallet liability. The sum of all debits in a single transaction always equals the sum of all credits. This is the invariant that everything else rests on.
The terms debit and credit confuse almost everyone at first, partly because they do not mean "decrease" and "increase" in any consistent way. Whether a debit raises or lowers a balance depends on the type of account. For asset accounts, debits increase the balance. For liability and equity accounts, credits increase the balance. The reason this asymmetry exists is the accounting equation: assets equal liabilities plus equity. Keep that equation balanced on every transaction and your books are, by construction, internally consistent.
In a software context, I find it helpful to drop the emotional baggage of the words and think purely in terms of signed movements across a graph of accounts, constrained so that each transaction nets to zero. Once you frame it that way, the accounting vocabulary becomes a useful interface to the rest of the business rather than a source of anxiety for engineers.
Why Regulated Fintech Needs This Discipline
You can build a payments product that simply mutates balances. Plenty of early-stage companies do exactly that, and it works right up until it does not. The first time you have a dispute, a regulator request, or a reconciliation break with a banking partner, a mutable balance leaves you with nothing to investigate. You know what the balance is now, but you cannot reconstruct why, or what it was at any point in the past.
A double-entry ledger gives you an immutable, append-only history of every movement. Auditors love it because every figure on a balance sheet can be traced back to the individual entries that produced it. Risk teams love it because suspicious patterns are visible in the flow rather than hidden behind an overwritten number. And engineers eventually love it because debugging becomes a matter of reading history rather than guessing at lost state.
A balance is an opinion derived from facts. The facts are the entries. If you store only the opinion, you have thrown away the evidence you will one day be asked to produce.
The Core Data Model
At its heart the model has three concepts: accounts, transactions, and entries. An account is a named bucket with a type that determines its normal balance direction. A transaction is a logical event that groups entries together and carries metadata such as the timestamp and a reference to whatever business action triggered it. An entry is a single signed movement against one account, belonging to exactly one transaction.
The non-negotiable constraint is that the entries within a transaction must sum to zero. I enforce this both in application code and, where possible, in the database, because defence in depth matters when money is involved. Here is a minimal SQL schema that captures the shape of it:
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN
('asset','liability','equity','revenue','expense')),
currency CHAR(3) NOT NULL
);
CREATE TABLE transactions (
id BIGINT PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL,
reference TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE entries (
id BIGINT PRIMARY KEY,
transaction_id BIGINT NOT NULL REFERENCES transactions(id),
account_id BIGINT NOT NULL REFERENCES accounts(id),
-- positive = debit, negative = credit, in minor units
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL
);
Notice that there is no balance column anywhere. The balance of an account is the sum of its entries. That is the whole point. We will talk about how to make that performant later, but the canonical truth is always the entries.
Representing Money Without Losing It
One mistake I see again and again is storing money as a floating-point number. Floats cannot represent most decimal fractions exactly, so a value like 0.10 plus 0.20 does not reliably equal 0.30. In a ledger that processes millions of entries, those rounding errors accumulate and eventually break the zero-sum invariant in ways that are maddening to track down.
The fix is to store amounts as integers in the smallest unit of the currency. For euros and dollars that means cents; for currencies with different subdivisions you adjust accordingly. A balance of twelve euros and fifty cents is stored as the integer 1250. All arithmetic happens in integer space, and you only format to a decimal string at the very edge, when you present the value to a human. In .NET I either use a long for minor units or the decimal type for intermediate calculations that genuinely require fractional precision, never double or float.
Currency itself is part of the model, not an afterthought. An entry has a currency, and you must never sum entries of different currencies as if they were the same number. Cross-currency movements are modelled as separate balanced transactions with an explicit foreign-exchange account in the middle, so the rate applied is itself a recorded fact rather than a hidden assumption.
Posting a Transaction Atomically
The act of writing a balanced set of entries is called posting. Posting must be atomic: either all entries land together or none do. A half-posted transaction is a hole in your books, and a hole in your books is a regulatory and operational nightmare. This is exactly the kind of guarantee a relational database transaction provides, which is one reason I am cautious about ledgers built on stores without strong transactional semantics.
Here is a simplified posting routine in C#. The validation that entries net to zero happens before anything touches the database, and the write is wrapped in a single transaction so partial failure is impossible:
public async Task PostAsync(PostingRequest request)
{
if (request.Entries.Count < 2)
throw new InvalidOperationException("A posting needs at least two entries.");
var byCurrency = request.Entries.GroupBy(e => e.Currency);
foreach (var group in byCurrency)
{
long net = group.Sum(e => e.AmountMinor);
if (net != 0)
throw new UnbalancedTransactionException(group.Key, net);
}
await using var tx = await _db.BeginTransactionAsync();
var txId = await _db.InsertTransactionAsync(request.OccurredAt, request.Reference);
foreach (var entry in request.Entries)
await _db.InsertEntryAsync(txId, entry.AccountId, entry.AmountMinor, entry.Currency);
await tx.CommitAsync();
}
Note that I validate balance per currency rather than across the whole request, because a multi-currency posting must balance within each currency independently. The foreign-exchange leg is what bridges them, and it is itself two balanced entries.
Immutability and Reversals
Once an entry is posted, it is never edited or deleted. This is a rule I will fight to keep, because the moment you allow mutation you lose the audit trail that justified building a ledger in the first place. But mistakes happen, and money sometimes needs to move back. The way you correct a ledger is not by deleting, but by posting a reversing transaction that mirrors the original with the signs flipped.
This keeps the history honest. A reader can see that an erroneous transaction was posted, then see the correction, then see any subsequent re-posting. Nothing is hidden. When a customer or an auditor asks what happened, the answer is the full sequence of events rather than a sanitised final state. I usually link the reversal back to the original transaction through a reference so the relationship is queryable.
The discipline pays off in subtle ways. Because nothing is ever destroyed, you can replay your entire ledger from the beginning to rebuild any derived view, recover from a corrupted cache, or validate a new reporting pipeline against a known-good source. The append-only log becomes the foundation for everything downstream.
Computing Balances at Scale
Summing every entry for an account on every read is fine when you have thousands of entries and unacceptable when you have hundreds of millions. The honest answer is that you keep the entries as the source of truth and maintain derived balances as an optimisation, with mechanisms to verify the derived values against the canonical entries.
There are a few common strategies, each with trade-offs worth weighing:
- Running balances: store a balance snapshot on each entry as it is posted. Reads are instant, but you must serialise postings per account to keep the running total correct.
- Periodic snapshots: checkpoint each account's balance at, say, the end of each day, then sum only the entries since the last checkpoint. This bounds the work per read.
- Materialised aggregates: maintain a separate balance table updated within the same database transaction as the posting, with a reconciliation job that periodically re-sums the entries and alerts on any drift.
Whichever you choose, the rule is the same: the derived balance is a cache, and you must be able to prove it matches the entries. I run a nightly reconciliation that recomputes balances from scratch and compares them against the maintained values. If they ever diverge, that is an incident, not a rounding tolerance.
Idempotency, Ordering, and Time
Distributed systems retry. A payment webhook may be delivered twice, a queue may redeliver a message, a client may resubmit after a timeout. If your posting logic is not idempotent, retries will double-count money, and double-counted money in a ledger is a serious failure. I attach a client-supplied idempotency key to every posting request and enforce uniqueness on it, so a retried request returns the original result rather than creating a second transaction.
Time deserves equal care. I distinguish between when an event occurred in the real world and when it was recorded in the ledger. A card transaction might happen at one moment but settle and post days later. Storing both timestamps lets you produce accurate point-in-time reports and answer questions about the state of an account as of any given date, which is exactly what auditors and regulators ask for. Conflating the two is a mistake you will regret the first time a reporting period boundary matters.
Conclusion
Double-entry bookkeeping survived seven centuries because it encodes a simple, powerful invariant: money moves, it does not vanish, and every movement balances. Translated into software, that invariant gives you an immutable audit trail, provable balances, and a system you can reason about under the scrutiny of regulators and the pressure of incidents. None of it requires exotic technology. It requires discipline: integers not floats, append-only not mutable, reversals not deletions, and derived balances treated as caches that must always reconcile to the entries.
If you are building anything that holds or moves customer money, I would urge you to start with this model even when it feels like overkill for your current scale. Retrofitting a ledger onto a system that has been silently overwriting balances is one of the hardest and most stressful migrations I have ever led. Build it right from the first transaction, and the ledger will quietly do its job for years, which is exactly what good financial infrastructure is supposed to do.
