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.
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.
-- Without filter
SELECT * FROM Orders WHERE Id = 1
-- With global filter: IsDeleted = false
SELECT * FROM Orders WHERE Id = 1 AND IsDeleted = 0This 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
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
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:
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.
// 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.
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:
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);
}
}// 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:
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:
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:
// 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:
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:
modelBuilder.Entity<Order>()
.HasIndex(o => o.TenantId)
.HasFilter("[IsDeleted] = 0"); // SQL Server syntaxAvoid 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.