Engineering July 12, 2026 · 10 min read

How to Architect a Ledger for a Fintech

Fintech ledger architecture: double-entry, immutable append-only journals, integer money, idempotent postings, serializable concurrency, reconciliation, and build vs. buy.

The short answer

Architect a fintech ledger as an immutable, append-only journal of double-entry postings where debits always equal credits, amounts are stored as integer minor units rather than floats, every posting is idempotent, and writes run under serializable isolation. Derive balances from the journal, and reconcile it against banks and processors continuously.

Architect a fintech ledger as an immutable, append-only journal of double-entry postings where debits always equal credits, amounts are stored as integer minor units rather than floats, every posting is idempotent, and writes run under serializable isolation. Derive balances from the journal, and reconcile it against banks and processors continuously.

That is the whole discipline in one paragraph. The rest is the reasoning behind each clause, because a ledger is the one system in your stack where “mostly correct” is indistinguishable from broken. If your marketing site drops a request, someone refreshes. If your ledger drops a posting, money is missing and you find out during an audit. Below is how we approach ledger design at FinWeb when a client is deciding what to build, what to buy, and what the schema actually needs to guarantee.

What is a fintech ledger and why is double-entry the foundation?

A ledger is the authoritative record of every movement of value in your system. Double-entry is its foundation because it makes errors structurally visible: every transaction is recorded as balanced debits and credits, and the sum of all debits must always equal the sum of all credits. If they diverge, you have a bug, not a balance.

Single-entry — a running list of “account X went up by 5” — cannot answer where the 5 came from. Double-entry forces you to name both sides: this account was debited, that account was credited, for the same amount, atomically. A customer funding their wallet is not one event. It is a credit to their wallet balance and a debit to a clearing or settlement account. The two legs are inseparable; you write both or neither.

This is not accounting ceremony. It is the cheapest integrity check you will ever get. A ledger that enforces debits-equal-credits at write time cannot silently create or destroy money, because money can only move between accounts, never appear or vanish. TigerBeetle, a database built specifically for this, models exactly this: it tracks cumulative posted debits and cumulative posted credits per account and computes the balance from them (TigerBeetle data modeling). If you build your own, you are reimplementing that invariant — so implement it deliberately, not by accident.

Should you store balances or an append-only journal of transactions?

Store the journal as the source of truth and treat balances as derived. The journal is an immutable, append-only log of postings; a balance is a projection you compute or cache from it. You may keep a materialized balance for read performance, but it is a cache, never the record. The journal is what an auditor trusts.

Why immutability matters

Mutable balances lose history. If you only store “wallet balance = 4200” and update it in place, you cannot answer how it got there, cannot replay it, and cannot prove it was never tampered with. An append-only journal never updates a row; corrections are new, compensating postings. This gives you a complete, ordered history for free, which is precisely what event sourcing formalizes: the sequence of state-changing events is the system of record, and current state is a fold over that sequence.

The balances-as-cache pattern

The practical architecture is a hybrid. Writes append immutable postings to the journal. A balances table, updated in the same transaction as the posting, serves fast reads. Because the balance is derived, you can always rebuild it by replaying the journal, which is your ultimate reconciliation and disaster-recovery tool. The invariant to protect: the balances table must never be writable except as a consequence of a journal posting. Design guidance on where correctness like this must live sits in our note on build vs. buy for fintech infrastructure.

Why should you never store money as floating-point numbers?

Because floating-point cannot represent most decimal fractions exactly. 0.1 + 0.2 is not 0.3 in IEEE 754 binary floating point, and those rounding errors accumulate across millions of postings until your ledger no longer balances. Store money as integers in the currency’s smallest unit, or as fixed-precision decimals. Never as float or double.

The standard approach is integer minor units: represent $42.00 as the integer 4200 cents, ¥42 as 42, and a currency with three decimal places accordingly. All arithmetic happens on integers, which is exact. TigerBeetle makes this explicit — amounts are unsigned 128-bit integers, and its guidance is to “map the smallest useful unit of the fractional currency to 1” so that calculations avoid floating-point approximation (TigerBeetle data modeling).

