Skip to content

ADR 0007 Double Entry Invariant

Tiana_ edited this page May 30, 2026 · 2 revisions

ADR-0007: Double-entry invariant enforced at three layers including database

Status: Accepted Date: 2026-04-25 Decider: Maintainer

Context

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:

  1. Application layer only (constructor + service check): catches developer bugs but bypassed by direct DB writes, maintenance scripts, or future code paths
  2. Database trigger only: mathematically robust but late (catches at COMMIT time, after compute work) and gives terse error messages
  3. All three layers (defense in depth)

Naive approach is to pick one layer. We pick all three.

Decision

Enforce the SUM(entries.amount) = 0 per (transaction_id, currency) invariant at three layers:

Layer 1: Domain layer (Kotlin entity constructor)

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"
            }
        }
    }
}

Layer 2: Service layer (extra guard before persistence)

@Transactional
fun post(cmd: PostTransactionCommand): Transaction {
    require(cmd.satisfiesDoubleEntryInvariant()) { "..." }
    val tx = Transaction.create(cmd)
    transactionRepo.save(tx)
    entryRepo.saveAll(tx.entries)
    return tx
}

Layer 3: Database trigger (deferred constraint)

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
    }
})

Consequences

Positive

  • 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

Negative

  • 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.

Neutral

  • 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.

Alternatives considered

Application layer only

  • Rejected: any bypass (reflection, deserialization, future code path, maintenance script) silently corrupts
  • "Trust the application code" is the wrong default for fintech

DB trigger only

  • Rejected: error messages are generic; developer feedback loop is slow
  • Skipping domain check = bugs caught only at integration test (slow)

Service layer only

  • Rejected: service layer is bypassable too (entity created via reflection, deserialization, or different code path)

Schema-level constraint without trigger

  • Rejected: PostgreSQL doesn't support cross-row CHECK constraints natively
  • Could fake with materialized check column, but trigger is cleaner

Event-sourced approach (events as primary state)

  • 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

Validation

  • 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

Related

Clone this wiki locally