It's 2021, and it occurred to me to add a more modern, standard, built-in solution that pertains to current versions of EF Core.
With global query filters you can ensure that certain filters are always applied to certain entities. And you can define your soft deletion properties via an interface, which facilitates programmatically adding the filter to all relevant entities. See:
...
public interface ISoftDeletable
{
public string DeletedBy { get; }
public DateTime? DeletedAt { get; }
}
...
// Call it from DbContext.OnModelCreating()
private static void ConfigureSoftDeleteFilter(ModelBuilder builder)
{
foreach (var softDeletableTypeBuilder in builder.Model.GetEntityTypes()
.Where(x => typeof(ISoftDeletable).IsAssignableFrom(x.ClrType)))
{
var parameter = Expression.Parameter(softDeletableTypeBuilder.ClrType, "p");
softDeletableTypeBuilder.SetQueryFilter(
Expression.Lambda(
Expression.Equal(
Expression.Property(parameter, nameof(ISoftDeletable.DeletedAt)),
Expression.Constant(null)),
parameter)
);
}
}
Then, to make sure this flag is used during deletion instead of hard deletion (alternative to e.g. repositories setting the flag instead of deleting the entity):
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<ISoftDeletable>())
{
switch (entry.State)
{
case EntityState.Deleted:
// Override removal. Unchanged is better than Modified, because the latter flags ALL properties for update.
// With Unchanged, the change tracker will pick up on the freshly changed properties and save them.
entry.State = EntityState.Unchanged;
entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = _currentUser.UserId;
entry.Property(nameof(ISoftDeletable.DeletedAt)).CurrentValue = _dateTime.Now;
break;
}
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
Caveat 1: Cascade Delete Timing
One crucial aspect is to take into account the cascade deletion of related entities, and either disable cascade delete, or understand and control the cascade delete timing behavior of EF Core. The default value of the CascadeDeleteTiming
setting is CascadeTiming.Immediate
, which causes EF Core to immediately flag all navigation properties of the 'deleted' entity as EntityState.Deleted
, and reverting the EntityState.Deleted
state only on the root entity won't revert it on the navigation properties. So if you have navigation properties which don't use soft deletion, and you want to avoid them being deleted, you must handle their change tracker state too (instead of just handling it for e.g. ISoftDeletable
entities), or change the CascadeDeleteTiming
setting as shown below.
The same is true for owned types used on the soft-deleted entities. With the default deletion cascade timing EF Core also flags these owned types as 'deleted', and in case they are set as Required/non-nullable, you will encounter SQL update failures when trying to save the soft-deleted entities.
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{
ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges;
}
Caveat 2: Effect on other root entities
If you define a global query filter this way, EF Core will diligently hide all other entities that reference a soft-deleted entity.
For example if you've soft-deleted a Partner
entity, and you have Order
entities where each of them references a partner through a (required) navigation property, then, when you retrieve the list of orders and you include the partner, all orders that reference a soft-deleted Partner
will be missing from the list.
This behavior is discussed at the bottom of the documentation page.
Sadly, the global query filters as of EF Core 5 don't provide an option to limit them to root entities, or to disable only one of the filters. The only available option is to use the IgnoreQueryFilters()
method, which disables ALL filters. And since the IgnoreQueryFilters()
method takes an IQueryable
and also returns an IQueryable
, you cannot use this method to transparently disable the filter inside your DbContext class for an exposed DbSet
.
Though, one important detail is that this occurs only if you Include()
the given navigation property while querying. And there is an interesting solution for getting a result set that has query filters applied to certain entities but doesn't have them applied to other entities, relying on a lesser known feature of EF, relational fixup. Basically, you load a list of EntityA
that has navigation property EntityB
(without including EntityB
). And then you separately load the list of EntityB
, using IgnoreQueryFilters()
. What happens is that EF automatically sets the EntityB
navigation property on EntityA
to the loaded EntityB
instances. This way the query filter was applied to EntityA
itself, but wasn't applied to the EntityB
navigational property, so you can see EntityA
s even with soft-deleted EntityB
s. See this answer on another question. (Of course this has performance implications, and you still can't encapsulate it in DbContext.)