.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.

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

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

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

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

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

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

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

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

Bash
dotnet add package Ardalis.Specification
dotnet add package Ardalis.Specification.EntityFrameworkCore
C#
using 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>:

C#
public class OrderRepository : RepositoryBase<Order>, IOrderRepository
{
    public OrderRepository(AppDbContext db) : base(db) { }
}

And the call site is clean:

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