Skip to main content

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:

  1. Status column valuesbilling_account.status, credit_reservation.status, credit_purchase_order.status, credit_bucket.status are all TEXT columns with no CHECK (status IN (...)) constraint.
  2. Foreign key relationshipscredit_bucket.billing_account_id, credit_reservation.bucket_id, credit_ledger.billing_account_id, etc. have no REFERENCES constraint.
  3. Mutual exclusivitybilling_account.owner_user_handle and owner_org_handle are not enforced as mutually exclusive at the schema level.
  4. 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 (...)) or ENUM types
  • Foreign key references — no REFERENCES constraints
  • Business-rule invariants — no cross-column CHECKs encoding domain logic
  • Mutual exclusivity between columns

Rationale

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

  2. Zero-downtime schema evolution. Adding a new status value (e.g., a future REFUNDED state on purchase orders) requires only a code change and deployment. With CHECK constraints, it requires an ALTER TABLE ... DROP CONSTRAINT / ADD CONSTRAINT migration that takes locks on high-traffic tables.

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

  4. 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 meaningless
  • CHECK (bucket_expiry_days > 0) — zero-day expiry is meaningless
  • CHECK (credit_balance >= 0) — balance cannot go negative
  • CHECK (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 when with 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.