Soft deletes in EF Core: How to implement and query efficiently

What does your application actually do with a record when a user deletes it? Can your application afford to delete a record permanently? One mistake can result in irreparable damage to the application. Today, I will shed light on D of the CRUD. Among all the CRUD operations, Delete is the most critical. Removing a record using a database delete is a simple way, but it does not provide a rollback option. In many cases, you don't want a cascading chain of deletes, which cannot be recovered. Soft delete is a common practice for such scenarios, where the record is flagged as deleted, allowing you to rollback using the same flag.

What is a soft delete?

Soft delete is a data persistence strategy that provides a secure way to manage data without permanently removing it. Instead of deleting records, the system marks them as inactive by toggling a deletion flag. Soft delete ensures that sensitive or critical data can remain available for restoration, auditing, or historical reference. A hard delete can result in cascade deletion, losing essential relations or associated data.

Traditionally, a hard delete performs the following:

DELETE FROM [dbo].[Book]
WHERE [Book].[Id] = 02;

Our rescuer soft delete does

UPDATE [dbo].[Book]
SET [dbo].[Book].[IsDeleted] = 1
WHERE [Book].[Id] = 02;

It is like telling your database, "Hide this record, but don't actually delete it.' to fetch records filtered by the IsDeleted flag.

SELECT *
FROM [Book]
WHERE IsDeleted = 0;

We have seen the introduction of the soft delete, but now our purpose was to know "How to implement soft delete efficiently?"

Way 1: Simplest - Manual Flagging

Step 1: Define the model

public class Book
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string IBN { get; set; }
    public int PageCount { get; set; }

    // Setting the flag false by default for any new record

    public bool IsDeleted { get; set; } = false;
}

Step 2: Configure ApplicationDbContext

public class ApplicationDbContext: DbContext
{
    public DbSet<Book> Books { get; set; }

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)  
    {  
    }
}

Step 3: Implement the Remove method

public async Task RemoveBookAsync(int id)
{
    var book = await context.Books.FirstOrDefaultAsync(id);
    book.IsDeleted = true;
    await context.SaveChangesAsync();
}

Step 4: Filter in the get methods

public async Task GetBooksAsync () =>
    await context.Books.Where(b => !b.IsDeleted).ToListAsync();

Manual flagging is the most straightforward way to handle deletion softly. However, if the application grows larger, managing IsDeleted for each model becomes troublesome.

Way 2: Global Query Filter

Defining common properties in a base model is a cleaner and OOP-based approach. To avoid repetitive code, you can define the IsDeleted property in such a model and inherit it across other classes.

Step 1: Declare the base model

public abstract class SoftDeletableModel
{
    public bool IsDeleted { get; set; } = false;
    public DateTime? DeletedAt { get; set; }
}

We added DeletedAt to record the time of deletion.

Step 2: Create Book model as an inherited class

public class Book: SoftDeletableModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string IBN { get; set; }
    public int PageCount { get; set; }
}

Step 3: Configure ApplicationDbContext

public class ApplicationDbContext: DbContext
{
    public DbSet<Book> Books { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)  
    {  
        modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);  
    }  
}

Here, we applied a filter in the DbContext itself, so it will filter book records while fetching.

Step 4: Implement the Remove method

public async Task RemoveBookAsync(int id)
{
    var book = await context.Books.FirstOrDefaultAsync(id);
    book.IsDeleted = true;
    book.DeletedAt = DateTime.UtcNow();
    await context.SaveChangesAsync();
}

The remove method remains the same.

Step 5: Filter in the get methods

public async Task GetBooksAsync () => await context.Books.ToListAsync();

No explicit filter required.

By configuring the IsDeleted filter, all the book records will be filtered out during fetching. However, if you need to skip the filter for complex joins, you can use IgnoreQueryFilters() like:

await context.Books.IgnoreQueryFilters().ToList();

Way 3: SaveChanges Override to Intercept Deletes

Now we are moving one step ahead, we will change the SaveChangesAsync default behavior for this purpose.

Step 1: Declare the base model

public abstract class SoftDeletableModel
{
    public bool IsDeleted { get; set; } = false;
    public DateTime? DeletedAt { get; set; }
}

We added DeletedAt to record the time of deletion.

Step 2: Create Book model as an inherited class

