Busflow Docs

Internal documentation portal

Skip to content
Reviewed 17 May 2026

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 โ€‹

RelationshipFK TypeRationale
Intra-aggregate (root โ†” child)Hard FKParent and child share a transactional boundary. DB-level integrity enforces containment.
Cross-aggregate, lifecycle conflictSoft ID referenceAggregates evolve independently. Hard FKs create coupling (cascade, delete-blocking, version-lock contention).
Cross-aggregate, stable context referenceHard FKReferenced entity is never deleted (status lifecycle only). No cascade risk. DB-level integrity catches application bugs.
Cross-schemaSoft ID referenceExisting ยง2 rule. Unchanged.
Self-referential version chainHard FKIntra-aggregate version linking (e.g., price_matrices.superseded_by).

Lifecycle Conflict Criteria โ€‹

A cross-aggregate reference has a lifecycle conflict when any of:

  1. The referencing and referenced aggregates transition through independent status machines (e.g., Invoice DRAFT โ†’ VOIDED vs. FinancialLedger OPEN โ†’ CLOSED)
  2. The child collection is unbounded (e.g., Message within Conversation)
  3. An ADR explicitly mandates independent lifecycles (e.g., ADR-006 for PriceMatrix โ†” CostingSheet)
  4. The referencing entity snapshots the referenced data at creation and does not need the live reference (e.g., Invoice JSONB snapshots)
  5. 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) โ€‹

ColumnSchemaLifecycle Conflict
price_matrices.costing_sheet_idBackofficeADR-006 independent lifecycle
price_matrices.tour_departure_idBackofficeNullable; PriceMatrix outlives departure context
allotments.supplier_idBackofficeAllotment has own lifecycle (ACTIVE โ†’ CONSUMED/EXPIRED)
template_boarding_point_assignments.boarding_point_idBackofficeLibrary item archival independence
template_ancillary_assignments.ancillary_catalog_item_idBackofficeLibrary item archival independence
invoices.financial_ledger_idCommerceInvoice lifecycle decoupled from ledger lifecycle
invoices.booking_idCommerceInvoice JSONB-snapshots booking data at generation
checkout_sessions.booking_idCommerceIndependent TTL lifecycle
messages.conversation_idCommunicationsUnbounded collection; independent webhook lifecycle
messages.channel_account_idCommunicationsChannel SUSPENDED while messages receive callbacks
conversations.contact_idCommunicationsContact 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.

Internal documentation โ€” Busflow