I'm always excited to take on new projects and collaborate with innovative minds.

Phone

+92 317 8597410

Email

info@muhammadabubakar.tech

Website

https://muhammadabubakar.tech/

Freelance Platform

Upwork , Fiverr

Social Links

Entity Framework Core

Implement Soft Delete using EF Core

EF Core is too slow? Discover how you can easily insert 14x faster (reducing saving time by 94%).
Boost your performance with our method within EF Core: Bulk Insert, update, delete and merge.
Thousands of satisfied customers have trusted our library since 2014.

Implement Soft Delete using EF Core

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.

database
| 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:

csharp
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:

csharp
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:

csharp
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:

csharp
public interface ISoftDeletableEntity
{
    bool IsDeleted { get; set; }

    DateTime? DeletedAt { get; set; }
}

Now, any entity implementing this interface is explicitly recognized as soft deletable:

csharp
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:

csharp
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.

csharp
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:

csharp
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:

csharp
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 Code

I hope you enjoyed it, subscribe and get a notification when a new blog is up!

6 min read
Jun 22, 2025
By Muhammad Abubakar
Share

Leave a comment

Your email address will not be published. Required fields are marked *