The Most Expensive Line of Code in Fintech
The most expensive line of code in fintech is usually the most innocent-looking one — a single UPDATE statement that increments a balance in place. It passes code review, it works in the demo, and it sails through QA. Then a client retries a timed-out request and a customer gets credited twice. A pod restarts between two writes and money appears to materialize from nothing. An auditor asks how a balance reached its current value, and the honest answer is that the system no longer knows. Building payment systems for a bank in Afghanistan, and separately an e-commerce platform where cash-on-delivery makes reconciliation a daily engineering concern rather than a quarterly accounting one, both systems converged on the same fix: an append-only, double-entry ledger with invariants enforced by the database itself, not by application discipline.
-- The pattern that looks harmless and isn't
UPDATE accounts SET balance = balance + :amount WHERE id = :id;Why the Industry Converged on Double-Entry
This is also, notably, where the market has gone. Ledger infrastructure became its own category — purpose-built transaction databases such as TigerBeetle, open-source posting engines such as Blnk and Formance, and managed ledger services — precisely because so many teams learned these lessons the expensive way, in production. The Synapse collapse remains the canonical case study: when a ledger's view of who owns what diverges from external reality and cannot be reconciled, everything built above it fails, no matter how polished the product on top looks. What follows is the design I now treat as the baseline for any system that moves money.
The Invariant: Money Never Appears or Disappears
Double-entry bookkeeping reduces to a single, machine-checkable rule: every transaction consists of two or more entries whose amounts sum to zero, per currency. A 1,000 AFN wallet top-up is not "increment the wallet" — it is two entries: +1,000 to the platform's cash asset account, and -1,000 to the customer's liability account, because from the platform's perspective a customer balance is money owed, not money possessed. A 50 AFN delivery fee moves value from the customer liability account to a revenue account. Money is never created or destroyed inside the system; it only moves between accounts. Consequently, the sum of every entry ever written is zero, and any deviation is by definition a bug — detectable the moment it occurs rather than at month-end close. Everything else in this design is machinery for making that invariant unbreakable under real-world conditions: retries, crashes, concurrency, and scale.

