In many applications, hard deleting data isn't always the best choice.
A hard delete permanently removes records from the database. Once deleted, the data cannot be recovered unless a backup is available.
While this method is straightforward and helps keep the database clean and performant, it has drawbacks:
- No way to recover accidentally deleted data
- Loss of historical records
- Difficulties in auditing and tracking changes
To overcome these limitations, many applications use soft delete as an alternative.
Soft Delete
Soft delete is a technique used to mark a record as inactive without actually removing it from the database.
Instead of deleting the row, you update a specific field, such as a DeletedAt timestamp or an IsDeleted flag to indicate the record is no longer active.
| Id | Name | Price | IsDeleted | DeletedAt |
|---------------------------|-----------|-------|-----------|-----------------------------|
| 23DAE8C-499B-4B1885E4C9 | Product 1 | 30.00 | 0 | NULL |
| D4155C9-4E42-72732739CC | Product 2 | 30.00 | 1 | 2025-05-07 16:59:59.1563189 |
With this approach, the data still exists in the database but is treated as inactive. This makes recovering "deleted" records simple.
Soft delete may seem like a superior approach, but it's not a silver bullet.
Every query must now explicitly exclude deleted records, which adds complexity. Over time, the accumulation of soft-deleted data can impact performance if not carefully managed.
In short, soft delete is a powerful tool when used properly, especially in systems where data recovery, auditing or historical tracking is important.
But like any tool, it introduces trade-offs that must align with your application's needs.
Naive Approach
The simplest way to implement soft delete is by adding one or more fields to your entity that indicate whether the record is considered deleted:
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
This can be as simple as a boolean flag (IsDeleted), a nullable timestamp (DeletedAt) or both.
When deleting an item, instead of removing it from the database, you just update these fields:
app.MapDelete("products/{id:guid}", async (Guid id, ApplicationDbContext dbContext, CancellationToken cancellationToken) =>
{
var product = await dbContext.Set()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (product is null)
{
return Results.NotFound();
}
product.IsDeleted = true;
product.DeletedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return Results.NoContent();
}).WithTags(Tags.Products);
From this point on, all queries must explicitly filter out deleted records:
app.MapGet("products/{id:guid}", async (ApplicationDbContext dbContext, Guid id, CancellationToken cancellationToken) =>
{
var response = await dbContext.Set()
.Where(x => x.Id == id && !x.IsDeleted)
.Select(x => new ProductResponse(
x.Id,
x.Name,
x.Description,
x.Price))
.FirstOrDefaultAsync(cancellationToken);
return response != null
? Results.Ok(response)
: Results.NotFound();
}).WithTags(Tags.Products);
While this approach is easy to implement, it has several drawbacks:
- Lack of clarity
There's no obvious indication that the entity supports soft delete. You have to manually inspect the properties.
- Inconsistent naming
Your colleagues might name the flag differently (IsDeleted, IsActive, Deleted, etc.), leading to confusion and inconsistency.
- Scattered logic
You must remember to apply the same filtering logic (!x.IsDeleted) everywhere. Additionally, your business logic must constantly adapt depending on whether soft deletion is enabled for a particular entity.
In short, this approach is a magnet for inconsistency and frequent oversights during implementation.
Using Abstraction
Instead of manually adding deletion related fields and hoping for consistency, you can define a contract that clearly marks an entity as soft deletable:
public interface ISoftDeletableEntity
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
}
Now, any entity implementing this interface is explicitly recognized as soft deletable:
public class Product : ISoftDeletableEntity
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
You could also move these fields into an abstract base class and encapsulate the deletion behavior there, but I’ve kept it as an interface to demonstrate my preferred approach in the next section.
Using EF Core Interceptors
When implementing soft delete with EF Core, I highly recommend leveraging EF Core Interceptors.
This approach lets you centralize your deletion logic, ensuring consistent behavior across your application without having to worry about accidental hard deletes.
To learn more about EF Core Interceptors check out this blog post: EF Core Interceptors
In short, EF Core interceptors allow you to intercept the saveChanges method using SaveChangesInterceptor and modify the intercepted operation:
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is null)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
UpdateAuditableEntities(eventData.Context!);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void UpdateAuditableEntities(DbContext dbContext)
{
var entries = dbContext.ChangeTracker.Entries<ISoftDeletableEntity>()
.Where(e => e.State == EntityState.Deleted);
foreach (var entry in entries)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
}
}
}
When an entity implementing the ISoftDeletableEntity interface is marked for deletion, this interceptor automatically changes the entity’s state from Deleted to Modified, sets the IsDeleted flag to true and updates the DeletedAt timestamp.
With this in place, you can simply call _context.Remove(entity) for any entity, and the interceptor will handle whether it should be soft deleted or hard deleted.
app.MapDelete("products/{id:guid}", async (Guid id, ApplicationDbContext dbContext, CancellationToken cancellationToken) =>
{
var product = await dbContext.Set()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (product is null)
{
return Results.NotFound();
}
dbContext.Set().Remove(product);
await dbContext.SaveChangesAsync(cancellationToken);
return Results.NoContent();
}).WithTags(Tags.Products);
Global Query Filtering
EF Core also offers a powerful feature called global query filters, which elegantly solves the last drawback of the naive soft delete approach.
Query Filters allow us to define global filtering rules at the entity level, ensuring they are consistently applied across all queries.
To learn more about global query filters check out this blog post: Global Query Filters
They can be set in the OnModelCreating method or in the EntityTypeConfiguration by calling the HasQueryFilter method and passing the common condition:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasQueryFilter(product => !product.IsDeleted);
}
Once the filter is set, your queries become much cleaner and no longer need to manually exclude soft deleted records:
var product = await dbContext.Products
.FirstOrDefaultAsync(product => product.Id == id);
Conclusion
Implementing soft delete with EF Core offers a flexible and robust alternative to hard deletes.
This approach preserves valuable data for recovery, auditing and historical tracking.
By adopting abstractions, EF Core Interceptors and global query filters, you can build a clean, maintainable and reliable soft delete implementation.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
