DEV Community

Spyros Ponaris
Spyros Ponaris

Posted on

Supercharging EF Core Specifications with EF.CompileQuery

** 1. Introduction**

When you’re using EF Core in production, query compilation can quietly become a performance bottleneck especially if the same query is executed thousands of times.

Normally, EF Core parses your LINQ, generates SQL, and prepares the execution plan every time.
With EF.CompileQuery, EF Core does that work once, caching the query plan for all future executions.

The catch?

If you’re using the Specification pattern for clean, reusable queries, your repository method probably applies a spec dynamically to DbSet — which makes EF.CompileQuery less obvious to use.

In this article, we’ll solve that and show you how to bake compiled queries into your specification-driven repository.

2. Quick Recap: Specification Pattern

The Specification pattern encapsulates query logic into reusable, composable units.

Example spec for active customers:

 public class ActiveCustomersSpec : Specification<Customer>
    {
        public ActiveCustomersSpec()
        {
            Query.Where(c => c.IsActive)
                 .OrderBy(c => c.Name)
                 .Include(c => c.Orders);
        }
    }
Enter fullscreen mode Exit fullscreen mode

Your repository then applies the spec:

    public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
    {
        var evaluator = new SpecificationEvaluator();
        var query = evaluator.GetQuery(_db.Set<T>().AsQueryable(), spec);
        return await query.ToListAsync();
    }
Enter fullscreen mode Exit fullscreen mode

3. The Problem

EF.CompileQuery needs a strongly-typed lambda starting from your DbContext:

    private static readonly Func<AppDbContext, bool, IEnumerable<Customer>> _activeCustomers =
        EF.CompileQuery((AppDbContext db, bool isActive) =>
            db.Customers.Where(c => c.IsActive == isActive));
Enter fullscreen mode Exit fullscreen mode

But with the Specification pattern, you usually pass in a spec, not a lambda on DbContext.
You don’t want every caller to remember how to invoke the compiled query.
We need a way to integrate compiled queries into the repository itself.

  1. The Solution: Encapsulate Compiled Queries in the Repository

4.1 Create the compiled query

    public static class CompiledQueries
    {
        public static readonly Func<AppDbContext, bool, IEnumerable<Customer>> ActiveCustomers =
            EF.CompileQuery((AppDbContext db, bool isActive) =>
                db.Customers
                  .Where(c => c.IsActive == isActive)
                  .OrderBy(c => c.Name)
                  .Include(c => c.Orders));
    }
Enter fullscreen mode Exit fullscreen mode

4.2 Update the repository

public class EfRepository<T> where T : class
    {
        private readonly AppDbContext _db;
        public EfRepository(AppDbContext db) => _db = db;

        public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
        {
            // Detect known spec → use compiled query
            if (typeof(T) == typeof(Customer) && spec is ActiveCustomersSpec)
            {
                var result = CompiledQueries.ActiveCustomers(_db, true).ToList();
                return (IReadOnlyList<T>)result;
            }

            // Fallback: normal spec evaluation
            var evaluator = new SpecificationEvaluator();
            var query = evaluator.GetQuery(_db.Set<T>().AsQueryable(), spec);
            return await query.ToListAsync();
        }
    }
Enter fullscreen mode Exit fullscreen mode

4.3 Usage stays clean

    var repo = new EfRepository<Customer>(db);
    var customers = await repo.ListAsync(new ActiveCustomersSpec());
Enter fullscreen mode Exit fullscreen mode

The caller doesn’t know (or care) if the query was compiled
but hot-path specs now run ~3x faster.

  1. The Compiled Specification Interface

We extend the idea of a specification to optionally include an async compiled query:

using System.Linq.Expressions;

public interface ICompiledSpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    Func<AppDbContext, IAsyncEnumerable<T>> CompiledQueryAsync { get; }
}

Enter fullscreen mode Exit fullscreen mode

Active Customers Specification

public class ActiveCustomersSpec : Specification<Customer>, ICompiledSpecification<Customer>
{
    public Expression<Func<Customer, bool>> Criteria { get; }
    public Func<AppDbContext, IAsyncEnumerable<Customer>> CompiledQueryAsync { get; }

    public ActiveCustomersSpec()
    {
        // Define criteria once
        Criteria = c => c.IsActive;

        // Precompile async query once for reuse
        CompiledQueryAsync = EF.CompileAsyncQuery((AppDbContext db) =>
            db.Set<Customer>()
              .Where(Criteria)
              .OrderBy(c => c.Name)
              .Include(c => c.Orders));
    }
}
Enter fullscreen mode Exit fullscreen mode
public class EfRepository<T> where T : class
{
    private readonly AppDbContext _dbContext;

    public EfRepository(AppDbContext dbContext)
    {
        _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
    }

    public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
    {
        // If spec supports compiled queries, use it
        if (spec is ICompiledSpecification<T> compiledSpec)
        {
            var results = new List<T>();
            await foreach (var item in compiledSpec.CompiledQueryAsync(_dbContext))
            {
                results.Add(item);
            }
            return results;
        }

        // Otherwise: normal Ardalis evaluation
        var evaluator = new SpecificationEvaluator();
        var query = evaluator.GetQuery(_dbContext.Set<T>().AsQueryable(), spec);
        return await query.ToListAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Benefits of This Approach
  • Encapsulation — All compiled query logic stays in the spec.
  • No duplication — Criteria is defined once and reused in both compiled and non‑compiled paths.
  • Async‑ready — Works with streaming and large datasets.
  • Generic — Works for any spec implementing ICompiledSpecification without
  1. Ready-to-Use Libraries Instead of writing your own Specification pattern, you can use:

Ardalis.Specification

  • Well-maintained, feature-rich.

  • Supports includes, projections, ordering, pagination.

NSpecifications

  • DDD-focused, composable specifications.

  • Great for domain-driven designs.

Both integrate nicely with EF Core and can work with this
compiled-query approach.

  1. Best Practices Use compiled queries for hot paths — they have a cost to set up but shine when reused often.
  • Avoid over-compiling — not every query benefits from precompilation.
  • Keep specs small and focused — huge includes and joins can still dominate runtime.
  • Combine with async wisely — EF.CompileQuery is sync; use EF.CompileAsyncQuery for async enumeration.
  1. Conclusion

You don’t have to choose between clean architecture (Specification pattern) and speed.
By encapsulating EF.CompileQuery inside your repository for frequently-used specs,
you can keep your calling code clean while still getting huge performance wins.

Source Code :
https://github.com/stevsharp/EfCompileSpecDemo

Top comments (0)