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.
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.
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.
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."
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.
| Field | Type | Why it exists |
|---|---|---|
| id | uuid | Stable reference for queries and audits |
| ts | timestamptz | Audit ordering; immutable once written |
| debit_account | text | Hierarchical account path (e.g. wlt:eu:cust:12345:available) |
| credit_account | text | The account on the other side of the entry |
| amount | numeric(20,4) | Always positive. Direction encoded in debit/credit, not sign |
| currency | char(3) | Per-row, ISO 4217. No mixed-currency postings |
| ref | text | Free-form reference (auth-id, scheme-ref, BPM case-id) |
| journal | text | Named flow (card-auth-block, sepa-credit, kyt-case) |
| idempotency_key | text | Required at the gateway boundary; dedup window 24h |
| event_hash | bytea | sha256 of the source event payload, for replay verification |
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.
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.
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.
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.
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.
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.