Soft Deletes with Entity Framework Core - Part 3

Update: SoftDeleteConvention is now generic.

Update: see part 4 here.

Introduction

Long ago, I wrote two posts on how to do soft deletes with EF Core. Back then, it was with version 2. Well, something (a lot!) has changed in the meantime, namely:

  • Custom conventions
  • Events

So this post is to complement the other two with this welcome additions!

Custom Conventions

You may have realised that EF Core automatically discovers things, such as navigation properties, the different relation types (one-to-one, one-to-many/many-to-one, many-to-many), the property that corresponds to the primary key column, etc, automatically. Well, it turns out that it does this by using conventions. There are many kinds of conventions, and they all inherit from the IConvention interface. This is a vast subject which deserves a post on its own, but for now, we'll keep it simple. If you want to change the default behaviour of EF Core, you can just remove the built-in convention and replace it with your own, for example:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Remove<RelationshipDiscoveryConvention>();
configurationBuilder.Conventions.Add(_ => new MyCustomRelationshipDiscoveryConvention());
base.ConfigureConventions(configurationBuilder);
}

There are many to choose from, that specialise in the different aspects of the model configuration. Just look at the different interface names and you get the idea.

Now, what we want here is something that runs when the model is ready, so that we don't need to repeat the configuration we entered on the OnModelCreating method here. So, we can implement a IModelFinalizingConvention convention, that leverages the ISoftDeletable<T> generic interface I introduced before:

internal class SoftDeleteConvention<T> : IModelFinalizingConvention
{
private const string _isDeletedProperty = "IsDeleted";
private static readonly MethodInfo _propertyMethod = typeof(EF).GetMethod(nameof(EF.Property), BindingFlags.Static | BindingFlags.Public)!.MakeGenericMethod(typeof(T));

public T DeletedValue { get; init; } = default!;
public string IsDeletedProperty { get; init; } = _isDeletedProperty;

private LambdaExpression GetIsDeletedRestriction(Type type)
{
var parm = Expression.Parameter(type, "it");
var prop = Expression.Call(_propertyMethod, parm, Expression.Constant(IsDeletedProperty));
var condition = Expression.MakeBinary(ExpressionType.Equal, prop, Expression.Constant(DeletedValue));
var lambda = Expression.Lambda(condition, parm);
return lambda;
}

public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes().Where(entityType => typeof(ISoftDeletable<T>).IsAssignableFrom(entityType.ClrType)))
{
entityType.AddProperty(IsDeletedProperty, typeof(T));
modelBuilder.Entity(entityType.ClrType)!.HasQueryFilter(GetIsDeletedRestriction(entityType.ClrType));
}
}
}

As you can see, the code is very much the same as here, but wrapped in IModelFinalizingConvention and made generic and with some parameters. This allows us to have different "IsDeleted" column names, types, and "is deleted" values. For example:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Add(_ => new SoftDeleteConvention<int>() { IsDeletedProperty = "Deleted", DeletedValue = 1 });
base.ConfigureConventions(configurationBuilder);
}

Essentially, we are looking for all entities that implement ISoftDeletable, and we apply a global query filter to them. And that's it! Now you just need to apply this convention on any contexts you want, on the ConfigureConventions method, without any parameters, for boolean properties, with a DeletedValue of true:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Add(_ => new SoftDeleteConvention<bool> { DeletedValue = true });
base.ConfigureConventions(configurationBuilder);
}

Events

What also was not possible at the time of the writing of the initial post, was to hook to events, such as SavingChanges. Fortunately, now, we can also externalise the code to make the modifications - change from delete to update - instead of having to modify all our soft-delete-aware contexts! And here's how:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
SoftDeleteUpdater.Register(this);

base.OnModelCreating(modelBuilder);
}

SoftDeleteUpdater.Register(this) can be called from either OnModelCreatingOnConfiguring or ConfigureConventions. Here is what it does:

public static class SoftDeleteUpdater
{
private const string _isDeletedProperty = "IsDeleted";

public static void Register(DbContext context)
{
context.SavingChanges += OnSavingChanges;
}

private static void OnSavingChanges(object? sender, SavingChangesEventArgs e)
{
var context = sender as DbContext;
foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>().Where(e => e.State == EntityState.Deleted))
{
entry.Property(_isDeletedProperty).CurrentValue = true;
entry.State = EntityState.Modified;
}
}
}

Again, it's the same code from the previous post, here wrapped in its own class. This is made possible because of events. With this, no need to override method SaveChanges no more!

Conclusion

And that's all for now. As you can see, these changes in EF Core allow for code that is much more maintainable and reusable. I have kept the two functionalities, the addition of the global query filter and the interception of the delete entities, separate, but it sure is possible to have them together, an exercise for you, dear reader! Stay tuned for more and let me hear your thoughts!

                             

2 Comments

  • I like the SaveChangesInterceptor ... you can register these in DI, and map them in to EFCore via Service location.

    Code seems simpler and more testable.

    Also if your subclass of SaveChangesInterceptor needs other service/state (eg: "who is the user who is deleting" or "TimeProvider" for testing .. it all weaves in.

  • @Nick Hodge: fair point, ISaveChangesInterceptor/SaveChangesInterceptor is definitely another option.

Add a Comment

As it will appear on the website

Not displayed

Your website