Architecture Decision Record
ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)
Status
Accepted (2026-08-23). Revised 2026-08-26 (the convention appends its clause to a hand-authored
filter instead of skipping the index). Revised 2026-10-01 (PostgreSQL coverage and per-engine
quoting). Revised 2026-10-06: the per-engine predicate now comes from each engine's
IDataSourceEngine.BuildSoftDeleteFilter, and the Cosmos no-op is a relational-capability check.
Context
ADR-005 makes deletion soft: an IAuditableEntity sets IsDeleted = true
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAuditableEntity.cs:11) and a named global
query filter hides the row from every query
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:453-467,
the filter name at :474). The application therefore behaves as though the row is gone.
The database does not. A unique index still counts the hidden row, so the deleted record keeps
occupying its unique slot forever: delete a speaker and the email unique index still refuses to
create a new speaker with that email, with an error the user cannot act on because the conflicting
row is invisible to them. That contradiction is stated as the reason for the convention in its own
remarks (.../Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:11-17).
The obvious fix, a hand-written HasFilter("[IsDeleted] = 0") on each index, is the kind of rule
that gets forgotten: it lives in a different file from the IsDeleted flag, it is engine-specific
SQL typed as a string literal, and nothing fails when it is missing until a user tries to re-create a
record months later. ADR-005 decides soft-delete versus erasure and never addresses what soft-delete
does to uniqueness; this ADR is that missing half.
Decision
Make the filter a convention: every unique index on a soft-deletable entity excludes deleted rows, automatically, in every context of every consumer.
- A model-finalizing convention, registered once in the base context.
SoftDeleteUniqueIndexConvention(.../Conventions/SoftDeleteUniqueIndexConvention.cs:34) is added byApplicationDbContext.ConfigureConventions(.../DbContexts/ApplicationDbContext.cs:392, rationale at:389-391). Because ADR-006 keeps one context class per engine over that base, a single registration reaches every module, every database and every consumer repo. Nothing opts in per entity. - Scope: unique, non-owned, soft-deletable. The convention walks entity types assignable to
IAuditableEntityand not owned (:46-47, the same predicate the query filter uses atApplicationDbContext.cs:456), then applies the filter to every index that is unique (:49-50,:61-64). - A hand-authored filter is kept and extended, not replaced and not skipped. An index that
already declares a predicate keeps it and gains the soft-delete clause appended with
AND(:80), in the same orderHasSoftDeleteFilter(additionalFilter:)produces (.../Persistence/Configuration/IndexBuilderExtensions.cs:62-65), so the two paths yield byte-identical SQL for the same pair of predicates (SoftDeleteUniqueIndexConvention.cs:78-79). Skipping such an index, as the convention originally did, left precisely the partial-unique indexes a model bothered to hand-author as the only ones a soft-deleted row could keep blocking (:19-26): the framework's own push-notification dedup index, filtered on[DedupKey] IS NOT NULL, was exactly that case (.../Persistence/Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:69-73). The append is idempotent: a filter that already constrains the soft-delete column is recognized and left alone (SoftDeleteUniqueIndexConvention.cs:73-76), so a second model build cannot produce... AND [IsDeleted] = 0 AND [IsDeleted] = 0. Recognition (SoftDeleteFilterSql.ContainsPredicate) compares a normalized form with whitespace and all three identifier quoting styles stripped (.../Persistence/SoftDeleteFilterSql.cs:52-59, normalizer at:77-78), because a hand-writtenHasFilter("[IsDeleted] = 0")literal and the builder's output do not agree on quoting (:44-51); the boolean= falsespelling counts as the same clause as= 0(:57-58). - There is therefore no opt-out. Declaring a filter no longer excludes an index from the
convention (the early
continueon an existing filter is gone,SoftDeleteUniqueIndexConvention.cs:61-81), so a unique index that genuinely must enforce uniqueness across deleted rows too cannot express that on a soft-deletable entity. Nothing in the workspace wants it, and the alternative (leaving hand-filtered indexes silently un-narrowed) is the bug this revision fixes. - One predicate builder serves both paths.
SoftDeleteFilterSql.Build(.../Persistence/SoftDeleteFilterSql.cs:34-35) is called by the convention (SoftDeleteUniqueIndexConvention.cs:57) and by the public opt-inHasSoftDeleteFilter(.../Persistence/Configuration/IndexBuilderExtensions.cs:52-66), so the automatic and the manual path cannot disagree about identifier quoting or about which column carries the flag (SoftDeleteFilterSql.cs:9-16). The column name is read from the model, falling back to the property name (:72-74), andBuildhands it to the engine'sIDataSourceEngine.BuildSoftDeleteFilter, which owns the per-engine predicate:[IsDeleted] = 0for SQL Server (.../Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:117),"IsDeleted" = 0for SQLite (SqliteDataSourceEngine.cs:115), and"IsDeleted" = falsefor PostgreSQL (PostgreSQLDataSourceEngine.cs:116), whose flag is a real boolean column that refuses an integer comparison. - SQL Server, PostgreSQL and SQLite are covered; Cosmos is a no-op. The convention returns
immediately for any engine whose capabilities are not relational
(
SoftDeleteUniqueIndexConvention.cs:43-44), which is Cosmos, and the Cosmos engine's builder returnsnull(CosmosDataSourceEngine.cs:117), which the callers read as "leave the index untouched". - Non-unique indexes opt in by hand.
HasSoftDeleteFilteris the extension point for an index the convention deliberately skips (a non-unique one), and a unique index that wants to declare the combined predicate at its own declaration site uses the same call: the two are joined as{additionalFilter} AND {filter}(:62-65). The framework's own push-notification dedup index does exactly that (PushNotificationConfiguration.cs:69-73, quotingDedupKeyper engine throughSoftDeleteFilterSql.QuoteColumn,SoftDeleteFilterSql.cs:69-70), and since the convention started appending, that call is belt and braces rather than the only thing narrowing the index: it produces the same SQL in the same order, and the convention recognizes it and stops (SoftDeleteUniqueIndexConvention.cs:73-76). Store's SKU index (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Persistence/EntityConfiguration/ProductVariantConfiguration.cs:104-109) is the same shape.
The two paths differ in one respect worth knowing: the convention runs at model finalizing, after
module configurations have declared their indexes, while HasSoftDeleteFilter reads the column name
at the moment it is called, so a HasColumnName on the soft-delete property has to come first
(IndexBuilderExtensions.cs:31-35).
Rationale
- The database should agree with what the application shows. The query filter already says a soft-deleted row does not exist; a unique index that disagrees is the one place the illusion leaks, and it leaks as an unexplainable error rather than as a visible row.
- A convention beats per-configuration discipline. Every unique index on a soft-deletable entity wants this predicate, so making it the default is strictly better than asking each configuration author to remember engine-specific filter SQL.
- A shared builder is what makes the opt-in path trustworthy. Because the manual call routes
through the same
Build, a hand-filtered index gets the same column name and the same quoting the convention would have produced, instead of a SQL-Server-shaped literal that silently breaks on SQLite. - Extending a hand-authored filter, rather than replacing or skipping it, is the only option that
is correct twice. Overwriting would drop the
[DedupKey] IS NOT NULLclause the push-notification index depends on (PushNotificationConfiguration.cs:55-68); skipping leaves that index enforcing uniqueness against soft-deleted rows, which is the exact defect the convention exists to remove, and it does so only for the indexes someone thought hard enough about to filter (SoftDeleteUniqueIndexConvention.cs:22-24). Appending is the composition that keeps both predicates, and idempotent recognition is what makes appending safe to run on a model that already says it (:24-26). - The behavior is pinned by tests, in both directions.
MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conventions/SoftDeleteUniqueIndexConventionTests.csasserts the filter is applied (:31), that a soft-deleted row no longer blocks re-inserting the same value (:41), that a live duplicate is still rejected (:65), that a hand-authored filter gets the soft-delete clause appended (:78), that a soft-deleted row no longer blocks a pre-filtered unique value (:91), and that an existing soft-delete clause is neither appended twice (:108) nor missed through different quoting and spacing (:120). The opt-in path is pinned for both engines and for Cosmos in.../Persistence/Configuration/IndexBuilderExtensionsTests.cs:23-60, and the combined predicate in.../Persistence/Configuration/PushNotificationConfigurationTests.cs:27-30.
Trade-offs
- It moves schema in consumers, invisibly from the entity configuration. Adopting the convention
is a database-contract change: nothing in an entity configuration changed, but the next scaffolded
migration drops and recreates unique indexes. The v1.120.0 adoption did exactly that in ADC, for
IX_User_Email(MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/Migrations/20260720031638_CommonV1120OutboxLeaseAndSoftDeleteIndexFilters.cs:14-43) and forIX_CategoryItem_CategoryId_Name(.../MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260720031645_CommonV1120OutboxLeaseAndSoftDeleteIndexFilters.cs:14-43), each needing anEXPAND-CONTRACT-OVERRIDEmarker to pass the ADR-057 gate. The churn is uneven: Store's and Helpdesk's migrations of the same sweep carry only the outbox columns (MMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Catalog/Migrations/20260720031626_CommonV1120OutboxLeaseAndSoftDeleteIndexFilters.cs:12-27,MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/Migrations/20260720031655_CommonV1120OutboxLeaseAndSoftDeleteIndexFilters.cs:12-27), because Store's configurations already opted in by hand and Helpdesk's single module declares no unique index on a soft-deletable entity. So "the framework changed your schema" is true for some consumers and not others, and only the generated migration says which. - It moves that schema a second time, for the indexes it previously skipped. Because the
convention now appends to a hand-authored filter (
SoftDeleteUniqueIndexConvention.cs:80), every pre-filtered unique index on a soft-deletable entity has a new predicate, so the next migration a consumer scaffolds drops and recreates it once, with the same ADR-057 override the first adoption needed. The one-off applies to precisely the indexes that were left un-narrowed before, which is also the population that was still refusing to re-create a soft-deleted record. After that migration the shape is stable: the recognition check makes a later model build a no-op rather than a further append (:73-76, pinned at.../Conventions/SoftDeleteUniqueIndexConventionTests.cs:108). - Duplicates among deleted rows become legal, permanently. Any number of soft-deleted rows may
now share the same "unique" value. Two consequences follow: a report that reads with
ignoreQueryFilters: truecan see duplicates that the model's index name promises are impossible, and any restore path a consumer writes (flippingIsDeletedback tofalse) has to handle a collision with the live row that took the slot, because the database will reject it at that moment rather than at delete time. - Engine coverage is partial by construction. The guarantee exists only where the provider
supports a filtered or partial index. On Cosmos both paths return without touching the index
(
SoftDeleteUniqueIndexConvention.cs:43-44,CosmosDataSourceEngine.cs:117), so a Cosmos-backed source (ADR-018) does not get this behavior at all, and a model shared across engines gets a different uniqueness contract per engine. - The filter is not visible where the index is declared. Reading
builder.HasIndex(x => x.Email).IsUnique()does not reveal that the shipped index is filtered; the predicate first appears in a generated migration or a model snapshot. That is the cost of moving the rule out of the configuration files, and the reason the tests above assert onindex.GetFilter()rather than on behavior alone.
Revision (2026-10-01)
No decision or rationale changed. Engine coverage now includes PostgreSQL: SoftDeleteFilterSql.Build
emits "IsDeleted" = false there, because the flag is a real boolean column, alongside
[IsDeleted] = 0 for SQL Server and "IsDeleted" = 0 for SQLite
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:34-50), the
convention's remarks name all three engines (SoftDeleteUniqueIndexConvention.cs:28-29), and
recognition treats the boolean and integer spellings as one clause (SoftDeleteFilterSql.cs:73-74).
The push-notification dedup index quotes DedupKey per engine through SoftDeleteFilterSql.QuoteColumn
(.../Notifications/PushNotificationConfiguration.cs:69-73, SoftDeleteFilterSql.cs:85-86). The
Decision bullets on quoting and engine coverage are corrected accordingly, and stale citations are
refreshed in place: ApplicationDbContext.cs (:393, :390-392, :454-468, :457, :475),
SoftDeleteFilterSql.cs (:27-51, :68-75, :93-94, :60-67, :88-90),
IndexBuilderExtensions.cs (:52-66, :62-65), PushNotificationConfiguration.cs (:69-73,
:55-68), Store's ProductVariantConfiguration.cs (:103-108), and ADR-057's ADC marker citation
(057-expand-contract-schema-evolution-gate.md:90).
Revision (2026-10-06)
No decision or rationale changed.
SoftDeleteFilterSql.Buildis now a one-line delegation to the engine'sIDataSourceEngine.BuildSoftDeleteFilter(SoftDeleteFilterSql.cs:34-35); the per-engine predicates live inSQLServerDataSourceEngine.cs:117,SqliteDataSourceEngine.cs:115,PostgreSQLDataSourceEngine.cs:116andCosmosDataSourceEngine.cs:117(which returnsnull). The predicate values are unchanged; the Decision bullet on the shared builder now says where they live.- The convention's Cosmos no-op is a capability check (
!DataSourceEngines.For(engine).Capabilities.IsRelational,SoftDeleteUniqueIndexConvention.cs:43-44), not aDataSource.CosmosDBcomparison; the Decision and Trade-offs bullets say so. Recognition isSoftDeleteFilterSql.ContainsPredicate(:52-59). - The 2026-10-01 revision's in-place citations had moved again (
ApplicationDbContext.cs,SoftDeleteUniqueIndexConvention.cs,SoftDeleteFilterSql.cs, Store'sProductVariantConfiguration.csnow:104-109); that section stays as recorded. The PostgreSQL predicate it cites atSoftDeleteFilterSql.cs:34-50is nowPostgreSQLDataSourceEngine.cs:116, and the remarks naming all three engines are atSoftDeleteUniqueIndexConvention.cs:29-30. - All live-section anchors were re-verified against current source.
Related
ADR-005 (decides soft-delete over erasure and owns the query filter that hides the row, but says
nothing about uniqueness: this ADR closes that gap), ADR-057 (the expand/contract CI gate, which
classifies the drop-and-recreate this convention produces as a legitimate override and cites the ADC
Identity migration as its live marker at 057-expand-contract-schema-evolution-gate.md:90),
ADR-006 (one context class per engine over the shared ApplicationDbContext, which is why a single
convention registration reaches every module and every database).