Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20264 min read
LINQExtension MethodsC#.NETEF Core
Share:𝕏

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:

C#
// 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

C#
// 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

C#
// 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)

C#
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

C#
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

C#
// 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

C#
// 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 NEXT

Extension Methods for Enumerables (In-Memory)

C#
// 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 a NotDeleted() 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.

Enjoyed this article?

Explore the AI 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.