-
Notifications
You must be signed in to change notification settings - Fork 0
ADR 0007 Double Entry Invariant
Status: Accepted (Layer 1 amended by ADR-0012) Date: 2026-04-25 Decider: Maintainer
Amended in part by ADR-0012 (2026-06-01). The Layer 1 (domain constructor) enforcement is now single-currency per transaction: a
Transactioncarries onecurrency, a currency-consistency guard rejects foreign-currency entries, and the balance check is a singleSUM = 0. The per-currency grouping shown in Layer 1 below is historical. Layer 3 (the database trigger) is unchanged and remains valid as defense in depth.
The mathematical promise of double-entry bookkeeping: for every transaction, in every currency, the sum of all entries' amounts equals zero. Without this, money can be created or destroyed by individual transactions. The trial balance fails. Audit fails. Reconciliation breaks. Customers lose money. Regulators step in.
Where to enforce this invariant:
- Application layer only (constructor + service check): catches developer bugs but bypassed by direct DB writes, maintenance scripts, or future code paths
- Database trigger only: mathematically robust but late (catches at COMMIT time, after compute work) and gives terse error messages
- All three layers (defense in depth)
Naive approach is to pick one layer. We pick all three.
Enforce the SUM(entries.amount) = 0 per (transaction_id, currency) invariant at three layers:
class Transaction(...) {
init {
require(entries.size in 2..1000)
val byCurrency = entries.groupBy { it.currency }
for ((currency, slice) in byCurrency) {
val sum = slice.sumOf { it.amount }
require(sum.compareTo(BigDecimal.ZERO) == 0) {
"entries must sum to zero for currency $currency, got $sum"
}
}
}
}@Transactional
fun post(cmd: PostTransactionCommand): Transaction {
require(cmd.satisfiesDoubleEntryInvariant()) { "..." }
val tx = Transaction.create(cmd)
transactionRepo.save(tx)
entryRepo.saveAll(tx.entries)
return tx
}CREATE OR REPLACE FUNCTION ledger.verify_double_entry_invariant()
RETURNS TRIGGER AS $$
DECLARE
bad_currency TEXT;
bad_sum NUMERIC(38,18);
BEGIN
SELECT currency, SUM(amount)
INTO bad_currency, bad_sum
FROM ledger.entries
WHERE transaction_id = NEW.transaction_id
GROUP BY currency
HAVING SUM(amount) <> 0
LIMIT 1;
IF bad_currency IS NOT NULL THEN
RAISE EXCEPTION
'double-entry invariant violated: transaction=%, currency=%, sum=%',
NEW.transaction_id, bad_currency, bad_sum
USING ERRCODE = '22000';
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER trg_entries_invariant
AFTER INSERT ON ledger.entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION ledger.verify_double_entry_invariant();The keyword DEFERRABLE INITIALLY DEFERRED makes the trigger fire at COMMIT time, after all entries of a transaction are inserted. Without deferral, the trigger fires after each individual INSERT - at which point the partial sum can never be zero (only one entry exists at that moment).
Property test in CI:
class LedgerInvariantPropertyTest : DescribeSpec({
it("never violates SUM=0 invariant under 100 concurrent posters") {
// ... 100 threads × 50 random transactions each
// Assert: all currencies sum to zero across all entries
}
})- Mathematical guarantee: even if domain code has a bug, even if maintenance scripts go awry, the invariant holds. The DB refuses to commit a violation.
- Fast feedback for developers: domain constructor catches bugs at unit-test time, no DB needed
- Better error UX: domain layer produces typed Kotlin exceptions with field-level info; the DB trigger is the last-resort generic message
- Auditor-friendly: invariant verification is in plain SQL, anyone with read access to the schema can verify
- Three places to maintain: a schema change requires updating all three layers consistently
- DB trigger overhead: each transaction commit incurs an extra query for invariant check. Measured at <2ms p99 even with 1000-entry transactions. Acceptable.
- Deferred trigger is Postgres-specific: portability to MySQL or SQLite would require redesign. We're not portable; we're Postgres-specific.
- Some teams pick application-only enforcement and call it good. They're one bypass-bug away from money corruption. Our cost is small; the benefit is mathematical certainty.
- Rejected: any bypass (reflection, deserialization, future code path, maintenance script) silently corrupts
- "Trust the application code" is the wrong default for fintech
- Rejected: error messages are generic; developer feedback loop is slow
- Skipping domain check = bugs caught only at integration test (slow)
- Rejected: service layer is bypassable too (entity created via reflection, deserialization, or different code path)
- Rejected: PostgreSQL doesn't support cross-row CHECK constraints natively
- Could fake with materialized check column, but trigger is cleaner
- Considered: event sourcing makes the invariant trivial because state is built from events
- Rejected: too radical for v0.1, learning-curve too steep
- May reconsider as v2.x option
- Unit test: constructor refuses entries that don't sum to zero
- Integration test: service layer rejects illegal command before DB call
- Integration test: bypass service, insert entries directly via raw JDBC, verify trigger blocks at COMMIT
- Property test (Kotest): 1000 random transaction sequences, invariant holds
- Concurrency test: 100 threads × 50 posts, no race condition violates invariant
- Chaos test: random DB connection drops mid-transaction, no partial state persists
- Domain-Model - Transaction aggregate definition
- Data-Model - full DDL including trigger
- User-Flows#uc-04 - full posting flow including trigger fire
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO