↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-119: Delete Behavior Is an Explicit Business Rule (Restrict by Default)

Status

Accepted (2026-09-11). Inverts EF Core's cascading default for every relationship in every repo built on this framework, and stamps each foreign key with where its delete behavior came from.

Revised 2026-09-19: the rule is adopted end to end. All eight migrations were authored and merged on 2026-09-11 (four in ADC, three in Store, one in Helpdesk), and all three applications subclass the shipped fitness-function base, so the Decision and Trade-offs record adopted state instead of pending work. Revised 2026-10-21: anchors refreshed after MMCA.Common moved the convention registration and the permission-grant and internal-command hard deletes. Revised 2026-10-26: anchors refreshed after MMCA.Common moved the outbox cleanup and permission-grant hard deletes.

Context

EF Core picks a delete behavior for you. A required relationship gets Cascade, an optional one gets ClientSetNull, and neither choice appears anywhere a reviewer reads. The consequence is a database constraint that deletes rows below the aggregate that owns them: below the invariants the aggregate root exists to enforce, below the soft-delete filter, and below the domain events the rest of the system reacts to.

Across the three applications built on this framework, exactly two delete behaviors were ever written down. ADC restricts Session to Room (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionConfiguration.cs:95) and Store cascades Product image data (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Persistence/EntityConfiguration/ProductImageDataConfiguration.cs:35). Every other foreign key in ADC, Store and Helpdesk took EF's default: required relationships cascade, optional ones null out on the client. Those defaults are already encoded in the migrations and are already the shape of the deployed schemas.

The obvious objection is that this is theoretical, and it is worth stating why it is not. These applications soft-delete: entities set IsDeleted and global query filters exclude them (ADR-005), so at runtime today no cascade ever fires from ordinary application code. The genuine hard deletes are narrow and deliberate: the framework's own cleanup jobs against leaf tables (audit trail retention at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailCleanupJob.cs:158-161, refresh sessions at .../Persistence/Auth/RefreshSessionCleanupService.cs:127, permission grants at .../Persistence/Auth/EFPermissionGrantStore.cs:143-146, internal commands at .../Persistence/InternalCommands/Administration/InternalCommandAdministration.cs:223-225 and .../Persistence/InternalCommands/Administration/InternalCommandCleanupService.cs:112 and :147, outbox messages at .../Persistence/Outbox/Administration/OutboxCleanupService.cs:105, :165 and :181), ADC's session-score replacement (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringRunner.cs:170), and Store's product image blob removal (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Services/ProductImageStorageService.cs:101 and :124), which removes ProductImageData rows through the change tracker; the audit interceptor leaves an EntityState.Deleted entry as it is (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:101-102), so that is a real DELETE.

But the FK constraint is not a runtime detail. It is what the migration writes into the database, and it is what would fire the day somebody writes a genuine hard delete, runs a data-repair script, or a DBA cleans a table by hand. A cascade nobody chose is a decision made by a default and discovered by an incident. The question this record answers is not "what happens today" but "what does the schema say we intend".

Decision

Restrict is the default delete behavior for every relationship nobody configured, a cascade is an opt-in with a stated reason, and the finished model records which of the two each foreign key is.

  1. A model-finalizing convention supplies the default. RestrictDeleteByDefaultConvention (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/RestrictDeleteByDefaultConvention.cs:42) walks every declared foreign key on the finalized model (:73-80) and, when the delete behavior has no configuration source or has only EF's own convention as its source (:98), sets DeleteBehavior.Restrict (:101). A behavior anybody configured, fluently or by attribute, is kept exactly as configured: this convention never overrides a decision (:106).

  2. Every foreign key is stamped with where its behavior came from. The annotation MMCA:DeleteBehaviorSource (:48) carries one of Explicit (:51), Convention (:54) or Ownership (:57), so a finished model can be audited without re-running the convention.

  3. Ownership foreign keys are left alone. An owned type has no identity apart from its owner and EF requires that cascade rather than offering it, so the convention stamps Ownership and moves on (:90-94).

  4. Cosmos is a no-op. The provider has no foreign key constraints to restrict, and stamping a delete-behavior decision on a model that cannot enforce one would only make the audit lie (:66-69).

  5. It is registered on the one base context, after the other two finalizing conventions. ApplicationDbContext.ConfigureConventions (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:378) adds it at :400, deliberately last of the three, so it never stamps a relationship the cross-source degrade convention has already removed. Because there is one context class per engine over one abstract base (ADR-006), that single registration reaches every module, every database and every repo.

  6. Two fitness functions read the stamp back off the finished model. DeleteBehaviorConventionTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Domain/DeleteBehaviorConventionTestsBase.cs:21) exposes CascadingDeletes_AreExplicitlyOptedIn (:30) and ConventionDeletes_AreRestricted (:33) over the rule bodies in Rules/Domain/ArchitectureRules.DeleteBehavior.cs: any Cascade or ClientCascade must carry source Explicit (:34), and any foreign key sourced Convention must be Restrict (:65). A subclass supplies dbContext.Model from a context built the way the app builds it, so no database server is needed: the model is what is asserted. All three applications subclass it (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Domain/DeleteBehaviorConventionTests.cs:29, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Domain/DeleteBehaviorConventionTests.cs:22, MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/DeleteBehaviorConventionTests.cs:20), each handing the base one model and covering its remaining service models with an explicit pair of facts (ADC adds Identity, Engagement and Notification; Store adds Sales and Identity; Helpdesk has one database, so it adds none). The base points at relational models only, since a Cosmos model carries no stamps.

  7. A cascade keeps its reason next to it. Opting in means writing .OnDelete(DeleteBehavior.Cascade) in the entity configuration with the business reason beside it, which is what turns "the schema happens to cascade here" into a rule somebody can review.

Rationale

  • The safe default is the one that fails loudly. Restrict turns an unintended parent delete into an error at the point of the delete, where it can be read and fixed. Cascade turns it into missing rows discovered later, with no record of what removed them.
  • Soft delete is a reason to fix this, not a reason to skip it. Because cascades never fire in normal operation, nothing in testing or production would ever have surfaced them. That is exactly the profile of a latent hazard: invisible until the one script that triggers it.
  • The annotation is what makes the rule auditable. Asserting "this model does not cascade" is weak; asserting "every cascade in this model was chosen, and everything the convention touched restricts" is the property that actually holds, and it needs the provenance stamp to be checkable at all.
  • One registration on one base context is the whole adoption story. The per-engine context design (ADR-006) is what makes a persistence-wide behavior change a framework change rather than a per-module sweep, the same way a fourth engine was (ADR-113).
  • Never overriding an explicit configuration keeps the convention additive. The two behaviors that were already written down keep working exactly as written, and are simply stamped Explicit.

Trade-offs

  • One migration per service database, authored and merged on 2026-09-11. The FK constraints change, so each database needed a migration: four in ADC (Conference, Identity, Engagement, Notification), three in Store (Catalog, Sales, Identity) and one in Helpdesk (MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/Migrations/20260911154248_RestrictDeleteByDefault.cs), all eight named RestrictDeleteByDefault. Five of them drop and re-add foreign keys with ReferentialAction.Restrict; three (ADC Identity, ADC Notification, Store Identity) are empty, because those models had no convention-sourced foreign key to flip. MMCA.Common's own tables (outbox, inbox, internal commands, scheduler, audit trail, refresh sessions, permission grants, notifications) declare no relationships between each other (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/DeleteBehaviorConventionTests.cs:21-26), so nothing flips inside the framework and its own gate passes vacuously today. That is the point of a vacuous gate: the day a framework table gains a relationship, the test decides whether its delete behavior was a decision or an accident, before consumers inherit it in a migration.
  • Optional relationships move too, from ClientSetNull to Restrict. A client-side null-out is the third behavior nobody chose either, and it silently rewrites a foreign key column in memory. A relationship that genuinely should null out on delete now has to say so.
  • A real cascade becomes more work to add. Someone who wants one writes a line and a reason instead of inheriting it. That is the intended cost.
  • SQL Server's multiple-cascade-path limit only bites where a configuration opts in. Restrict cannot create a cycle, so the error class that most often forces a schema redesign now appears exactly where a human chose a cascade, which is the one place it is diagnosable.
  • The fitness functions assert the model, not the database. They prove what a migration will write, not what an already-deployed schema contains. The migrations that carry the current models into the schemas were merged on 2026-09-11, so the gap this bullet describes reopens only in the ordinary migrations-pending window between a model change and the migration that follows it.
  • Cosmos-backed entities get no coverage from this rule, by construction. The audit says nothing about them because the model cannot carry the claim.

Revision (2026-10-01)

No decision or rationale changed. The Context list of genuine hard deletes is completed: it now also names the framework's outbox cleanup (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxCleanupService.cs:110, :166, :182), its processed internal-command cleanup (.../Persistence/InternalCommands/Administration/InternalCommandCleanupService.cs:118, :153) and Store's ProductImageData removal (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Services/ProductImageStorageService.cs:101, :124), a change-tracker delete that the audit interceptor passes through unchanged (.../Persistence/Interceptors/AuditSaveChangesInterceptor.cs:87-89). Citations refreshed to the current lines: AuditTrailCleanupJob.cs:161-163, EFPermissionGrantStore.cs:134-136, InternalCommandAdministration.cs:223-225, SessionScoringRunner.cs:155, and ApplicationDbContext.ConfigureConventions at ApplicationDbContext.cs:379 with the registration at :400, still last after the cross-source degrade (:388) and soft-delete unique index (:393) conventions.

Revision (2026-10-06)

No decision, rationale or trade-off changed; the convention, its registration order and every cited hard delete behave as recorded.

  • Anchors re-verified against current source and refreshed in the live sections: RestrictDeleteByDefaultConvention.cs (class :42, walk :73-80, restrict :101, explicit stamp :106, annotation and sources :48/:51/:54/:57, ownership :90-94, Cosmos :66-69), ApplicationDbContext.cs:378 with the registration at :399 (degrade :387, soft-delete unique index :392), AuditTrailCleanupJob.cs:158-161, RefreshSessionCleanupService.cs:127, InternalCommandAdministration.cs:218-221, InternalCommandCleanupService.cs:112/:147, OutboxCleanupService.cs:105/:161/:177, SessionScoringRunner.cs:170, and AuditSaveChangesInterceptor.cs:101-102 (the Deleted case falls through to the default pass-through). Test-file and remaining anchors were re-checked and are unchanged.

Revision (2026-10-21)

Re-verified against current source. No decision, rationale or trade-off changed: the convention is still registered last of the three finalizing conventions, and both moved hard deletes are still set-based deletes against leaf tables. Only line anchors in the live sections moved.

  1. The permission-grant revoke now removes the grant for every stored spelling of the role (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFPermissionGrantStore.cs:139) in one set-based delete (:145-148, ExecuteDeleteAsync at :147); it remains a leaf-table hard delete, so the Context list is unaffected in substance.
  2. Anchors re-verified against current source: ApplicationDbContext.cs:378 with the registration at :400 (degrade :387, soft-delete unique index :393), EFPermissionGrantStore.cs:145-148, and InternalCommandAdministration.cs:223-225. The earlier dated revisions keep the anchors that were accurate on their own dates.

Revision (2026-10-26)

Re-verified against current source. No decision, rationale or trade-off changed: every cited hard delete is still a set-based delete against a leaf table, and the convention and its registration are as recorded. Only line anchors in the Context list moved.

  1. Anchors re-verified against current source: the outbox cleanup deletes are the processed purge (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxCleanupService.cs:105), the dead-letter purge (:165) and the inbox purge (:181), and the permission-grant revoke is one set-based delete at .../Persistence/Auth/EFPermissionGrantStore.cs:143-146 (ExecuteDeleteAsync at :145). The earlier dated revisions keep the anchors that were accurate on their own dates.

ADR-006 (one context class per engine over one abstract base, which is what makes this a single registration), ADR-005 (the soft-delete posture that keeps cascades dormant at runtime and hides them from testing), ADR-015 (the shared rule library the two new rules join), ADR-018 and ADR-113 (the engine axis the convention branches on), ADR-016 (the lockstep release the migrations ride in on).