Two practical notes. First, store the currency and its scale alongside the amount; an integer 4200 is meaningless without knowing it is USD cents. Second, if you must handle division — splitting fees, computing interest — decide your rounding rule explicitly and record the remainder as its own posting so the books still balance to the cent. Where you use fixed-precision decimals instead (Postgres NUMERIC, Java BigDecimal), the same rule holds: never let a value transit through a binary float on its way in or out.

How do you make postings idempotent and exactly-once?

Give every posting a client-supplied idempotency key and enforce uniqueness on it in the database. When a request arrives, you either insert the posting for the first time or detect the key already exists and return the original result. This turns unreliable at-least-once delivery into effectively exactly-once, which is the strongest guarantee a distributed system can offer.

Exactly-once delivery does not exist on a network; retries are inevitable. A client that times out will retry, and without idempotency that retry double-posts. The fix is not to prevent retries but to make them safe. A unique constraint on the idempotency key means the second attempt fails the insert cleanly, and you respond with the already-committed outcome instead of creating a duplicate.

Design decisionNaive approachCorrect approach
Recording moneyMutable balance column updated in placeAppend-only journal of double-entry postings; balance derived
Amount typefloat / double dollarsInteger minor units (or fixed NUMERIC), with currency + scale stored
Retries / duplicatesHope the client only sends onceUnique idempotency key enforced by a DB constraint
Concurrent updatesRead balance, add, write backSerializable transaction or atomic conditional update
CorrectionsUPDATE the wrong postingNew compensating (reversing) posting, original untouched

The idempotency key belongs to the business operation, not the HTTP request — derive it from the transfer’s natural identity (order ID, transfer ID) so that a genuinely new operation gets a new key and a retry of the same operation reuses it.

How do you handle concurrency without losing updates?

Two requests debiting the same account at once can each read the old balance, each compute a new one, and each write — and one update is silently lost. Prevent this either with serializable isolation, which makes concurrent transactions behave as if they ran one at a time, or with atomic conditional writes that reject stale updates.

The lost-update problem is the canonical failure. PostgreSQL’s Serializable level uses predicate locking to detect read/write dependencies among concurrent transactions and aborts one with a serialization failure rather than allow an anomaly (PostgreSQL transaction isolation). Your application must catch that failure and retry the transaction — retry logic is not optional at this isolation level, it is part of the contract.

Serializable vs. explicit locking

You have two workable strategies. Run the posting under SERIALIZABLE and retry on serialization failures — simplest to reason about, correct by construction, but you pay in aborts under contention. Or use explicit row locking (SELECT ... FOR UPDATE) on the affected accounts so concurrent postings queue rather than conflict. Hot accounts — a single settlement or fee account touched by every transaction — are where contention concentrates; that is exactly the problem purpose-built ledger databases optimize away, and a real input to any build-vs-buy decision. These consistency trade-offs are covered well in Martin Kleppmann’s Designing Data-Intensive Applications, which is the reference we point ledger teams toward.

What account types and equation must the ledger enforce?

The ledger must respect the accounting equation: assets equal liabilities plus equity. Every account is one of five types — asset, liability, equity, income, expense — and each type has a normal balance side (debit or credit) that determines whether a debit increases or decreases it. Enforcing the equation is how you prove the books are internally consistent.

A customer’s wallet balance is a liability to you — you owe them that money — so it carries a credit-normal balance. Your bank settlement account is an asset. When a customer deposits, you debit the asset and credit the liability, and the equation holds. Getting the sign conventions right per account type is not pedantry; it is what lets your balance sheet reconcile and what an auditor will check first.

Account typeNormal balanceDebit effectExample in a fintech
AssetDebitIncreasesBank settlement account, cash at processor
LiabilityCreditDecreasesCustomer wallet balances, pending payouts
EquityCreditDecreasesRetained platform capital
IncomeCreditDecreasesFees earned, interchange revenue
ExpenseDebitIncreasesProcessing costs, network fees

TigerBeetle encodes this directly: accounts declare whether their balance is debits - credits (assets, expenses) or credits - debits (liabilities, equity, income), and offer flags to keep balances non-negative (TigerBeetle data modeling).

How do you reconcile the ledger against banks and processors?

