Designing a Money-Moving System: Lessons from Formance Ledger
A System Design Chapter, in the style of "System Design Interview" — using Formance Ledger as the case study
The goal of this chapter is not to memorize Formance's API. It is to understand why a money-moving system is built the way it is. Almost every decision Formance made is a reaction to a specific, painful failure mode that naive ledgers fall into. We will earn each decision.
Step 1 — Understand the Problem and Establish Design Scope
What is a ledger, really?
A ledger is the authoritative record of where money is and how it got there. Not a cache. Not a projection of some other database. The source of truth. If your ledger and your bank statement disagree, the ledger is supposed to tell you exactly why.
The naive version everyone builds first is a balances table:
users(id, balance)
UPDATE users SET balance = balance - 100 WHERE id = 'alice';
UPDATE users SET balance = balance + 100 WHERE id = 'bob';
This is wrong in roughly every way that matters, and the reasons it's wrong are the requirements:
- It stores a derived value (
balance) as if it were a fact. You can't answer "what was Alice's balance last Tuesday?" or "which transaction made this number change?" - The two
UPDATEs can partially apply → money vanishes or is created. - A single bad
UPDATE balance = 999999creates cash out of thin air, and nothing stops it. - It's mutable, so there's no audit trail — fatal for anything regulated.
So the problem we're actually solving is: record financial movements such that money is conserved, history is immutable, balances are always derivable and provable, and the whole thing is fast enough and concurrent enough to run a real product.
Functional requirements
- Record transactions that move an amount of an asset between accounts, atomically (all-or-nothing).
- Conserve money — the system must make it impossible to create or destroy value by accident.
- Derive balances for any account, for any asset, at any point in time.
- Support arbitrary assets — USD, EUR, BTC, ETH, loyalty points, even
MADEUPONE. The ledger shouldn't care. - Query history — list transactions, filter by account, get point-in-time snapshots.
- Be programmable — complex flows ("split this payment 50% to the seller, 10% fee to us, the rest to tax") should be expressible declaratively, not hand-rolled in application code.
Non-functional requirements
| Property | Target / nature | |---|---| | Correctness | Non-negotiable. Money must be conserved at all times. | | Immutability / auditability | Append-only. Nothing is ever edited in place. Ideally tamper-evident. | | Consistency | Strong / immediate. A read after a write must reflect that write. No "eventually." | | Throughput | ~1,000 writes/sec per ledger on commodity hardware; scale horizontally beyond that. | | Read performance | Balance and history queries should stay cheap even as the log grows forever. | | Deployability | Run as a standalone microservice or self-hosted on Kubernetes, or as managed cloud. |
Back-of-the-envelope
A ledger is append-only, so it only ever grows. Say a mid-size fintech does 5M transactions/day. Each transaction touches a few accounts and writes a log entry, maybe ~1 KB of structured data.
- 5M/day ≈ 58 writes/sec average, but bursts will spike 10–20×.
- 5M × 365 ≈ 1.8B rows/year, growing without bound.
Two facts fall out of this immediately and shape the entire design:
- The store grows forever → we need a strategy so that query cost does not grow linearly with total history.
- Writes are bursty and contend on hot accounts → concurrency control is the central performance problem, not raw CPU.
Step 2 — Propose High-Level Design and Get Buy-In
The single most important decision: the accounting model
Most ledgers use classic double-entry accounting (every entry is a debit on one account and an equal credit on another). Formance deliberately chose a variant called the source/destination model. Understanding why unlocks everything else.
In the source/destination model, an account is just a container for funds, and per asset it tracks two add-only counters:
source= total funds that have ever left the account (its outputs)destination= total funds that have ever entered the account (its inputs)
You never decrement these. You only ever add. The balance is derived:
balance = destination − source (i.e. total in − total out)
The ledger does not store balances. It stores volumes (the unsigned in/out totals) and computes balances on read. This is the database equivalent of event sourcing: store the events, derive the state. It's why you get auditability for free — the balance is never something you can corrupt, only something you can recompute.
Why this model over textbook double-entry? Three reasons Formance cites:
- It makes money-creation structurally impossible. Because
sourceanddestinationare unsigned, add-only counters, the only way to change a balance is to record a movement that came from somewhere. - It treats the ledger as an inventory of cash you can build complex flows on top of, rather than a pile of accounting entries you have to interpret.
- It's easier to reason about and audit than multi-input/multi-output entries, which are mathematically fine but mentally hard.
Postings and transactions
The atom of movement is a posting — a single, simple statement:
{ "source": "alice", "destination": "bob", "asset": "COIN", "amount": 100 }
Note this is single input / single output: one source, one destination. Formance picked this on purpose — multi-in/multi-out postings are "correct but hard to grasp." When you need complexity, you don't make the posting complex; you bundle many simple postings into one atomic transaction:
{
"postings": [
{ "source": "alice", "destination": "teller", "amount": 100, "asset": "COIN" },
{ "source": "teller", "destination": "alice", "amount": 5, "asset": "GEM" }
]
}
This is the design slogan: single-I/O postings, multi-posting transactions. The transaction is the atomic, all-or-nothing unit. Need a multi-input flow (fund a payout from three accounts)? Route them through a transient intermediary account inside one transaction.
The three constraints that enforce correctness
The model is only safe because the engine enforces three invariants on every write:
- Zero ledger-wide balance. Across the whole ledger, total sources = total destinations. The sum of all balances is always exactly zero. Money has to come from somewhere and go somewhere.
- Balanced transactions. Within a single transaction, money in = money out. No transaction can leak value.
- No negative balances — unless an account is explicitly allowed to go negative.
So how does money enter the system at all?
If every transaction must balance and nothing can go negative, how does the first dollar get in? Answer: a special account allowed to be negative — the world account (or any "counter-part" account you mark as overdraftable).
To inject $100 into Alice's account:
source = @world → destination = @alice (amount 100)
Now @alice = +100 and @world = −100. The ledger still sums to zero; world simply holds the negative counter-weight representing "value that originated outside our system." In practice you model real-world counterparts this way — @payment-method:credit-card, @bank:wire — and those negative balances become your reconciliation hooks against external statements. That's not an accident; that's the point.
The immutable log
Underneath, every operation — new transaction, metadata change, everything — is appended to an immutable log. You never UPDATE or DELETE a transaction. Made a mistake? You revert it, which appends a new compensating transaction with opposite postings. The original stays visible forever. This is what makes the system auditable: history is a fact, not a current opinion.
The programmability layer: Numscript
Expressing "send $99, pulling first from the user's main balance, then vouchers, then credit card, and if all else fails from world" as raw JSON postings is tedious and error-prone. Formance ships a small DSL called Numscript for exactly this:
send [USD/2 9900] (
source = {
@users:1234:main
@users:1234:vouchers
@users:1234:creditcard
@world
}
destination = @payments:1234
)
The runtime walks the sources in order, drawing what's available from each until the amount is satisfied, and compiles the whole thing down to balanced postings. (USD/2 means "USD with 2 decimal places," so 9900 = $99.00.) Numscript is declarative and balanced-by-construction — you describe the flow of funds, and it's impossible to express an unbalanced one. The "why" is risk reduction: business logic that moves money lives in a constrained, auditable script instead of scattered across application code.
The API design contract
Before we draw boxes, let's pin down the contract. Everything is versioned under /api/ledger/v2, and a ledger name is always in the path — that path segment is the unit of isolation we'll exploit for scaling later.
| Method & path | Purpose | Notable codes |
|---|---|---|
| POST /api/ledger/v2/{ledger} | Create a ledger (optionally pick a bucket and features, e.g. HASH_LOGS) | 204 created · 400 bad config |
| POST /api/ledger/v2/{ledger}/transactions | Commit a transaction — the hot path | 200 committed · 400 validation · 409 conflict (duplicate reference) |
| POST /api/ledger/v2/{ledger}/transactions/{id}/revert | Append a compensating transaction (?atEffectiveDate=true, ?force=true) | 201 reverted · 400 insufficient funds |
| GET /api/ledger/v2/{ledger}/accounts | List/filter accounts (wildcards like users:*:main) | 200 |
| GET /api/ledger/v2/{ledger}/accounts/{address} | Point query one account | 200 · 404 |
| GET /api/ledger/v2/{ledger}/transactions | List/filter transactions | 200 |
| GET /api/ledger/v2/{ledger}/volumes | Aggregated volumes/balances (startTime, endTime, insertionDate, groupBy) | 200 |
| POST /api/ledger/v2/{ledger}/accounts/{address}/metadata | Attach metadata | 204 |
The core write payload. The transactions endpoint accepts either a Numscript program or raw postings — same endpoint, two front-ends onto the same engine:
// POST /api/ledger/v2/main/transactions
// Header: Idempotency-Key: 8f3c... (safe retries)
// Header: Content-Type: application/json
{
"script": { // OPTION A: Numscript
"plain": "send [USD/2 3000] (source = @user:alice destination = {90% to @merchant:bob remaining to @platform:fees})",
"vars": {}
},
// ── or ──
"postings": [ // OPTION B: explicit postings
{ "source": "user:alice", "destination": "merchant:bob", "amount": 2700, "asset": "USD/2" },
{ "source": "user:alice", "destination": "platform:fees", "amount": 300, "asset": "USD/2" }
],
"reference": "order_1234", // optional business key → 409 on duplicate
"timestamp": "2024-01-15T10:30:00Z", // optional effective time → bi-temporality
"metadata": { "channel": "checkout" }
}
A successful 200 echoes the committed transaction with its assigned id, timestamp (effective) and insertedAt (request time), plus preCommitVolumes and postCommitVolumes per account so the caller sees balances before and after without a second round-trip.
Two ways to be idempotent, on purpose. This is a money system, so duplicate-suppression is first-class and the API gives you two flavors with different ergonomics:
Idempotency-Keyheader — retry-safe. Replay the identical request and the ledger returns the original response withIdempotency-Hit: true. Replay the same key with a different body and you get aVALIDATIONerror (it hashes the body to detect drift). Best for network-retry logic.referencefield — a business key. Submit a second transaction with an existing reference and it's rejected with a conflict. Best when a real-world entity (an order, a refund) must map to exactly one transaction, and you want a loud error.
A validation failure comes back as a structured error, not a stack trace:
{ "errorCode": "VALIDATION", "errorMessage": "account 'user:alice' has insufficient funds" }
One more contract detail that leaks from the storage layer: amounts are integers in the asset's smallest unit, and the engine can hold integers far larger than a 64-bit float. So clients on weak-integer languages can set Formance-Bigint-As-String: true to receive amounts as strings instead of numbers.
High-level architecture
The pieces, and the deliberate boringness of the choices:
┌─────────────────────────────────────────────┐
HTTP / SDK │ Formance Ledger (Go) │
───────────► │ │
Numscript │ ┌────────────┐ ┌──────────────────────┐ │
or postings │ │ Numscript │ │ Transaction engine │ │
│ │ interpreter│──►│ • validate 3 rules │ │
│ └────────────┘ │ • compute volumes │ │
│ │ • append to log │ │
│ └──────────┬────────────┘ │
│ ┌─────────────────────────┐ │ │
│ │ Query / aggregation API │ │ │
│ └────────────┬────────────┘ │ │
└────────────────┼──────────────┼───────────────┘
▼ ▼
┌────────────────────────────────┐
│ PostgreSQL │
│ • immutable log (append-only) │
│ • accounts_volumes (per a/c, │
│ per asset in/out totals) │
│ ACID = the correctness engine │
└────────────────────────────────┘
- Written in Go, stores everything in PostgreSQL.
- Why Postgres instead of something exotic? Because correctness here is ACID.
COMMIT/ROLLBACKgive atomic, isolated, durable transactions for free, plus row-level locking for concurrency control. Formance leans on Postgres's transactional semantics as its consistency engine rather than reinventing it. The result is immediate consistency — read-after-write always reflects the write, nosleep()hacks needed. - Deploy as a standalone microservice, self-host via a Kubernetes operator, or use managed cloud.
Step 3 — Design Deep Dive
This is where the interesting tradeoffs live.
3.1 Concurrency — the real bottleneck
Recall a balance is destination − source, and those volumes live in an accounts_volumes table keyed by (account, asset). To commit a transaction the engine must:
- Read the balance of the source account(s) — to check the no-negative-balance rule.
- Compute the new volumes.
- Write the updated volumes and append the log entry — inside one Postgres transaction.
Step 3 takes a row-level lock on each (account, asset) row it updates. And here's the catch that defines ledger performance:
Two transactions that touch the same
(account, asset)row must serialize. They cannot commit concurrently.
For most accounts this never matters — they're touched rarely. The problem is hot accounts, and the hottest of all is @world. If every deposit in your system sources from @world, then every deposit contends on the same row and your throughput collapses to "one at a time," no matter how many CPUs you have.
This is why the architecture is described as multi-ledger, single-writer, sequential writes, optimized for ~1,000 writes/sec on commodity storage. The single-writer, sequential nature is what guarantees a clean, auditable, reason-about-able trail — but it means write throughput is gated by lock contention on hot rows, not by hardware.
Mitigations (and why each works):
| Technique | Why it helps |
|---|---|
| Shard the hot account. Use @world:<random_id> across a pool of ~20 accounts instead of one @world. | Spreads writes across many rows → Postgres parallelizes the updates. You trade one hot row for 20 warm ones. |
| Spread across assets. Locks are per (account, asset). | Different assets on the same account don't contend. Asset becomes a free parallelization axis. |
| Use multiple ledgers (segmentation). | Different ledgers are fully independent → write to them in parallel. This is the horizontal scaling story. |
3.2 Tamper-evidence vs. throughput: log hashing
To make the log not just immutable-by-policy but tamper-evident, Formance can hash each log entry into a chain (each entry's hash depends on the previous one), so anyone can later prove no entry was altered or sneaked in.
But computing that chain requires a Postgres advisory lock on the entire ledger (you must serialize to chain hashes in order). That's a ledger-wide serialization point — strictly worse for throughput than per-row locks.
So this is exposed as a tunable via a HASH_LOGS feature flag:
SYNC(default-ish): full cryptographic tamper-evidence, lower throughput.ASYNC: chain the hashes off the critical path → no advisory lock on commit → much higher throughput.DISABLED: no hashing at all → highest throughput, but you give up the cryptographic proof.
Why expose this instead of picking for the user? Because the right answer is domain-specific. A regulated core ledger wants SYNC. A high-volume internal points system might happily take DISABLED. The engine refuses to make that risk tradeoff on your behalf.
3.3 Keeping reads cheap as history grows forever
The append-only log grows without bound, so the design fights to keep query cost sub-linear in total history. Here's the cost model (N = relevant entries, W = write cost, M = a small factor):
| Operation | Cost | Reading |
|---|---|---|
| Commit a transaction | O(N) + W | N = number of source accounts read; W = the write itself |
| Aggregate balances | O(log(N) · M) | Indexed, not a full scan |
| Point query (one account / one tx) | O(1) | |
| Range query (accounts / txs) | O(log(N)) | Index-backed |
The lever that keeps reads scalable is the chart of accounts — how you name accounts. Accounts use colon-separated segments, and you should design them so wildcards can do the work:
- Bad:
users:1234_main— the_mainis welded on; you can't slice it. - Good:
users:1234:main— now you can queryusers:1234:*(all of one user's accounts) orusers:*:main(every user's main account) efficiently.
Account naming is a performance decision. The schema of your addresses determines which queries are cheap.
3.4 Scaling out: segmentation and buckets
Since a single ledger tops out around 1K writes/sec and grows forever, the horizontal-scale answer is multiple ledgers plus buckets for physical isolation:
- Segment by entity: group N users per ledger.
- Segment by time: a ledger per year, then per month as volume climbs.
- Buckets give compliance-grade data separation and let enterprises scale storage independently per tenant.
The tradeoff: cross-ledger queries are now your problem to fan out and stitch together. You buy linear write scaling at the cost of giving up a single global query surface. For ledgers, that's usually the right trade — segments map naturally onto tenants, regions, or time periods you'd want isolated anyway.
3.5 Bi-temporality — time as a first-class primitive (Ledger v2)
Real financial data arrives late, out of order, and needs correcting. A payment that happened Monday might only be recorded Wednesday. A naive single-timestamp ledger can't represent this, so Ledger v2 makes every transaction carry two timestamps:
- Request time — when the transaction was submitted (wall clock).
- Transaction (effective) time — when it's considered to have occurred.
This unlocks four things that are otherwise miserable: time-travel queries ("balance as of last Tuesday"), error correction via backdating, point-in-time auditing, and clean data import from systems with their own timestamps.
A delightful consequence: the ledger's notion of "now" is the latest transaction time, not the machine clock. Insert only a postdated transaction (effective tomorrow) and the ledger's present jumps to tomorrow. Insert only backdated ones and its present sits in the past.
The hard part — validating backdated writes. If you insert a transaction into the past, it can retroactively break the future. Suppose an account's balance over time was 100 → 50 → 40 → 90 → 80. Now you backdate a −100 at time 2. The engine doesn't just check that step; it replays the backdated transaction plus every transaction after it and checks the final state. If any account ends up illegally negative, the whole insert is rejected. (Intermediate negatives are tolerated; only the final state must be valid — and accounts explicitly allowed to overdraft are exempt.)
Visually, the backdated insert splits the timeline and forces a re-derivation of everything downstream of the insertion point:
ORIGINAL TIMELINE (effective time →)
t1 t2 t3 t4 t5
+100 −50 −10 +50 −10
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│100 │──▶│ 50 │──▶│ 40 │──▶│ 90 │──▶│ 80 │ final = 80 ✓
└────┘ └────┘ └────┘ └────┘ └────┘
INSERT a backdated −100 at t2 ──┐ (request time = now, effective time = t2)
▼
┌─────────── split here; replay t2…t5 ───────────┐
t1 │ t2' t2 t3 t4 t5 │
+100 │ −100 −50 −10 +50 −10 │
┌────┐ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│100 │─┼▶│ 0 │──▶│−50 │──▶│−60 │──▶│−10 │──▶│−20 │ │ final = −20 ✗ REJECT
└────┘ │ └────┘ └────┘ └────┘ └────┘ └────┘ │
└────── intermediate negatives are OK; only ──────┘
the recomputed FINAL state is checked
The validation rule is "the final recomputed balance must be legal," not "every step along the way must be legal." That single sentence is the whole design: it makes correction possible (you couldn't backdate anything if every intermediate state had to hold) while still refusing any insert that would leave the account in an impossible end state. The machinery that maintains those downstream numbers is effective volumes — and because a single backdated insert can dirty every later transaction, recomputing them is O(transactions after the insertion point), which is exactly why it's a feature flag you enable only when you need it.
Effective volumes are the supporting machinery: per-account running totals computed at effective time rather than insertion time. Insert a backdated transaction and the effective volumes of every subsequent transaction must be recomputed. That's powerful but costs writes proportional to how many later transactions are affected — so it too is a feature flag (MOVES_HISTORY, MOVES_HISTORY_POST_COMMIT_EFFECTIVE_VOLUMES) you turn on only if you need it.
Reverts respect time too. Reverting transaction #2 with atEffectiveDate=true places the compensating entry at #2's original effective time, so historical reports stay correct. Do it without that flag and the reversal lands "now," silently corrupting any point-in-time report between then and now. (There's also a force=true to push a revert through even when it would create a negative balance — useful for liability-style accounts that live negative by design.)
3.6 Handling exotic assets and giant numbers
Because the ledger is asset-agnostic, it has to handle a single ETH (18 decimals) sitting next to USD (2 decimals) sitting next to whole-number loyalty points. Amounts are stored as integers in the asset's smallest unit (hence USD/2), and Postgres can hold integers up to 131,072 digits — absurd overkill that means no crypto amount will ever overflow. The catch is on the client side: JavaScript's number type can't represent these, so the API offers a Formance-Bigint-As-String: true header to return big integers as strings. The storage is unchanged; it's purely a presentation accommodation for languages with weak integer types.
3.7 The life of a transaction — end-to-end write path
Let's trace one POST /transactions carrying a Numscript split from the millisecond it arrives until downstream systems hear about it. Every numbered stage maps to a decision we've already justified — this is where they assemble into a single path.
CLIENT
│ POST /api/ledger/v2/main/transactions
│ Idempotency-Key: 8f3c… body: { script: "send [USD/2 3000] (…90%…remaining…)" }
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ ① API layer (Go) │
│ • authn/authz, route to ledger "main" │
│ • Idempotency-Key lookup → HIT? return stored response + Idempotency-Hit│
│ MISS → continue │
├──────────────────────────────────────────────────────────────────────────┤
│ ② Numscript interpreter │
│ • parse + type-check the script (reject malformed BEFORE touching DB) │
│ • resolve sources in order, expand %-splits → concrete balanced postings│
│ → [ alice→bob 2700 ] [ alice→fees 300 ] │
├──────────────────────────────────────────────────────────────────────────┤
│ ③ BEGIN (Postgres transaction) │
│ • SELECT … FOR UPDATE on each (account, asset) row touched │
│ → acquires row locks; concurrent writers to @user:alice/USD QUEUE │
│ • read balances of the source account(s) │
├──────────────────────────────────────────────────────────────────────────┤
│ ④ Validate the 3 invariants │
│ • transaction balanced? in == out │
│ • no illegal negative balance? (alice has ≥ 3000) │
│ • (if backdated) replay-and-check final state │
│ fail → ROLLBACK → 400 VALIDATION (nothing written, locks released) │
├──────────────────────────────────────────────────────────────────────────┤
│ ⑤ Append + update (same DB txn) │
│ • INSERT one immutable log entry (NEW_TRANSACTION) │
│ • UPDATE accounts_volumes: bump source/destination counters │
│ • if HASH_LOGS=SYNC: take ledger advisory lock, chain the hash │
│ (ASYNC/DISABLED skip this serialization point) │
├──────────────────────────────────────────────────────────────────────────┤
│ ⑥ COMMIT → ACID makes it durable & atomic; row locks released │
│ • store (Idempotency-Key → response) for future retries │
├──────────────────────────────────────────────────────────────────────────┤
│ ⑦ Publish COMMITTED_TRANSACTIONS event (post-commit) │
│ • to Kafka and/or an HTTP webhook, per PUBLISHER_* config │
│ • payload carries postings + pre/postCommitVolumes + metadata │
└──────────────────────────────────────────────────────────────────────────┘
│ 200 OK { id, timestamp, insertedAt, postCommitVolumes… }
▼
CLIENT downstream consumers (analytics, reconciliation, notifications)
A few things worth calling out, because they're where the "why" pays off:
- Validation happens at two depths, cheap-first. Syntax and type errors die in stage ② before any lock is taken; balance/invariant violations die in stage ④ inside the transaction and trigger a clean
ROLLBACK. You never hold an expensive lock while discovering the script doesn't parse. - The lock window is steps ③–⑥. Everything contending on
@user:alice / USDwaits across exactly that span — which is why shrinking it (async hashing) or avoiding it (sharded@world) is the entire throughput game. - The event is emitted only after commit. Consumers are guaranteed to hear about transactions that truly happened, never phantom ones from a rolled-back attempt. (Note: a correction to a common assumption — Formance ships Kafka and HTTP webhook publishers out of the box, not NATS. The event type for new transactions is
COMMITTED_TRANSACTIONS.) - Idempotency wraps the whole thing. The key is checked at ① and persisted at ⑥, so a client that times out and retries re-enters at ① and gets the original committed result back — no double-spend.
Step 4 — Wrap Up
Let's connect every decision back to its cause. This is the table worth remembering:
| Problem | Formance's answer | Why it works |
|---|---|---|
| Mutable balances lose history & can be corrupted | Store volumes, derive balances; append-only log | Balance is recomputed, never edited → auditable by construction |
| Money created/destroyed by accident | Source/destination model + 3 invariants | Add-only unsigned counters + balanced-transaction rule make leakage structurally impossible |
| Money has to originate somewhere | world / overdraftable counter-part accounts | Ledger still sums to zero; negatives become reconciliation anchors |
| Complex flows are error-prone in app code | Numscript DSL | Declarative, balanced-by-construction money movement |
| Need bulletproof atomicity & consistency | PostgreSQL ACID | COMMIT/ROLLBACK + row locks; immediate consistency for free |
| Hot-account write contention | Account sharding, per-asset locks, multi-ledger | Spread writes across rows/ledgers to escape serialization |
| Tamper-evidence costs throughput | HASH_LOGS flag (SYNC/ASYNC/DISABLED) | Lets each user pick their point on the security↔speed curve |
| Log grows forever | Segmentation + smart chart of accounts | Keeps queries sub-linear; scales writes horizontally |
| Real money data is late & out of order | Bi-temporality (request vs. effective time) | Time-travel, backdating, correction, clean imports |
| Retries must not double-spend | Idempotency-Key header + reference field | Silent safe replay vs. loud business-key conflict |
| Other systems need to react to money moving | COMMITTED_TRANSACTIONS events (Kafka / webhook) | Emitted only post-commit → consumers never see phantom transactions |
The meta-lesson
If you remember one thing: a financial ledger trades raw performance for provable correctness, and then claws performance back at the edges where it's safe to. The single-writer, sequential, append-only core is "slow" on purpose — that's what makes it trustworthy. Every scaling feature (sharded accounts, multiple ledgers, async hashing, optional effective volumes) is a carefully bounded escape hatch that lets you buy back throughput without touching the correctness guarantees at the center.
That ordering — correctness first, then performance as a tunable — is the whole philosophy. It's the opposite of how you'd design a cache, and exactly how you should design something that holds people's money.
Quick glossary
- Posting — one simple move: amount of an asset from one source to one destination.
- Transaction — an atomic bundle of postings; all commit or none do.
- Volumes — unsigned per-account in/out totals; the stored facts.
- Balance —
destination − source; derived, never stored. world— special account allowed to go negative; how value enters the system.- Numscript — Formance's DSL for expressing balanced flows of funds.
- Bi-temporality — every transaction has both a request time and an effective time.
- Segmentation / buckets — splitting into multiple ledgers/stores for horizontal scale and isolation.
- Idempotency-Key / reference — two duplicate-suppression mechanisms: silent retry-safe replay vs. loud business-key conflict.
COMMITTED_TRANSACTIONS— the post-commit event published to Kafka / HTTP webhook for downstream consumers.
Keep reading
Jun 11, 2026
When Comfort Is the Symptom: Rethinking AI Sycophancy
Asked to critique one paper like a collaborator, I landed on an uncomfortable conclusion — the most dangerous harm an AI can do might be the one that feels like help.
Jun 6, 2026
Catching a Container in the Act: An eBPF Intrusion Detector for Kubernetes
My undergraduate thesis journey — a year of kernel tracing, broken signatures, and 700 simulated attacks that taught me the honest answer is usually more interesting than the clean one.