writing / architecture
2026-05-1914 min readessay № 042v 1.2 · edited 2 days ago

The ledger is the product: building primitives you can’t fork later.

// On the strange weight of the first abstraction you ship to billing, ops, and accounting — and why the rest of your product will quietly sit on top of it for years.

Most product teams I’ve worked with treat their ledger— the table that records every state change involving money, balance, credits, points, or counts — as a technical detail. A schema decision. Something the senior backend engineer should figure out before sprint planning ends.

This is roughly the most expensive mistake a startup can make.

The ledger is not a schema. The ledger is the product. It is the single artifact your entirecompany will end up reasoning about — finance, ops, support, compliance, growth, your CTO at 2am, the auditor in year three. Once it’s in production, it does not get rewritten. It gets migrated around, gently, like an unexploded shell.

The four properties

A ledger that survives the first three years of a product has four properties. In rough order of how often they are violated:

  • Append-only. You do not update rows. You write a new one that supersedes the previous state. Updates are how you lose money in court.
  • Idempotent on the producer.Every write carries a key the caller chose, and re-submitting it returns the same outcome. Not the same row — the same outcome.
  • Reconstructable.Given the rows, you can derive every downstream view (balances, projections, invoices) deterministically. If you can’t replay it, you don’t have a ledger; you have a log.
  • Causally complete. Every row knows what caused it. Cause is a row, not a string in metadata.notes.

I’ll come back to each one. First, the shape.

The shape, in 30 lines of SQL

-- The boring version. Run it. Read it. Don’t skip it.create table ledger_entries ( id            uuid        primary key default gen_random_uuid(), tenant_id     uuid        not null, account_id    uuid        not null, kind          text        not null, -- 'debit' | 'credit'amount        numeric(20,4) not null, currency      char(3)     not null, posted_at     timestamptz not null default now(),-- causalitycause_kind    text        not null, -- 'invoice' | 'refund' | ...cause_id      uuid        not null, request_key   text        not null,-- supersessionreverses      uuid        references ledger_entries(id),unique (tenant_id, request_key) );

Two things to notice.

First, there is no balance column. You compute balances. You do not store them on the entity. The minute you store the balance, you have two sources of truth — the rows and the field — and your job for the next four years is keeping them in sync. Don’t.

If you find yourself wanting a cached balance, build a projection table. Treat it as derived. Re-derivable. Delete-and-rebuild safe. The day you can’t rebuild it from ledger_entriesalone is the day you have a bug you won’t find for nine months.

Second, reverses is a pointer, not a status. A refund is not an entry whose statusflipped to ‘refunded’. A refund is a brand-new entry that points at the original. The original is unchanged. Forever. Your future-self will thank you when the auditor asks for the state of the world at 14:32:11.

Idempotency, properly

Most teams I review get this 80% right and pay for the last 20%. The 80% looks like:

POST /v1/charges Idempotency-Key: "c_abc123"→ if a row with that key exists, return it. → if not, write a new one with the key.

This works until two requests with the same key race each other into the same transaction, the second one wins the unique-constraint lottery, and your client — who was nervous and retrying — gets back a 500 instead of the row from attempt 1. Now they retry again with a fresh key.

Three rules to make this stop:

  • The key lives in the request envelope, not the body. It survives serialization.
  • On unique-violation, you re-read the existing row and return that. You do not return an error.
  • The key has a TTL that’s longer than your slowest dead-letter retry window. Three days, usually. Not three hours.

The migration trap

Here is the thing nobody tells you: you will need to migrate your ledger schema in year two. You will discover you need a new currency, or a new account className, or per-line tax breakdowns, or sub-ledgers per legal entity. And you will not be allowed to rewrite history.

This is fine, if you treat kind and cause_kind asopen enums and your projections as recomputable. It is catastrophic if you have JOINs three layers deep that assume the 2023 shape of the world.

The trick: never let business logic read raw rows. Always read through a typed accessor that knows about the schema version a row was written in. The accessor is allowed to be ugly. The business logic is not.

What this buys you

When we rebuilt Lattice’s ledger from the patterns above:

  • Reconciliation went from a nightly cron + 14 hours of human spreadsheet to a query that runs in 240ms and is correct by construction.
  • Refunds, partial refunds, disputes, and chargebacks — five different code paths in the old system — collapsed to one: write a reversing entry.
  • Our compliance review took three days instead of three weeks because we could literally hand the auditor an SQL view and a printed schema.
  • Most importantly: the next two product teams that built on top of this never asked us “is this number correct?”

The closing argument

The reason this matters — the reason I’m writing 3000 words about a table — is that the ledger is one of maybe four primitives in your system that you genuinely cannot replace. You can replace your front end. You can replace your auth provider. You can swap React for Solid for HTMX for whatever next year’s discourse demands. You can’t replace the table that says who owes whom what.

So: take a Friday. Sketch it. Argue about it. Get the four properties right. Then go ship the rest of your product on top.

// Thanks to Priya, Daniel, and Jenna for reading drafts of this. Pre-existing literature worth your time: Pat Helland on immutability, Martin Kleppmann ch. 11, and Stripe’s public engineering blog on idempotency.


// If you found this useful, the next one lands in your inbox Sunday. Subscribe →