Reconciliation is the continuous process of proving your internal ledger matches external reality — bank statements, processor settlement files, card-network reports. You pull the external record, match each line to a ledger posting, and investigate every break. A ledger that balances internally can still be wrong if it disagrees with the bank; reconciliation is how you catch that.

Your ledger is one view of the money; the bank’s is another, and they update on different clocks. A payout you posted instantly may settle at the bank two days later, so reconciliation is fundamentally a state-machine and timing exercise: pending, settled, failed, reversed. Model those states as distinct postings rather than mutating one record. When a processor reports a chargeback or a failed ACH, that is a new event to post, not an edit to history. Because processors differ in how they report and settle, your acceptance architecture matters here — we compare two common choices in Stripe vs. Adyen for a fintech, and how the ledger sits in the wider stack is mapped in the fintech stack for 2026.

Should you build a ledger in-house or buy one?

Build in-house when the ledger’s guarantees are your product and you have the engineering depth to own correctness, concurrency, and auditability permanently. Buy or adopt a specialized system — TigerBeetle, Formance, Modern Treasury — when you want those invariants handled by a team that does only this. Most teams should start from a proven ledger primitive, not a blank table.

The honest trade-off: a ledger looks like a simple table until you hit hot-account contention, exactly-once posting under retries, 128-bit money math, and audit-grade immutability at the same time. Those are the hard parts, and they are precisely what dedicated ledger databases exist to solve. Building your own is defensible when ledger semantics are your differentiator — a novel settlement model, a multi-asset design no vendor supports. It is a poor use of a small team’s time when you are reimplementing double-entry that TigerBeetle or a ledger API already got right. The reasoning generalizes in our build vs. buy for fintech infrastructure piece, and this correctness-critical layer is exactly the kind of work our platform engineering team takes on.

Whichever way you go, the invariants do not change: immutable journal, double-entry, integer money, idempotent postings, serializable writes, continuous reconciliation. Get those right and the ledger becomes the quiet, trustworthy core the rest of the product can lean on. If you are designing or auditing a ledger and want a second set of eyes on the schema and the concurrency model, talk to FinWeb.

Frequently asked questions

Why is double-entry accounting the foundation of a fintech ledger?

Because it makes errors structurally visible. Every transaction is recorded as balanced debits and credits, and the sum of all debits must equal the sum of all credits. Money can only move between accounts, never be created or destroyed silently, so a ledger that enforces this invariant at write time gives you the cheapest integrity check available.

Should a fintech store balances or a journal of transactions?

Store the journal as the source of truth and derive balances from it. The journal is an immutable, append-only log of postings; a balance is a projection you compute or cache. Keep a materialized balance for fast reads if you like, but only ever update it as a consequence of a journal posting, and always be able to rebuild it by replay.

Why can't you store money as floating-point numbers?

Floating-point cannot represent most decimal fractions exactly, so rounding errors accumulate across postings until the ledger no longer balances. Store money as integers in the currency's smallest unit, such as cents, or as fixed-precision decimals, and store the currency and its scale alongside the amount. Do all arithmetic on integers.

How do you make ledger postings idempotent?

Give every posting a client-supplied idempotency key derived from the operation's natural identity, and enforce uniqueness on it in the database. A retry then fails the insert cleanly and you return the original result instead of double-posting. This turns unreliable at-least-once network delivery into effectively exactly-once posting.

How do you avoid lost updates when two postings hit the same account?

Run the posting under serializable isolation, which makes concurrent transactions behave as if executed one at a time, and add retry logic for serialization failures. Alternatively, use explicit row locking so concurrent postings queue. Hot accounts touched by every transaction are where contention concentrates and where dedicated ledger databases help.

Should you build a fintech ledger in-house or buy one?

Build in-house when the ledger's guarantees are your product and you can own correctness, concurrency, and auditability permanently. Otherwise adopt a specialized system such as TigerBeetle, Formance, or Modern Treasury. Most teams should start from a proven ledger primitive rather than a blank table, because the hard parts are exactly what those systems solve.

Sources

Published by FinWeb · July 12, 2026

#engineering#ledger#accounting#concurrency#build-vs-buy
Let’s build

Have a fintech worth building right?

Tell us where you are — an idea, a rebrand, a raise, a replatform. We’ll come back with a point of view, a plan and a fixed scope, usually within one business day.