Why I enforce my ledger's invariant twice

The naive approach
When I started thinking about how to track balances, the first thing I reached for was a single UPDATE:
UPDATE accounts SET balance = balance + 100 WHERE id = 42;
It works. Until it doesn't.
Two requests hit the same account in the same millisecond — both read the old balance, both write back, ₹100 disappears.
A client retries a network-failed request, and you've recorded the same payment twice. Someone asks "what was the balance on March 15?" — and there's no way to answer, because the column has been overwritten thousands of times.
By the time you notice, the books are wrong and you can't tell which transaction broke them.
The 500-year-old fix
The fix is older than computers. It's called double-entry bookkeeping, and the rules were written down in 1494 by Luca Pacioli, a Venetian friar — though merchants in Italy had been using them for at least a century before that. They're still the foundation of modern accounting.
The idea: never store balances. Store events. Every event is two or more lines that together net to zero.
When a guest pays ₹10,000 for an Airbnb booking, you don't update one account's balance. You record four lines:
₹10,000 in to Guest Payments Received
₹8,500 out to Host Payable (what the platform owes the host)
₹1,300 out to Commission Revenue
₹200 out to GST Payable
In = ₹10,000. Out = ₹10,000. The transaction balances.
Balances aren't stored anywhere — they're computed by summing the line items for an account. Nothing is overwritten. "What was the balance on March 15?" becomes a query, not a mystery.
The invariant is one sentence: Σ in = Σ out per currency, always, every transaction. If a transaction doesn't balance, the books are corrupt. So you don't let it.
What I'm building
Over the next 6 weeks, I'm building a double-entry ledger in public. It's an HTTP API in Go, backed by Postgres, with a small Airbnb-style marketplace demo on top later. The repo is at github.com/rithvikronaldo/stayfair.
The whole API is one core endpoint: POST /transactions. Here's the booking from above as a real request body:
{
"description": "Booking #B001 confirmed",
"occurred_at": "2026-04-21T14:32:00Z",
"entries": [
{"account": "guest_payments", "amount": 1000000, "currency": "INR", "direction": "in"},
{"account": "host_payable", "amount": 850000, "currency": "INR", "direction": "out"},
{"account": "commission", "amount": 130000, "currency": "INR", "direction": "out"},
{"account": "gst_payable", "amount": 20000, "currency": "INR", "direction": "out"}
]
}
When a request arrives, it passes through three layers. An HTTP handler parses the JSON and checks for an Idempotency-Key replay. A domain layer validates that the entries balance per currency. A database layer writes the transaction and its entries atomically inside a single SQL transaction — if any step fails, the whole write rolls back. Amounts are stored as BIGINT minor units (paise, cents) — never floats, never NUMERIC.
What I deliberately didn't build: a payment processor, a billing engine, user auth, FX conversion (yet), or a chart-of-accounts editor. This is the ledger primitive — the layer that records what happened. Everything else is someone else's job.
Two-place enforcement
The natural place to put the balance check is in application code, before any database write. Here's the core of mine in Go (full file: internal/ledger/invariant.go):
func CheckBalanced(entries []Entry) error {
sums := map[string]*totals{}
for _, e := range entries {
t := sums[e.Currency]
if t == nil {
t = &totals{}
sums[e.Currency] = t
}
switch e.Direction {
case DirIn:
t.in += e.Amount
case DirOut:
t.out += e.Amount
}
}
for cur, t := range sums {
if t.in != t.out {
return ImbalanceError{
Currency: cur,
In: t.in,
Out: t.out,
}
}
}
return nil
}
It groups entries by currency, sums in versus out per currency, and returns a structured ImbalanceError if any currency is off. The HTTP handler runs it before any DB write and returns a 422 response with the imbalance details on failure.
Good enough? No.
Application code can have bugs. A future refactor might add a path that skips this check. A teammate might run a migration script with raw SQL. Someone might use psql to manually fix data and forget to keep the entries balanced. Each of those is a path where the rule could be violated and the books quietly corrupted.
So I added a second copy of the rule inside Postgres, as a constraint trigger that fires when entries rows are inserted
(full file: migrations/002_invariant_trigger.up.sql):
CREATE CONSTRAINT TRIGGER entries_balanced_check
AFTER INSERT ON entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION check_transaction_balanced();
The function it calls runs a single SELECT that groups entries by (transaction_id, currency) and uses HAVING SUM(in) <> SUM(out) to find any unbalanced group. If one is found, it raises an exception, which fails the COMMIT and rolls back the whole transaction.
The interesting words are DEFERRABLE INITIALLY DEFERRED. A normal AFTER INSERT trigger fires after every row, but a 4-entry transaction inserts 4 rows, and after the first one nothing balances yet. A normal trigger would reject every valid transaction. The deferred kind waits until COMMIT, when all the rows are in, and validates the whole group at once. It's the only way to enforce a cross-row invariant via a trigger.
The Go check fails fast with a friendly error. The Postgres trigger is the last line of defense — if Go is wrong, the database is still right. Same algorithm, two layers, two different jobs.
The proof
Two-place enforcement only matters if the second layer actually works independently of the first. So I wrote a test that deliberately bypasses my own Go code.
It opens a raw SQL connection, starts a transaction, inserts a transactions row, then inserts entries that obviously don't balance — 100 IN and 50 OUT — and tries to commit:
_, err = tx.Exec(ctx, `
INSERT INTO entries (transaction_id, account_id, amount, currency, direction)
VALUES (\(1, \)2, 100, 'INR', 'in'),
(\(1, \)2, 50, 'INR', 'out')
`, txID, accountID)
// Inserts succeed because the trigger is DEFERRED.
err = tx.Commit(ctx)
The test asserts that COMMIT fails with ENTRIES_UNBALANCED.
Run output:
=== RUN TestTriggerRejectsUnbalancedRawInsert
trigger correctly rejected commit:
ERROR: ENTRIES_UNBALANCED: transaction=1c06730e... currency=INR in=100 out=50 diff=50 (SQLSTATE 23514)
--- PASS
The Go code in internal/ledger/repo.go is never called. None of my application logic runs. The bytes go straight to the database, the trigger fires at COMMIT, and the database refuses.
That test is the contract the database makes with me: when my code is wrong, you say no. The books cannot be corrupted by any code path I write today, or any code path I add tomorrow.
What's next
This is week 1 of 6. The repo is at github.com/rithvikronaldo/stayfair — the Week 1 code is tagged week-1 if you want to see exactly what shipped.
Week 2: account balance queries, FX rate handling, and point-in-time reads — "what was the balance of account X on March 15?". The naive answer (replay every entry from the beginning) gets slow at scale, so I'll write about a snapshot-and-delta approach.
I'm posting one of these every Sunday until the 6 weeks are up. Subscribe if you want the next one.