Schema: Transactions, Entries, Accounts
I use a three-table core: accounts, transactions, and entries. Amounts are stored as signed integers rather than in separate debit and credit columns — the debit/credit presentation is a reporting concern, derivable from the sign of the amount and the type of account it belongs to.
CREATE TABLE accounts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT UNIQUE NOT NULL, -- e.g. '1001-CASH-AFN'
type TEXT NOT NULL CHECK (type IN
('asset','liability','equity','revenue','expense')),
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'posted'
CHECK (status IN ('pending','posted','voided')),
description TEXT,
posted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE entries (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transaction_id UUID NOT NULL REFERENCES transactions(id),
account_id BIGINT NOT NULL REFERENCES accounts(id),
amount_minor BIGINT NOT NULL CHECK (amount_minor <> 0),
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Three Decisions That Matter
Three choices in that schema are deliberate rather than incidental, and each one closes off a class of bug before it can occur.
- Integer minor units. amount_minor is a BIGINT, never a floating-point type — IEEE 754 cannot represent 0.1 exactly, and rounding drift across millions of entries is real, missing money. Store each currency with its ISO 4217 exponent rather than assuming AFN-style zero-subunit behavior applies universally.
- Append-only, enforced at the grant level. Revoking UPDATE and DELETE privileges on entries from the application's database role means the application can physically not rewrite history. Corrections are new, reversing transactions that reference the original — accountants arrived at immutable logs six centuries before software did, for the same reason: an audit trail you can edit is not an audit trail.
- Balances are derived, never stored as truth. An account balance is the sum of amount_minor over its entries. Materialized snapshots exist purely as a read-path optimization and are always rebuildable from the journal.
Enforcing the Invariant Where It Cannot Be Bypassed
The most common failure mode in first-generation ledgers is enforcing balance in application code: write the debit, write the credit, and hope nothing happens in between. Application code has bugs, deploys fail mid-rollout, and a crash between two writes leaves an unbalanced ledger. The correct place to enforce the invariant is the database, via a deferred constraint trigger that fires at commit time — after every entry for a transaction has been inserted, and before any of them become visible to another session. With this in place, all entries for a transaction are inserted inside a single database transaction, and the invariant holds under every partial-failure scenario, because the database simply refuses to commit anything else. Note the GROUP BY currency in the check below: a multi-currency transaction must balance within each currency independently, so an FX conversion is never one AFN entry offsetting one USD entry — it is two balanced legs routed through FX position accounts, with the applied rate recorded explicitly. That is what keeps currency exposure a queryable fact instead of an accident.
CREATE OR REPLACE FUNCTION assert_balanced() RETURNS trigger AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM entries
WHERE transaction_id = NEW.transaction_id
GROUP BY currency
HAVING SUM(amount_minor) <> 0
) THEN
RAISE EXCEPTION 'unbalanced transaction %', NEW.transaction_id;
END IF;
RETURN NULL;
END $$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER trg_entries_balanced
AFTER INSERT ON entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_balanced();Posted vs. Available: The Two-Balance Model
Production systems need two distinct numbers per account, and conflating them is a frequent source of bugs. The posted, or ledger, balance is the sum of entries from fully settled transactions. The available balance is the posted balance minus active holds and pending debits. The clean way to model the difference is a transaction lifecycle: a card authorization or an in-flight transfer is created as pending, reserving funds against the available balance, then transitions to posted on capture or settlement, or to voided on expiry. TigerBeetle formalizes exactly this as two-phase transfers, which tells you how fundamental the pattern is — it isn't an application quirk, it's part of the domain. Every authorization-style flow, from card holds to cash-on-delivery orders awaiting rider confirmation to escrow, maps onto it.
Concurrency: The Hot-Account Problem
Deriving balances from entries removes contention on the write path — inserts do not lock each other. The difficulty returns the moment a business rule such as "no overdrafts" has to be enforced, because checking that the available balance covers an amount and inserting the offsetting entries must be atomic per account. The options, in ascending order of sophistication:
- Pessimistic locking. SELECT ... FOR UPDATE on the account row before posting. Simple and correct; throughput per account is bounded by lock hold time. Adequate for customer wallets, painful for a single hot revenue or fee account receiving every transaction in the system.
- Serializable isolation with retry. Let PostgreSQL's serializable snapshot isolation detect conflicts and retry the losing transaction. Cleaner application code, but unpredictable retry storms on genuinely hot accounts.
- Single-writer sequencing. Route postings for hot accounts through one ordered consumer — a queue partitioned by account. This is, in essence, the architectural bet TigerBeetle makes: a deterministic, single-threaded state machine, which is why it posts numbers general-purpose databases cannot.
- Account sharding. Split a hot account into N sub-accounts written round-robin and aggregated on read. Standard practice for platform-level fee and revenue accounts.
- Rule of thumb. Use pessimistic locking until profiling proves a specific account hot, then shard or sequence only that account — premature distributed cleverness in a ledger buys consistency bugs with extra steps.
Idempotency as a Schema Property, Not a Convention
Every money-moving endpoint accepts a client-generated idempotency key, and a unique constraint on that key makes replays structurally impossible rather than procedurally avoided — on conflict, the service simply returns the stored outcome of the original attempt. Unreliable mobile networks, a daily operating condition in markets like Afghanistan, turn this from best practice into survival: a payment request that succeeds server-side while the response is lost will be retried by the client. The only question worth asking at design time is whether the schema has already decided what happens next.
Read-Path Performance: Checkpoints, Not Shortcuts
Summing millions of entries on every balance read does not scale, but the answer is a checkpoint, not a mutable balance column. The current balance is the latest checkpoint plus the sum of entries written after it. Checkpoints are written asynchronously, are safe to delete, and are periodically re-verified against the full journal. The journal remains the single source of truth; everything else is cache, and is treated with the suspicion caches deserve. Time-based partitioning of the entries table keeps both the hot working set and the audit queries fast as history grows into the billions of rows.
CREATE TABLE balance_checkpoints (
account_id BIGINT NOT NULL,
last_entry_id BIGINT NOT NULL,
balance_minor BIGINT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, last_entry_id)
);Getting Data Out: The Outbox, Not Dual Writes
Downstream systems — notifications, analytics, the accounting general ledger, fraud scoring — need to know about postings as they happen. Publishing to a message broker and committing to the database as two separate operations recreates the exact partial-failure problem the ledger was built to eliminate. The transactional outbox pattern solves it: write the event to an outbox table inside the same database transaction as the entries, and let a relay, or change-data-capture via logical replication, deliver it from there. Exactly-once semantics end at the ledger boundary; at-least-once delivery plus consumer-side idempotency handles the rest of the world.
Reconciliation Is an Engineering Deliverable
The ledger asserts what should be true. Bank statements, the payment switch's settlement files, and the cash riders hand over at the end of a route assert what is true. Reconciliation — continuously proving these two views agree — is the discipline whose absence turned Synapse into an industry-wide cautionary tale, and it belongs in the initial architecture, not in a post-launch cleanup sprint. Concretely, every external money event lands in a staging table, is matched to internal entries by deterministic keys such as reference numbers, amounts, and value dates, and unmatched items age into alerts. ISO 20022's structured messaging has materially improved this: settlement records now carry enough machine-readable context that automated matching is the norm rather than the aspiration, which is a quiet but significant part of why the global migration mattered. For cash-on-delivery specifically, the model that works is treating each rider as a clearing account — collected cash posts to a rider-clearing account representing what the rider owes the platform, handover moves it to the cash account, and "how much cash is currently on the road" becomes a query instead of an estimate.
Tamper Evidence, Briefly
For regulated contexts, append-only can be upgraded to cryptographically tamper-evident: each entry stores a hash of its content chained to the previous entry's hash, so any retroactive modification invalidates every subsequent hash. It's a modest addition that turns "we don't rewrite history" from policy into proof — increasingly requested in audits, and considerably cheaper than the distributed-ledger machinery it superficially resembles.
Build or Buy, Honestly
The honest answer here is more nuanced than it was five years ago. If your differentiation is elsewhere, a managed ledger or an open-source posting engine buys audited primitives and months of runway. If you need extreme per-second posting volume, a purpose-built transaction database is the honest tool. I build in-house because banking regulation and infrastructure realities in Afghanistan require it — data residency, offline resilience, and integration with a domestic payment switch are not managed-service features. That decision deserves the same rigor as the schema itself.
The Checklist I Hold Myself To
Venetian merchants specified this system's invariants roughly six hundred years before ACID had a name. Most of what has been added since is mechanical sympathy — faster storage, distributed consensus, better observability — layered on a discipline that hasn't fundamentally changed. The design humility worth keeping is this: when an architecture disagrees with double-entry, it is almost never double-entry that's wrong.
- Append-only journal, with balances derived, checkpointed, and never authoritative in a mutable column.
- Zero-sum enforced per currency by a deferred database constraint, not by application discipline.
- Integer minor units everywhere — no floating-point money, ever.
- Idempotency expressed as a unique constraint, not a best-effort check.
- A pending-to-posted lifecycle for anything with settlement delay.
- An outbox for every downstream event, never a dual write.
- Reconciliation pipelines built from day one, not bolted on after an incident.
- A nightly job that sums the entire journal and pages someone if it isn't zero — it should never fire, and the day it does, it will be the most valuable alert in the system.
