Back to blog
Backend Systemsintermediate

Add Soft Deletes in 5 Minutes With Global Query Filters

Global query filters automatically apply WHERE conditions to every query for a type. Use them for soft deletes, multi-tenancy, and row-level security — without touching a single repository method.

LearnixoApril 14, 20264 min read
.NETC#EF CoreEntity FrameworkSoft DeleteMulti-TenancyGlobal Filters
Share:𝕏

What Global Query Filters Are

A global query filter adds a WHERE clause to every LINQ query for a given entity type — transparently, at the DbContext level. You configure it once in OnModelCreating and every query respects it automatically.

SQL
-- Without filter
SELECT * FROM Orders WHERE Id = 1

-- With global filter: IsDeleted = false
SELECT * FROM Orders WHERE Id = 1 AND IsDeleted = 0

This is the right tool for any concern that should silently constrain all queries: soft deletes, tenant isolation, row-level security.


Soft Delete in 5 Minutes

Step 1 — Add the interface and entity

C#
public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTime? DeletedAt { get; set; }
}

public class Order : ISoftDeletable
{
    public int Id { get; set; }
    public string CustomerId { get; set; } = default!;
    public decimal Total { get; set; }

    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
}

Step 2 — Register the filter in OnModelCreating

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        if (typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
        {
            modelBuilder.Entity(entityType.ClrType)
                .HasQueryFilter(BuildSoftDeleteFilter(entityType.ClrType));
        }
    }
}

private static LambdaExpression BuildSoftDeleteFilter(Type entityType)
{
    // Builds: e => !e.IsDeleted
    var param = Expression.Parameter(entityType, "e");
    var prop = Expression.Property(param, nameof(ISoftDeletable.IsDeleted));
    var notDeleted = Expression.Not(prop);
    return Expression.Lambda(notDeleted, param);
}

Step 3 — Soft-delete instead of hard-delete

Override SaveChangesAsync to intercept deletes:

C#
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    foreach (var entry in ChangeTracker.Entries<ISoftDeletable>()
        .Where(e => e.State == EntityState.Deleted))
    {
        entry.State = EntityState.Modified;
        entry.Entity.IsDeleted = true;
        entry.Entity.DeletedAt = DateTime.UtcNow;
    }

    return base.SaveChangesAsync(ct);
}

Now _db.Orders.Remove(order) sets IsDeleted = true and saves it — no rows are ever physically deleted.


Ignoring the Filter

Sometimes you genuinely need to see deleted records — admin views, audit logs, restore operations.

C#
// See ALL orders including soft-deleted
var allOrders = await _db.Orders
    .IgnoreQueryFilters()
    .ToListAsync(ct);

// Restore a soft-deleted order
var order = await _db.Orders
    .IgnoreQueryFilters()
    .FirstOrDefaultAsync(o => o.Id == id, ct);

if (order is not null)
{
    order.IsDeleted = false;
    order.DeletedAt = null;
    await _db.SaveChangesAsync(ct);
}

IgnoreQueryFilters() removes ALL global filters for that query, not just the soft-delete one. If you have multiple filters, they all get ignored.


Multi-Tenancy Filter

The same pattern works for tenant isolation. Every query automatically filters by the current tenant — no chance of cross-tenant data leaks from forgetting a WHERE clause.

C#
public interface IHasTenant
{
    string TenantId { get; set; }
}

public class Product : IHasTenant
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
    public string TenantId { get; set; } = default!;
}

The filter needs access to the current tenant at query time, so inject it via the DbContext:

C#
public class AppDbContext : DbContext
{
    private readonly ITenantContext _tenant;

    public AppDbContext(DbContextOptions options, ITenantContext tenant)
        : base(options)
    {
        _tenant = tenant;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .HasQueryFilter(p => p.TenantId == _tenant.CurrentTenantId);
    }
}
C#
// ITenantContext resolved from the HTTP request (DI, Scoped lifetime)
public class HttpTenantContext : ITenantContext
{
    public string CurrentTenantId { get; }

    public HttpTenantContext(IHttpContextAccessor accessor)
    {
        CurrentTenantId = accessor.HttpContext?.User
            .FindFirstValue("tenant_id") ?? "default";
    }
}

Registration:

C#
builder.Services.AddScoped<ITenantContext, HttpTenantContext>();
builder.Services.AddHttpContextAccessor();

Combining Multiple Filters

You can only call HasQueryFilter once per entity type — a second call replaces the first. Combine conditions into a single expression:

C#
modelBuilder.Entity<Order>()
    .HasQueryFilter(o =>
        !o.IsDeleted &&
        o.TenantId == _tenant.CurrentTenantId);

If you want to compose filters dynamically, build the predicate with PredicateBuilder (LINQKit) or Expression trees:

C#
// Using LinqKit or manual expression composition
Expression<Func<Order, bool>> softDelete = o => !o.IsDeleted;
Expression<Func<Order, bool>> tenantFilter = o => o.TenantId == _tenant.CurrentTenantId;

var combined = softDelete.And(tenantFilter); // LinqKit

modelBuilder.Entity<Order>().HasQueryFilter(combined);

Performance Implications

Global filters add a predicate to every query. For indexed columns (IsDeleted, TenantId) the impact is negligible — the database uses the index.

Make sure your indexes cover the filter columns:

C#
modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.TenantId, o.IsDeleted });

For soft deletes, a partial/filtered index (supported in SQL Server and PostgreSQL) is even better — it only indexes rows where IsDeleted = 0:

C#
modelBuilder.Entity<Order>()
    .HasIndex(o => o.TenantId)
    .HasFilter("[IsDeleted] = 0"); // SQL Server syntax

Avoid putting expensive computed expressions inside global filters — they run on every query for that type. Keep filters to simple column comparisons.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.