Supercharging EF Core Specifications with EF.CompileQuery

Leader posted 2 min read
  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.

  1. 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);
    }
}

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();
}
  1. 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));

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));
}

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();
    }
}

4.3 Usage stays clean

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

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

  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

If you read this far, tweet to the author to show them you care. Tweet a Thanks

More Posts

⚔️ The Mighty MERGE: Using SQL Merge Statements Safely with EF Core

Spyros - Jul 25

EF Core Bulk Updates and Deletes: Performance vs Change Tracking

Spyros - Mar 24

EF Core: Lazy Loading, Eager Loading, and Loading Data on Demand

Spyros - Mar 6

EF Core Global Query Filters: A Complete Guide

Spyros - Mar 2

When NOT to Use AsSplitQuery() in EF.core

Spyros - Feb 26
chevron_left