Soft deleting is an easy way of deleting data without actually removing it from the database. Instead of performing DELETE on the database we mark it as deleted and filter it out by default on the application side. We want our soft delete mechanism to function seamlessly with EFCore, after all, it’s just the implementation detail, so we need to intercept all DbContext Remove calls.

So, to achieve this we need two parts:

  • filtering out all data that has the deleted flag set, so that it is impossible for our Application to read deleted data
  • intercepting all SaveChanges or SaveChangesAsync calls and replacing the usual delete with our custom Soft Delete mechanism

First, we need our ISoftDelete.cs interface, which all our entities will implement. It’s a good practice to put in the time stamp as well.

public interface ISoftDelete
{
    bool IsDeleted { get; set; }
    DateTimeOffset? DeletionDateTime { get; set; }
}

After that in our ApplicationDbContext.cs we need to create the filter with the help of some reflection magic. We need to set the filter on each entity, so we iterate over them and then check if they implement the ISoftDelte interface to see if they are a candidate for the filter. After that, we set the filtering by getting the property and creating a lambda expression for it.

protected void SetGlobalSoftDeleteQueryFilter(ModelBuilder modelBuilder)
{
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        var isDeletedProperty = entityType.FindProperty("IsDeleted");
        if (isDeletedProperty != null 
            && isDeletedProperty.ClrType == typeof(bool))
        {
            var parameter = Expression.Parameter(
                entityType.ClrType, "p");
            var prop = Expression.Property(parameter, 
                isDeletedProperty.PropertyInfo);
            var filter = Expression.Lambda(Expression.Not(prop),
                parameter);
            entityType.SetQueryFilter(filter);
        }
    }
}

Now we need to register it in our OnModelCreating method:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    SetGlobalQueryForSoftDelete(modelBuilder);

    // ...
}

We are halfway there, now we need to set those columns by intercepting the SaveChanges calls. For this purpose let’s create a method, that will check the EFCore internal ChangeTracker for any candidates implementing ISoftDelete interface to be removed and replacing their state with EntityState.Modified and setting the appropriate columns.

private void SetSoftDeleteColumns()
{
    var entriesDeleted = ChangeTracker
        .Entries()
        .Where(e => e.Entity is ISoftDelete 
                && e.State == EntityState.Deleted);

    foreach (var entityEntry in entriesDeleted)
    {
        ((ISoftDelete)entityEntry.Entity).IsDeleted = true;
        ((ISoftDelete)entityEntry.Entity).DeletionDateTime = 
                DateTimeOffset.Now;
        entityEntry.State = EntityState.Modified;
    }
}

Now we need to override SaveChanges and SaveChangesAsync methods in our ApplicationDbContext.cs to perform our custom logic and that’s it.

public override int SaveChanges()
{
    SetSoftDeleteColumns();

    return base.SaveChanges();
}

public override Task<int> SaveChangesAsync(
    CancellationToken cancellationToken = default)
{
    SetSoftDeleteColumns();

    return base.SaveChangesAsync(cancellationToken);
}