Soft delete
Deleting records permanently is rarely what you want in a production application. Compliance requirements, audit trails, undo functionality, and data recovery all benefit from keeping records around but marking them as deleted. EF Core doesn't provide this out of the box, so you typically end up implementing query filters and save interceptors manually in each project.
ISoftDeletable standardizes this pattern with automatic query filtering and deletion interception.
Setting up soft delete on your entities
Implement ISoftDeletable on any entity that should support soft deletion:
using MADE.Data.EFCore;
public class User : EntityBase, ISoftDeletable
{
public string Name { get; set; }
public string Email { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedDate { get; set; }
}
Applying the global query filter
Register the soft-delete filter in your OnModelCreating to automatically exclude deleted entities from all queries:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().Configure();
modelBuilder.ApplySoftDeleteFilter();
}
With this in place, dbContext.Users.ToListAsync() only returns non-deleted users. You never need to add .Where(u => !u.IsDeleted) to your queries.
Querying deleted records
When you do need to see deleted records (admin panels, audit views, data recovery), use IgnoreQueryFilters():
var allUsers = await dbContext.Users
.IgnoreQueryFilters()
.ToListAsync();
var deletedUsers = await dbContext.Users
.IgnoreQueryFilters()
.Where(u => u.IsDeleted)
.ToListAsync();
Automatic deletion interception
Without interception, calling dbContext.Users.Remove(user) would still perform a hard delete. InterceptSoftDeletions converts these to soft deletes automatically:
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
this.InterceptSoftDeletions();
this.SetEntityDates();
return await base.SaveChangesAsync(cancellationToken);
}
Now when you call Remove() on any entity that implements ISoftDeletable, the interceptor sets IsDeleted = true and DeletedDate to the current UTC time instead of issuing a SQL DELETE.
Manual soft delete and restore
For explicit control, use the SoftDelete and Restore extension methods:
// Soft delete
user.SoftDelete();
await dbContext.SaveChangesAsync();
// Restore a soft-deleted record
user.Restore();
await dbContext.SaveChangesAsync();
SoftDelete() sets IsDeleted = true and DeletedDate to DateTime.UtcNow. Restore() sets IsDeleted = false and clears DeletedDate.
Best practices
- Always call
InterceptSoftDeletions()in yourSaveChangesAsyncoverride. Without it, directRemove()calls bypass soft-delete and permanently delete the record. - Apply
ApplySoftDeleteFilter()globally rather than per-entity. This ensures no soft-deletable entity is accidentally queried without the filter. - Use
IgnoreQueryFilters()sparingly and only in contexts where seeing deleted records is intentional (admin tools, audit logs, compliance reports). - Consider combining with
IAuditableEntityto also track who deleted the record. See Audit tracking.