Notes · · 3 min read
Put the invariant in the database
Deedstead keeps books for portfolios split across several LLCs. The rule that mattered most was simple: posted financial history must not change. Here is why that rule lives in PostgreSQL rather than in the application.
The application is only one of the ways data reaches a database. A migration, a console session, a support script or a bug in a second service can all write the same table. If the only guard is a form validation or a service method, the guarantee is really “this path is careful”, not “this cannot happen”. For a ledger, that difference is the whole product.
A rule in the application is a rule with exceptions
Most software treats validation as a property of the interface. The form checks the field, the service checks the permission, and the database stores whatever it is given. That is a reasonable default for preferences and profiles. It is the wrong default for anything a customer will later have to defend to an accountant, an auditor or a bank.
A financial history has one non-negotiable property: a posted entry means what it said when it was posted. Not after a deploy, not after a migration, not after someone opens a console at midnight to fix a support ticket.
Balance is enforced where the write happens
A journal entry whose debits do not equal its credits is not a data-quality issue; it is a corrupted book. Deedstead enforces balance in the database, so an unbalanced entry is refused whether it arrives from the interface, a script or a console.
The same principle covers the smaller rules. A line cannot be added to a posted entry. An entry cannot be edited or deleted after posting. Audit rows reject updates and deletes at the trigger level, underneath the application. At the time of writing, the product's public page reports 116 database guards and 86 distinct refusals, exercised by 165 passing tests.
Corrections, not edits
Accountants do not erase mistakes; they reverse them. A reversing entry leaves both records visible, so the history explains how the balance arrived at its current value. The database refuses to update or delete the original, which means the correction is the only route available.
This is more work for the interface: a correction is a new document, not a field edit. It is also the only version of the feature that survives an audit, because the question is never “what is the balance?” but “show me how it got there”.
A closed period stays closed
Closing a period is a promise: the numbers for March will not move in June. The database refuses writes dated on or before a closed period. Reopening is possible, requires a reason, and leaves an append-only record of who did it.
Without that, closing the books is a report rather than a state. The moment a later write can land in an earlier period, every report generated from it becomes a draft.
Reconciliation locks its evidence
A completed bank reconciliation locks the statement and every ledger line it was proved against. If the evidence could change afterwards, the reconciliation would only describe a moment that no longer exists. Locking the evidence is what turns a matching exercise into a record.
What it costs
Database-enforced rules are harder to change than application code. Triggers need migrations, refusal messages have to stay understandable to the person reading them, and every guard needs a test that tries to break it. That is why the test count matters more than the trigger count: the tests exercise the rules from the outside, not just through the happy path.
The trade is deliberate. Application code can be bypassed; the database is the last place every write has to pass. For a system whose output is financial history, that is the right place to be strict.
The test is not whether the interface can post an entry. It is whether anything else can post a bad one.
Built while working on
Deedstead — read the case study