DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on AI-assisted

Soft Delete Is a Model Contract, Not a Repository Habit

Soft deletion often begins innocently: add an IsDeleted flag, remember to exclude retained rows, and move on. That works until the application grows into several modules, several DbContext types, and configuration spread across assemblies.

At that point, soft deletion is no longer a local query concern. It is a visibility boundary for the complete EF Core model.

The difference is practical. A repository predicate protects the query paths that remember to use it. A model contract protects ordinary reads by default and makes exceptional reads visible in code review.

The dangerous partial success

Imagine a shared base context that scans the entity types it knows and installs a query filter. Later, a module applies another configuration assembly and adds more entities. The base code is correct for the model it saw, but its timing was wrong. Some retained rows are hidden and others are returned normally.

This failure is uncomfortable because both sides look reasonable in isolation. The entity has the deletion marker. The shared context contains filter code. A few tests pass. Yet the effective model has uneven visibility rules.

The lesson is to configure cross-cutting rules at a lifecycle point where the model is complete. An EF Core model-finalising convention can inspect every registered type after the contexts and configuration assemblies have contributed their mappings.

For each root entity carrying the deletion marker, the convention can add the standard predicate. Owned types follow their owner, and derived types inherit the root filter. Existing filters must be preserved: combine with an anonymous filter, or add a separate named filter when the model uses named filters. Any always-visible audit type should be an explicit, narrow exception rather than an accidental omission.

Test the effective predicate

Checking that a filter exists is too weak.

An entity might have an unrelated visibility filter. It might even mention the deletion marker beneath a logical OR:

not deleted OR visible to this user
Enter fullscreen mode Exit fullscreen mode

That expression can still admit a retained row. The required property is stronger: not deleted must be a mandatory conjunct of the effective filter.

A reusable model audit can enumerate all root entity types with the marker, exclude only documented exceptions, and fail if any type lacks that mandatory predicate. It should have tests against itself: prove that it rejects an unrelated filter and rejects the OR case. Otherwise the audit risks becoming a green check that does not guard the boundary it names.

Provider-backed tests add the next layer of evidence. Insert one active row and one retained row. Show that normal queries return only the active row, while an explicit bypass can retrieve both. This verifies the generated query and provider behaviour rather than only evaluating a compiled expression in memory.

A correct filter changes workflows

When global filtering becomes consistent, previously accidental behaviour may break. That is useful information.

Restoration is the clearest example. Suppose a retained row still owns a unique business slot. A normal lookup cannot see it, so a naive create path tries to insert a replacement and collides with the database constraint.

The solution is not to weaken the global filter. Give the restoration path an explicit way to ignore model filters, then make its intention precise:

  • creating into a slot held by a retained row restores that same row;
  • editing a different active row into the reserved slot is refused;
  • failure leaves both rows unchanged;
  • historical or restoration reads are the only paths allowed to bypass the boundary.

Putting the bypass on a named specification or dedicated query object is easier to review than scattering IgnoreQueryFilters() through services. The name communicates why the exception exists, and focused tests can pin its scope.

Watch required navigations

Global filters also interact with relationship shape. If a required navigation uses an inner join and the principal is filtered out, the dependent can disappear from a query even when the dependent itself is active.

That may be correct, but it should be a decision.

An inventory of required relationships that cross filtered and unfiltered types gives reviewers a bounded list of hazards. For each one, choose deliberately: align filters on both sides, make the navigation optional, change the query shape, or accept and test the disappearance.

This is one reason metadata-driven verification scales better than a long list of hand-written repository tests. The model can tell you what exists now, including a new entity or relationship added by a future module.

A practical verification stack

A durable soft-delete boundary has four layers:

  1. Apply the convention after the model is complete.
  2. Audit every soft-deletable root for the exact mandatory predicate.
  3. Keep bypasses explicit and limited to restoration or historical use.
  4. Run real-provider tests for visibility, restoration, uniqueness, and required-navigation behaviour.

There is a cost: more model infrastructure, a deliberate exception list, and tests that can expose old assumptions. Some teams may decide that hard deletion or temporal tables better match particular data. The point is not that soft deletion is always right.

The point is that, once chosen, it must behave like one coherent contract.

The most valuable outcome is not the filter itself. It is that a future entity cannot quietly arrive outside the boundary—and that exceptional access reads like an exception.

Top comments (0)