** 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);
}
}
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();
}
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));
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.
- 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.
- 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; }
}
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));
}
}
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();
}
}
- 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
- Ready-to-Use Libraries Instead of writing your own Specification pattern, you can use:
Well-maintained, feature-rich.
Supports includes, projections, ordering, pagination.
DDD-focused, composable specifications.
Great for domain-driven designs.
Both integrate nicely with EF Core and can work with this
compiled-query approach.
- 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.
- 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)