Coreal.
Book a working session →
← RESOURCES·LEDGERv1.2

Designing a ledger that survives an audit.

Why double-entry beats single-entry under concurrency, what a posting actually looks like, and how zero reconciliation drift falls out of the design — not out of a Friday cleanup job.

AUTHOR
A. Avramenko
Principal Engineer · Coreal
READING TIME
22 min
UPDATED
2026-04-30
WRITTEN FOR
Senior architect, principal engineer, head of payments

A ledger is the system of record. Not a database. Not an accounting afterthought. The single source of truth a regulator will eventually ask to read. If you build the ledger right, reconciliation is an artefact — there is nothing to reconcile, because every event is already a typed posting in the same structure. If you build it wrong, your ops team spends Friday afternoons chasing delta files until somebody quits.

The single-entry trap

Most fintech systems start the same way: a customer table with a balance column. Money in, balance up. Money out, balance down. It feels like progress. It is the future suffering condensed into one row.

Single-entry breaks the moment two things happen concurrently. Two card authorisations against the same balance. A SEPA refund landing while a top-up is mid-flight. A KYT case unblocking funds while an outbound transfer hits the gateway. Every one of those becomes a race condition you have to think about, every time, in every code path that touches money. You cannot win this game.

A balance column is a pre-aggregated view of postings you didn’t bother to write. The postings always exist. You either store them or pretend they don’t.

Double-entry as a constraint, not a convention

Coreal’s core ledger is a posting log with a database constraint: every posting is two rows that net to zero. A debit and a credit, against typed accounts, with the same amount, same currency, same idempotency key. The constraint is enforced at the database, not the application — the database refuses to commit a posting that doesn’t balance.

This sounds like a small detail. It is the difference between a ledger you trust and a ledger you check. Once the constraint exists, every higher-level operation gets simpler. Authorisations? A blocking entry. Captures? A reversal of the block plus a new settled entry. Refunds? A reversal entry against the original. Every flow reduces to "post these two rows."

Throughput sustained
14,200 tps
double-entry, hot path
Reconciliation drift
0.00 bps
across PSP / scheme / wallet
Ledger consistency check
continuous
sum-of-debits = sum-of-credits, per minute

Anatomy of a posting

A Coreal posting carries: identifier, timestamp, debit account, credit account, amount, currency, reference, journal name, idempotency key, source-event hash. Nine fields. None are optional. The journal name groups related postings (e.g. card-auth-block, sepa-credit-transfer); the idempotency key is what makes retries safe; the source-event hash links the posting back to the originating event in Kafka.

FieldTypeWhy it exists
iduuidStable reference for queries and audits
tstimestamptzAudit ordering; immutable once written
debit_accounttextHierarchical account path (e.g. wlt:eu:cust:12345:available)
credit_accounttextThe account on the other side of the entry
amountnumeric(20,4)Always positive. Direction encoded in debit/credit, not sign
currencychar(3)Per-row, ISO 4217. No mixed-currency postings
reftextFree-form reference (auth-id, scheme-ref, BPM case-id)
journaltextNamed flow (card-auth-block, sepa-credit, kyt-case)
idempotency_keytextRequired at the gateway boundary; dedup window 24h
event_hashbyteasha256 of the source event payload, for replay verification

Hierarchical accounts

The account is not a number. It is a path. customer:12345:available, customer:12345:blocked, customer:12345:savings, treasury:eur:safeguarding. The path is the type system of the ledger. A posting that moves from customer:available to customer:blocked is a card auth. A posting from treasury:safeguarding to bank-partner:safeguarding is a settlement. The shape of the path tells you what the posting means.

The hierarchy lets the ledger answer questions without a separate query layer. "Total customer liabilities" is the sum of all customer:* accounts. "Total safeguarding obligation" is the sum of all *:available + *:blocked + *:savings under customer paths. "Drift" is that sum compared to the treasury balance. The answer is always one query against the postings table.

Currency as a column

A common design error: separate ledgers per currency. EUR ledger, USD ledger, BTC ledger. The first time someone needs an FX trade or a fiat-crypto switch, the design breaks. Coreal stores currency as a column on every posting. A four-line FX trade posts to four accounts in two currencies — customer fiat debit, customer crypto credit, treasury fiat credit, treasury crypto debit — in a single transaction. Currency boundaries vanish at the ledger level.

Replayability

Every posting is the result of an event. The event lives in the immutable Kafka log with 7-year retention. Every posting carries the event hash. This means: any subset of postings can be recomputed from the event log. Bug in the BPM workflow? Patch the workflow, replay the events from the affected window, diff the postings.

What replay buys the regulator

A regulator pulling an evidence pack on transaction X gets: the originating event with its hash, the BPM workflow version that was active at that time, the postings that resulted, the case if there was one, and the operator who closed it. Same input → same output, deterministically, seven years later.

What zero drift means in practice

Zero drift is not "we reconcile to zero every Friday." Zero drift is: at any minute, the sum of all debits equals the sum of all credits. By construction, because the database refuses to write a posting that doesn’t balance. There is nothing to reconcile because there is no asymmetry to detect. Reconciliation, in the traditional sense, becomes a dashboard that always reads zero.

What we still call "reconciliation" is the comparison between the ledger and external counter-systems: the PSP settlement file, the card scheme batch, the bank partner statement. Those are external systems we do not control. The ledger compares its own postings against incoming statements and surfaces mismatches as cases in the operator workspace. Mismatches almost always trace to provider-side timing (a settlement booked T+1 vs T+2). The ledger itself is never the source of the mismatch.

What this section costs you

Double-entry is more expensive than single-entry. Each money movement writes two rows. Each row carries nine columns. The hot-path latency budget is tighter because the database constraint takes time. We invested in this for years; we run sustained 14.2k tps on a 6-node Postgres 16 cluster with a tuned WAL configuration. If you start from scratch, expect 6 months of hardening before you trust your own benchmarks.

The trade is favourable: higher infrastructure cost in exchange for an entire class of bugs you do not have to chase, a reconciliation team you do not have to staff, and a regulator interaction you can complete in days instead of weeks.

OTHER DEEP DIVES
← All deep divesReserve a 4-hour working session →