ADR-036: Aggregate-Level FK Policy โ
Status: โ Approved โ 2026-05-17 Impacts: schema-backoffice.md, schema-commerce.md, schema-communications.md, domain-driven-design.md
Context โ
The existing DDD architecture (domain-driven-design.md ยง2) mandates no cross-schema foreign keys โ bounded contexts reference each other via soft ID columns. However, the documentation was silent on FK treatment within a schema when references cross aggregate boundaries.
With the formalization of the aggregate model, the question arises: should cross-aggregate references within the same PostgreSQL schema use hard FKs or soft ID references?
Three options were evaluated.
Decision โ
Hybrid approach. The FK strategy follows aggregate boundaries with a lifecycle-conflict discriminator:
Rule โ
| Relationship | FK Type | Rationale |
|---|---|---|
| Intra-aggregate (root โ child) | Hard FK | Parent and child share a transactional boundary. DB-level integrity enforces containment. |
| Cross-aggregate, lifecycle conflict | Soft ID reference | Aggregates evolve independently. Hard FKs create coupling (cascade, delete-blocking, version-lock contention). |
| Cross-aggregate, stable context reference | Hard FK | Referenced entity is never deleted (status lifecycle only). No cascade risk. DB-level integrity catches application bugs. |
| Cross-schema | Soft ID reference | Existing ยง2 rule. Unchanged. |
| Self-referential version chain | Hard FK | Intra-aggregate version linking (e.g., price_matrices.superseded_by). |
Lifecycle Conflict Criteria โ
A cross-aggregate reference has a lifecycle conflict when any of:
- The referencing and referenced aggregates transition through independent status machines (e.g., Invoice
DRAFT โ VOIDEDvs. FinancialLedgerOPEN โ CLOSED) - The child collection is unbounded (e.g., Message within Conversation)
- An ADR explicitly mandates independent lifecycles (e.g., ADR-006 for PriceMatrix โ CostingSheet)
- The referencing entity snapshots the referenced data at creation and does not need the live reference (e.g., Invoice JSONB snapshots)
- Archival/deactivation of the referenced aggregate should not cascade to the referencing aggregate (e.g., BoardingPointLibrary archival must not delete template assignments)
FKs Converted to Soft References (11) โ
| Column | Schema | Lifecycle Conflict |
|---|---|---|
price_matrices.costing_sheet_id | Backoffice | ADR-006 independent lifecycle |
price_matrices.tour_departure_id | Backoffice | Nullable; PriceMatrix outlives departure context |
allotments.supplier_id | Backoffice | Allotment has own lifecycle (ACTIVE โ CONSUMED/EXPIRED) |
template_boarding_point_assignments.boarding_point_id | Backoffice | Library item archival independence |
template_ancillary_assignments.ancillary_catalog_item_id | Backoffice | Library item archival independence |
invoices.financial_ledger_id | Commerce | Invoice lifecycle decoupled from ledger lifecycle |
invoices.booking_id | Commerce | Invoice JSONB-snapshots booking data at generation |
checkout_sessions.booking_id | Commerce | Independent TTL lifecycle |
messages.conversation_id | Communications | Unbounded collection; independent webhook lifecycle |
messages.channel_account_id | Communications | Channel SUSPENDED while messages receive callbacks |
conversations.contact_id | Communications | Contact de-dup/merge must not cascade |
FKs Retained as Hard (14) โ
All references where the target entity is never deleted (status lifecycle only) and no cascade risk exists. Examples: bookings.tour_offering_id, incidents.service_leg_id, tour_departures.tour_template_id. Full list in domain-driven-design.md ยง2.2.
Consequences โ
Positive โ
- Aggregate boundaries become visible in the schema. Hard FK = intra-aggregate or stable reference. Plain UUID = cross-aggregate with lifecycle conflict.
- No cascade surprises. Archival, versioning, and status transitions in one aggregate never block or cascade into another.
- Uniform pattern. Combined with the existing cross-schema soft FK rule, the system consistently uses soft references wherever lifecycle independence exists.
- Future extraction readiness. If a bounded context splits, soft FK columns require zero schema changes.
Negative โ
- 11 manual Hasura relationships. These FKs no longer auto-generate GraphQL resolvers โ manual metadata entries required (one-time cost, tracked in version control).
- No DB-level referential integrity for 11 columns. Application bugs could create orphaned references. Mitigated by NestJS Action validation and the fact that entities are never physically deleted.
Neutral โ
- No migration cost. The project is greenfield with no production data. Schema docs update immediately; DDL generation follows.
Rejected Alternatives โ
Option A โ Full soft FK (all 25 cross-aggregate FKs) โ
Maximum DDD purity. Rejected because 14 of the references are stable context references (target never deleted, no lifecycle conflict). The DB-level safety net is free for these โ removing it provides no benefit and increases orphan risk.
Option B โ Keep all hard FKs, document boundaries in docs only โ
The aggregates document defines logical boundaries; the schema retains all 25 hard FKs. Rejected because 11 FKs create real coupling friction: cascade-blocking on archival, version-lock contention on immutable-append entities, and cascading delete risk for library items.