ADR-017: Application-Layer Enforcement of Business Constraints
Context
During production readiness review of the entitlement domain (SPEC-026), the question arose whether business constraints should be enforced at the database level (CHECK constraints, ENUM types, foreign keys) or at the application level. The specific constraints in question:
- Status column values —
billing_account.status,credit_reservation.status,credit_purchase_order.status,credit_bucket.statusare allTEXTcolumns with noCHECK (status IN (...))constraint. - Foreign key relationships —
credit_bucket.billing_account_id,credit_reservation.bucket_id,credit_ledger.billing_account_id, etc. have noREFERENCESconstraint. - Mutual exclusivity —
billing_account.owner_user_handleandowner_org_handleare not enforced as mutually exclusive at the schema level. - Business rule invariants — rules like "only PENDING orders can be completed" or "reserved credits cannot exceed balance" are not expressed as CHECK constraints.
Database-level constraints provide a hard safety net but couple schema evolution to business rule changes, complicate zero-downtime migrations (adding/removing a status value requires an ALTER), and push domain logic into two places.
Decision
All business constraints are enforced at the application layer. The database schema enforces only structural integrity:
- Primary keys and unique indexes (identity and idempotency)
- NOT NULL (column presence)
- Data-type constraints (INTEGER, UUID, TIMESTAMPTZ)
- CHECK constraints for physical invariants that are always true regardless of business rules (e.g.,
credits > 0,bucket_expiry_days > 0,credit_balance >= 0,reserved_credit >= 0)
The database does not enforce:
- Status value enumerations — no
CHECK (status IN (...))orENUMtypes - Foreign key references — no
REFERENCESconstraints - Business-rule invariants — no cross-column CHECKs encoding domain logic
- Mutual exclusivity between columns
Rationale
-
Single source of truth for business logic. Status values and their transitions are defined in Kotlin sealed types (
PurchaseOrderStatus,BucketStatus,ReservationStatus). Duplicating these in CHECK constraints creates two sources of truth that must be kept in sync across code and migrations. -
Zero-downtime schema evolution. Adding a new status value (e.g., a future
REFUNDEDstate on purchase orders) requires only a code change and deployment. With CHECK constraints, it requires anALTER TABLE ... DROP CONSTRAINT / ADD CONSTRAINTmigration that takes locks on high-traffic tables. -
Foreign keys inhibit operational flexibility. FK constraints prevent out-of-order data cleanup, complicate bulk operations, and add hidden locking overhead on parent tables during child inserts. The application validates all references before writes; reconciliation catches any drift.
-
Reconciliation as the safety net. Instead of relying on the database to reject invalid states, the system relies on reconciliation processes to detect and flag inconsistencies. This is a conscious tradeoff: the failure mode shifts from "write rejected at DB" to "write succeeds, recon flags it" — which is acceptable for a billing system where auditability and recoverability matter more than prevention.
What stays in the database
Physical invariants that are universally true and never change with business rules are expressed as CHECK constraints:
CHECK (credits > 0)— a grant or order with zero credits is physically meaninglessCHECK (bucket_expiry_days > 0)— zero-day expiry is meaninglessCHECK (credit_balance >= 0)— balance cannot go negativeCHECK (reserved_credit >= 0)— reserved cannot go negative
These are structural, not business. They protect against arithmetic bugs, not policy violations.
Consequences
- Engineers must not add CHECK constraints for status values, FK references, or business rules without revising this ADR.
- Every status enum in the application must have exhaustive handling (Kotlin
whenwith sealed types). - Production readiness reviews will flag missing application-layer validation, not missing DB constraints.
- Reconciliation coverage must exist for all cross-entity relationships that would otherwise be enforced by FKs.