public class Book: SoftDeletableModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string IBN { get; set; }
    public int PageCount { get; set; }
}

Step 3: Configure ApplicationDbContext soft delete via SaveChanges interception

public class ApplicationDbContext: DbContext
{
    public DbSet<Book> Books { get; set; }

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);
        base.OnModelCreating(modelBuilder);
    }

    public override int SaveChanges()
    {
        foreach (var entry in ChangeTracker
            .Entries()
            .Where(e => e.State == EntityState.Deleted && e.Entity is SoftDeletableEntity))  
        {
            entry.State = EntityState.Modified;
            ((SoftDeletableEntity)entry.Entity).IsDeleted = true;
            ((SoftDeletableEntity)entry.Entity).DeletedAt = DateTime.UtcNow;
        }

        return base.SaveChanges();
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {  
        foreach (var entry in ChangeTracker
            .Entries()
            .Where(e => e.State == EntityState.Deleted && e.Entity is SoftDeletableEntity))
        {
            entry.State = EntityState.Modified;
            ((SoftDeletableEntity)entry.Entity).IsDeleted = true;
            ((SoftDeletableEntity)entry.Entity).DeletedAt = DateTime.UtcNow;
        }

        return await base.SaveChangesAsync(cancellationToken);
    }
}

Here we applied a filter in the DbContext itself, hence it will filter book records while fetching.

You can also define a separate interceptor and register it. The DbContext code becomes:

public class ApplicationDbContext: DbContext
{
    public DbSet<Book> Books { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);
        base.OnModelCreating(modelBuilder);
    }
}

And define a separate inceptor.

public sealed 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);

        var entries = eventData.Context.ChangeTracker
            .Entries<ISoftDeletable>()
            .Where(e => e.State == EntityState.Deleted);

        foreach (var entry in entries)
        {
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedAt = DateTime.UtcNow;
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

Register it in the services

services.AddSingleton<SoftDeleteInterceptor>();
services.AddDbContext<ApplicationDbContext>((sp, options) =>
    options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))
    .AddInterceptors(sp.GetRequiredService<SoftDeleteInterceptor>()));

Step 4: Implement the Remove method

public async Task RemoveBookAsync(int id)
{
    // Soft delete via the Remove method
    var book = await context.Books.FindAsync(1);
    context.Books.Remove(book); // ← Intercepted by SaveChanges
    await context.SaveChangesAsync();
}

Remove() acts as a soft delete.

Step 5: Filter in the get methods

public async Task GetBooksAsync () => await context.Books.ToListAsync();

Here, you don't need to use Update logic in the RemoveBookAsync method.

Way 4: With Repository Pattern

The repository pattern will utilize a generic type to implement soft deletion in the Remove method. The first two steps are the same as Way 2

Step 3: Define ApplicationDbContext

public class ApplicationDbContext: DbContext
{
    public DbSet<Book> Books { get; set; }

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);
        base.OnModelCreating(modelBuilder);
    }
}

Step 4: Define a generic repository

public class Repository<T> where T : SoftDeletableModel
{
    private readonly ApplicationDbContext _context;
    public Repository(ApplicationDbContext context) => _context = context;

    public async Task RemoveAsync(int id)
    {
        var entity = await _context.Set<T>().FindAsync(id);  

        if (entity != null)
        {
            entity.IsDeleted = true;
            entity.DeletedOnUtc = DateTime.UtcNow;
            await _context.SaveChangesAsync();
        }
    }

    public IQueryable<T> GetAll() => _context.Set<T>();
}

Step 5: Use the code

To Remove:

var repo = new Repository<Book>(context);
await repo.RemoveAsync(1);

To Get:

var books = await repo.GetAll().ToListAsync();

Conclusion

Traditional hard delete methods can be problematic in many ways. They permanently erase data from the database, and you cannot roll back that data. Such operations are expensive if your application is prone to mistakes and becomes troublesome when cascade deleting occurs. Additionally, many law enforcement bodies have established data retention and disposal procedures, and deleting data permanently could result in penalties for non-compliance. A soft delete strategy that deletes records logically by setting a flag on the record, indicating it as "deleted." It maintains the foreign key integrity and ensures the consistency of other associated documents. This approach allows the application to ignore these records during regular queries. However, you can restore these records if necessary. We discussed some standard methods for implementing soft deletion in EF Core.