.NET & C# Development · Lesson 49 of 92
Specification Pattern — Move Query Logic Out of Controllers
The Problem: LINQ Everywhere
Without the specification pattern, your repositories grow into query dumping grounds.
// OrderRepository.cs — this is the smell
public async Task<List<Order>> GetActiveOrdersByCustomerAsync(Guid customerId)
=> await _db.Orders
.Where(o => o.CustomerId == customerId && o.Status == OrderStatus.Active)
.ToListAsync();
public async Task<List<Order>> GetLateOrdersAsync()
=> await _db.Orders
.Where(o => o.DueDate < DateTime.UtcNow && o.Status == OrderStatus.Active)
.ToListAsync();
public async Task<List<Order>> GetActiveOrdersWithItemsAsync()
=> await _db.Orders
.Include(o => o.Items)
.Where(o => o.Status == OrderStatus.Active)
.ToListAsync();The query logic is duplicated, untestable without a database, and leaks EF concerns into the wrong layer.
ISpecification<T>
public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
int? Take { get; }
int? Skip { get; }
}A base class handles the common wiring:
public abstract class BaseSpecification<T> : ISpecification<T>
{
public Expression<Func<T, bool>> Criteria { get; private set; } = _ => true;
public List<Expression<Func<T, object>>> Includes { get; } = new();
public Expression<Func<T, object>>? OrderBy { get; private set; }
public Expression<Func<T, object>>? OrderByDescending { get; private set; }
public int? Take { get; private set; }
public int? Skip { get; private set; }
protected void AddCriteria(Expression<Func<T, bool>> criteria)
=> Criteria = criteria;
protected void AddInclude(Expression<Func<T, object>> include)
=> Includes.Add(include);
protected void ApplyOrderBy(Expression<Func<T, object>> orderBy)
=> OrderBy = orderBy;
protected void ApplyOrderByDescending(Expression<Func<T, object>> orderBy)
=> OrderByDescending = orderBy;
protected void ApplyPaging(int skip, int take)
{
Skip = skip;
Take = take;
}
}Building Specifications
Each spec is a class with a single clear purpose:
public class ActiveOrdersSpec : BaseSpecification<Order>
{
public ActiveOrdersSpec()
{
AddCriteria(o => o.Status == OrderStatus.Active);
AddInclude(o => o.Items);
ApplyOrderByDescending(o => o.CreatedAt);
}
}
public class OrdersByCustomerSpec : BaseSpecification<Order>
{
public OrdersByCustomerSpec(Guid customerId)
{
AddCriteria(o => o.CustomerId == customerId);
}
}
public class LateOrdersSpec : BaseSpecification<Order>
{
public LateOrdersSpec()
{
AddCriteria(o => o.DueDate < DateTime.UtcNow && o.Status == OrderStatus.Active);
}
}
public class PagedOrdersSpec : BaseSpecification<Order>
{
public PagedOrdersSpec(int page, int pageSize)
{
ApplyOrderByDescending(o => o.CreatedAt);
ApplyPaging((page - 1) * pageSize, pageSize);
}
}Combining With AndSpecification / OrSpecification
public class AndSpecification<T> : BaseSpecification<T>
{
public AndSpecification(
ISpecification<T> left,
ISpecification<T> right)
{
var param = Expression.Parameter(typeof(T));
var combined = Expression.AndAlso(
Expression.Invoke(left.Criteria, param),
Expression.Invoke(right.Criteria, param));
AddCriteria(Expression.Lambda<Func<T, bool>>(combined, param));
Includes.AddRange(left.Includes);
Includes.AddRange(right.Includes);
}
}
public class OrSpecification<T> : BaseSpecification<T>
{
public OrSpecification(
ISpecification<T> left,
ISpecification<T> right)
{
var param = Expression.Parameter(typeof(T));
var combined = Expression.OrElse(
Expression.Invoke(left.Criteria, param),
Expression.Invoke(right.Criteria, param));
AddCriteria(Expression.Lambda<Func<T, bool>>(combined, param));
}
}Usage:
var activeByCustomer = new AndSpecification<Order>(
new ActiveOrdersSpec(),
new OrdersByCustomerSpec(customerId));EF Core Evaluator
The evaluator applies a specification to an IQueryable<T> — this is the only place EF concerns live:
public static class SpecificationEvaluator
{
public static IQueryable<T> GetQuery<T>(
IQueryable<T> inputQuery,
ISpecification<T> specification) where T : class
{
var query = inputQuery;
query = query.Where(specification.Criteria);
query = specification.Includes
.Aggregate(query, (current, include) => current.Include(include));
if (specification.OrderBy is not null)
query = query.OrderBy(specification.OrderBy);
else if (specification.OrderByDescending is not null)
query = query.OrderByDescending(specification.OrderByDescending);
if (specification.Skip.HasValue)
query = query.Skip(specification.Skip.Value);
if (specification.Take.HasValue)
query = query.Take(specification.Take.Value);
return query;
}
}Your generic repository now takes a spec instead of raw predicates:
public class Repository<T> : IRepository<T> where T : class
{
private readonly AppDbContext _db;
public Repository(AppDbContext db) => _db = db;
public async Task<List<T>> ListAsync(ISpecification<T> spec, CancellationToken ct = default)
{
return await SpecificationEvaluator
.GetQuery(_db.Set<T>().AsQueryable(), spec)
.ToListAsync(ct);
}
public async Task<T?> FirstOrDefaultAsync(ISpecification<T> spec, CancellationToken ct = default)
{
return await SpecificationEvaluator
.GetQuery(_db.Set<T>().AsQueryable(), spec)
.FirstOrDefaultAsync(ct);
}
public async Task<int> CountAsync(ISpecification<T> spec, CancellationToken ct = default)
{
return await SpecificationEvaluator
.GetQuery(_db.Set<T>().AsQueryable(), spec)
.CountAsync(ct);
}
}Ardalis.Specification as a Shortcut
Rolling your own works fine, but Ardalis.Specification gives you all of this pre-built plus extra features (pagination, projection, AsSplitQuery, etc.).
dotnet add package Ardalis.Specification
dotnet add package Ardalis.Specification.EntityFrameworkCoreusing Ardalis.Specification;
public class ActiveOrdersSpec : Specification<Order>
{
public ActiveOrdersSpec(Guid customerId)
{
Query
.Where(o => o.Status == OrderStatus.Active && o.CustomerId == customerId)
.Include(o => o.Items)
.OrderByDescending(o => o.CreatedAt);
}
}Your repository inherits RepositoryBase<T>:
public class OrderRepository : RepositoryBase<Order>, IOrderRepository
{
public OrderRepository(AppDbContext db) : base(db) { }
}And the call site is clean:
var spec = new ActiveOrdersSpec(customerId);
var orders = await _orderRepo.ListAsync(spec, cancellationToken);When It Helps vs When It Doesn't
Use the specification pattern when:
- Query logic is duplicated across multiple repository methods
- You need to combine filters dynamically (user-facing filters, permissions)
- You want to test query logic in isolation without hitting a database
- The codebase uses a generic repository
Skip it when:
- You have simple CRUD with no complex filters — just write the LINQ directly
- You're using Dapper or raw SQL — specs don't map well outside of IQueryable
- You only have 2-3 queries total — the abstraction costs more than it saves
The pattern shines once you have a dozen or more overlapping query conditions. Before that point it's ceremony.