Custom LINQ Operators — Extending the Query Pipeline
Build reusable custom LINQ extension methods: pagination, soft-delete filtering, ordering helpers, and the patterns that remove query boilerplate from handlers without leaking EF Core concerns.
Why Custom LINQ Operators
Repeated query patterns become maintenance liabilities. Pagination, soft-delete filtering, and tenant filtering appear in dozens of queries. Custom extension methods centralize them:
// Before — repeated in every paginated query
var patients = await db.Patients
.Where(p => !p.IsDeleted) // soft delete — every query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// After — expressive, centralized
var patients = await db.Patients
.NotDeleted()
.Paginate(page, pageSize)
.ToListAsync();Extension Method Basics
// Extension methods on IQueryable<T>
public static class QueryableExtensions
{
// Must be in a static class
// First parameter: this IQueryable<T>
public static IQueryable<T> Paginate<T>(
this IQueryable<T> query,
int page,
int pageSize)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageSize);
ArgumentOutOfRangeException.ThrowIfNegative(page);
return query
.Skip(page * pageSize)
.Take(pageSize);
}
}
// Usage — looks like a built-in LINQ operator
var page2 = await db.Patients.Paginate(page: 2, pageSize: 20).ToListAsync();Soft Delete Filter
// Domain interface for soft-deletable entities
public interface ISoftDeletable
{
bool IsDeleted { get; }
DateTime? DeletedAt { get; }
}
// Extension method works for any soft-deletable entity
public static class SoftDeleteExtensions
{
public static IQueryable<T> NotDeleted<T>(this IQueryable<T> query)
where T : ISoftDeletable
{
return query.Where(e => !e.IsDeleted);
}
public static IQueryable<T> DeletedOnly<T>(this IQueryable<T> query)
where T : ISoftDeletable
{
return query.Where(e => e.IsDeleted);
}
public static IQueryable<T> DeletedAfter<T>(
this IQueryable<T> query, DateTime cutoff)
where T : ISoftDeletable
{
return query.Where(e => e.IsDeleted && e.DeletedAt > cutoff);
}
}
// Usage
var patients = await db.Patients.NotDeleted().ToListAsync();
var recently = await db.Patients.DeletedAfter(DateTime.UtcNow.AddDays(-7)).ToListAsync();Production issue I've seen: A team forgot the soft-delete filter in 3 out of 15 query methods. Deleted patients appeared in search results for months before anyone noticed. A global query filter (EF Core) or a mandatory extension method enforced at the repository level would have made it impossible to forget.
Tenant Filter (Multi-Tenancy)
public interface IHasTenantId
{
Guid TenantId { get; }
}
public static IQueryable<T> ForTenant<T>(
this IQueryable<T> query, Guid tenantId)
where T : IHasTenantId
{
return query.Where(e => e.TenantId == tenantId);
}
// All queries automatically scoped to the tenant
var tenantPatients = await db.Patients
.NotDeleted()
.ForTenant(currentUser.TenantId)
.ToListAsync();Dynamic Ordering
public static IQueryable<T> OrderByField<T>(
this IQueryable<T> query,
string field,
bool descending = false)
{
var parameter = Expression.Parameter(typeof(T), "x");
var property = Expression.Property(parameter, field);
var lambda = Expression.Lambda(property, parameter);
var methodName = descending ? "OrderByDescending" : "OrderBy";
var method = typeof(Queryable)
.GetMethods()
.First(m => m.Name == methodName && m.GetParameters().Length == 2)
.MakeGenericMethod(typeof(T), property.Type);
return (IQueryable<T>)method.Invoke(null, [query, lambda])!;
}
// Usage — sort column comes from the UI
var sorted = await db.Patients
.OrderByField(filter.SortBy ?? "LastName", filter.Descending)
.Paginate(filter.Page, filter.PageSize)
.ToListAsync();Search Extension
// Generic search on string properties
public static IQueryable<Patient> Search(
this IQueryable<Patient> query, string? term)
{
if (string.IsNullOrWhiteSpace(term))
return query;
return query.Where(p =>
p.FullName.Contains(term) ||
p.MRN.Contains(term) ||
p.Email!.Contains(term));
}
// OR — use EF.Functions for full-text search on SQL Server
public static IQueryable<Patient> FullTextSearch(
this IQueryable<Patient> query, string? term)
{
if (string.IsNullOrWhiteSpace(term))
return query;
return query.Where(p =>
EF.Functions.Contains(EF.Property<string>(p, "SearchVector"), term));
}Composing Multiple Custom Operators
// All operators chain naturally — reads like a sentence
var results = await db.Patients
.NotDeleted() // soft-delete filter
.ForTenant(currentUser.TenantId) // multi-tenant
.Search(filter.Term) // full-text search
.Where(p => p.Department == filter.Dept) // additional filter
.OrderByField(filter.SortBy, filter.Desc) // dynamic sort
.Paginate(filter.Page, filter.PageSize) // pagination
.Select(p => new PatientDto(p.Id, p.FullName, p.MRN))
.ToListAsync(ct);
// All of this generates ONE SQL query with WHERE, ORDER BY, OFFSET/FETCH NEXTExtension Methods for Enumerables (In-Memory)
// Chunk — split a list into batches (useful for bulk operations)
public static IEnumerable<IEnumerable<T>> Chunk<T>(
this IEnumerable<T> source, int size)
{
// Note: .NET 6+ has Chunk() built in — use that instead
return source
.Select((item, index) => (item, index))
.GroupBy(x => x.index / size)
.Select(g => g.Select(x => x.item));
}
// Usage — send prescriptions to pharmacy in batches of 100
foreach (var batch in prescriptions.Chunk(100))
await pharmacyService.SendBatchAsync(batch, ct);Red Flag / Green Answer
Red Flag: "We added !p.IsDeleted to every single query method in the repository — it's in 22 places."
This will be forgotten in query #23, and deleted records will appear in production. Centralize in one extension method or use an EF Core global query filter that applies automatically.
Green Answer:
EF Core global query filter for soft delete:
modelBuilder.Entity<Patient>().HasQueryFilter(p => !p.IsDeleted). Applied to every query automatically. Or aNotDeleted()extension method applied in the base repository.
Key Takeaway
Custom LINQ extension methods centralize repeated patterns — pagination, soft-delete, tenant scoping, dynamic ordering — so they cannot be forgotten or inconsistently applied. Extend
IQueryable<T>with constrained generics (where T : ISoftDeletable) for type-safe, composable operators. The result: handlers read like business logic, not query boilerplate.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.