Skip to content

The Specification Pattern in .NET A Practical Introduction

Learn the Specification pattern in .NET the EF Core-friendly way: why the classic version loads your whole table into memory, how to build expression-based specs step by step, combine them with And/Or/Not, and when to use Ardalis.Specification instead.

Isaiah Clifford Opoku
Isaiah Clifford OpokuSoftware Engineer & ArchitectOctober 27, 2026·21 min read

In the last post we looked at the Repository and Unit of Work patterns and asked whether you still need them on top of EF Core. Near the end, the Specification pattern showed up as “the middle ground”: the thing to reach for when you want reusable queries without exposing raw IQueryable. This post explains that pattern properly.

By the end you should know what problem it solves, why the version most tutorials show will quietly load your entire table into memory, how to build an EF Core-friendly version step by step, how to combine specifications with And/Or/Not without breaking SQL translation, and when to just use the Ardalis.Specification library instead of writing your own.

The problem: repositories are great at CRUD and bad at “find me…”

A generic repository handles create, update, and delete really well. Every entity does the same thing for those operations, so one generic implementation covers them all. Here’s a typical one:

public interface IGenericRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IReadOnlyList<T>> GetAllAsync();
    Task AddAsync(T entity);
    void Update(T entity);
    void Remove(T entity);
}

public class GenericRepository<T> : IGenericRepository<T> where T : class
{
    private readonly AppDbContext _context;
    private readonly DbSet<T> _dbSet;

    public GenericRepository(AppDbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }

    public async Task<T?> GetByIdAsync(int id) => await _dbSet.FindAsync(id);

    public async Task<IReadOnlyList<T>> GetAllAsync() => await _dbSet.ToListAsync();

    public async Task AddAsync(T entity) => await _dbSet.AddAsync(entity);

    public void Update(T entity) => _dbSet.Update(entity);

    public void Remove(T entity) => _dbSet.Remove(entity);
}

As in the previous post, the repository doesn’t call SaveChanges. Committing is the Unit of Work’s job (or DbContext.SaveChangesAsync() directly).

Reading is where it falls apart. Real applications almost never read “all products” or “product #42”. They read products in a category, active products under a price, the newest 20 products with their reviews, page 3. A generic repository has no good way to express that, so teams usually end up doing one of three things, and all three hurt.

Option 1: one method per query. You add a method for every new screen:

public interface IProductRepository : IGenericRepository<Product>
{
    Task<IReadOnlyList<Product>> GetByCategoryAsync(string category);
    Task<IReadOnlyList<Product>> GetActiveByCategoryAsync(string category);
    Task<IReadOnlyList<Product>> GetActiveByCategoryUnderPriceAsync(string category, decimal maxPrice);
    Task<IReadOnlyList<Product>> GetActiveByCategoryWithReviewsPagedAsync(string category, int page, int size);
    // ...and it never stops growing
}

Each new requirement adds a method. The names get longer, the logic for “active” gets copied into five places, and when the business changes what “active” means you have to find and update every copy.

Option 2: return IQueryable<T>. You let callers compose whatever they want:

IQueryable<T> Query();

That’s flexible, but now every service writes EF Core LINQ directly, and the repository isn’t abstracting anything. Microsoft’s architecture guide specifically recommends against this.

Option 3: accept a raw predicate.

Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate);

Better, but it only covers filtering. Includes, sorting, and paging still need more parameters, and the business rule itself (p => p.IsActive && p.Stock > 0) still gets written inline in every service that needs it.

What we actually want is a way to give a query a name, put it in one place, and hand it to the repository. That’s the Specification pattern.

What the Specification pattern is

The pattern comes from Eric Evans and Martin Fowler (Specifications, 2003) and shows up in Evans’ Domain-Driven Design. The idea is simple:

A specification is an object that represents a business rule, and can answer the question “does this object satisfy the rule?”

Instead of GetActiveByCategoryUnderPriceAsync(...), you write a small class called ActiveProductsInCategorySpec that describes the query, and the repository gets one generic method that knows how to run any specification:

var spec = new ActiveProductsInCategorySpec("Laptops");
var laptops = await _repository.ListAsync(spec);

Three things change:

  1. The repository stops growing. It has ListAsync(spec), FirstOrDefaultAsync(spec), and CountAsync(spec), and that’s it. New queries are new specification classes, not new repository methods.
  2. Business rules get a name and a home. “Active product” is defined once, in one class, and reused everywhere.
  3. Rules become testable on their own. A specification is a plain object. You can unit test it without a database, a repository, or a mock.

The trap: the “classic” specification that loads your whole table

Most introductions (including many older .NET tutorials) show the textbook version straight from the original paper:

public interface ISpecification<T>
{
    bool IsSatisfiedBy(T entity);
}

public class ProductInCategorySpec : ISpecification<Product>
{
    private readonly string _category;

    public ProductInCategorySpec(string category) => _category = category;

    public bool IsSatisfiedBy(Product product) => product.Category == _category;
}

This is fine for checking a single object that’s already in memory, for example validating a command before saving it. The problem appears as soon as you use it to query:

public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
{
    var all = await _dbSet.ToListAsync();          // SELECT * FROM Products  (every row!)
    return all.Where(spec.IsSatisfiedBy).ToList(); // filtered in C#, in memory
}

IsSatisfiedBy is a compiled C# method. EF Core can’t look inside a compiled method, so it can’t turn it into a SQL WHERE clause. The only way to run it is to pull every row into memory first and filter in C#. With 50 products in development that’s invisible. With 2 million rows in production it’s an outage.

The key idea: EF Core can translate an Expression<Func<T, bool>> to SQL, because an expression is a data structure describing code. It cannot translate a Func<T, bool> or a regular method, because those are compiled code.

So a specification that’s meant to be used with EF Core has to hold its rule as an expression tree, not as a method. Then you get both: EF Core translates it to SQL for queries, and you can still compile it to check a single object in memory.

Building an EF Core-friendly specification, step by step

Here’s the entity we’ll work with:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }
    public List<Review> Reviews { get; set; } = new();
}

public class Review
{
    public int Id { get; set; }
    public int ProductId { get; set; }
    public int Rating { get; set; }
    public string Comment { get; set; } = string.Empty;
}

Step 1: the base specification

A query is usually more than a filter. It also has includes, ordering, and paging. The base class holds all of those, and exposes protected methods so each concrete specification can describe itself in its constructor.

using System.Linq.Expressions;

public abstract class Specification<T>
{
    private Func<T, bool>? _compiledCriteria;

    public Expression<Func<T, bool>>? Criteria { get; private set; }
    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? Skip { get; private set; }
    public int? Take { get; private set; }
    public bool AsNoTracking { get; private set; }

    protected void Where(Expression<Func<T, bool>> criteria)
    {
        // Calling Where twice combines the conditions with AND
        Criteria = Criteria is null ? criteria : Criteria.AndAlso(criteria);
        _compiledCriteria = null;
    }

    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 pageNumber, int pageSize)
    {
        Skip = (pageNumber - 1) * pageSize;
        Take = pageSize;
    }

    protected void ApplyNoTracking() => AsNoTracking = true;

    // The same rule, checked against one object in memory
    public bool IsSatisfiedBy(T entity)
    {
        if (Criteria is null) return true;
        _compiledCriteria ??= Criteria.Compile();
        return _compiledCriteria(entity);
    }
}

Notice IsSatisfiedBy is still there. The difference is that it’s derived from the expression instead of being the only representation of the rule. The expression goes to the database; the compiled version is for in-memory checks. Compiling an expression is relatively expensive, so the result is cached.

(AndAlso is a small extension method for combining two expressions. We’ll write it in the “Combining specifications” section below, because doing it naively has a nasty bug.)

Step 2: concrete specifications

Each business query becomes a small, named class. The constructor reads almost like a sentence describing the query:

public sealed class ActiveProductsInCategorySpec : Specification<Product>
{
    public ActiveProductsInCategorySpec(string category)
    {
        Where(p => p.IsActive && p.Category == category);
        ApplyOrderBy(p => p.Name);
        ApplyNoTracking();
    }
}

public sealed class ProductsInPriceRangeSpec : Specification<Product>
{
    public ProductsInPriceRangeSpec(decimal min, decimal max)
    {
        Where(p => p.Price >= min && p.Price <= max);
    }
}

public sealed class ProductWithReviewsSpec : Specification<Product>
{
    public ProductWithReviewsSpec(int productId)
    {
        Where(p => p.Id == productId);
        AddInclude(p => p.Reviews);
    }
}

public sealed class NewestProductsPagedSpec : Specification<Product>
{
    public NewestProductsPagedSpec(int pageNumber, int pageSize)
    {
        Where(p => p.IsActive);
        ApplyOrderByDescending(p => p.CreatedAt);
        ApplyPaging(pageNumber, pageSize);
        ApplyNoTracking();
    }
}

The rule for each query now lives in exactly one place. If “active” later means IsActive && Stock > 0, you change it in one class (or better, in one shared spec, as shown below).

Step 3: the evaluator, where a specification becomes a query

Something has to turn a specification into an actual IQueryable. That’s the evaluator. It’s the only place in the codebase that knows how to apply each part of a specification to EF Core:

using Microsoft.EntityFrameworkCore;

public static class SpecificationEvaluator
{
    public static IQueryable<T> GetQuery<T>(
        IQueryable<T> inputQuery,
        Specification<T> spec,
        bool ignorePaging = false) where T : class
    {
        var query = inputQuery;

        if (spec.AsNoTracking)
            query = query.AsNoTracking();

        if (spec.Criteria is not null)
            query = query.Where(spec.Criteria);

        query = spec.Includes.Aggregate(query, (current, include) => current.Include(include));

        if (spec.OrderBy is not null)
            query = query.OrderBy(spec.OrderBy);
        else if (spec.OrderByDescending is not null)
            query = query.OrderByDescending(spec.OrderByDescending);

        if (!ignorePaging)
        {
            if (spec.Skip is not null) query = query.Skip(spec.Skip.Value);
            if (spec.Take is not null) query = query.Take(spec.Take.Value);
        }

        return query;
    }
}

The order matters: filter, include, sort, then page. Paging before sorting gives you a random page, and EF Core will warn you about it.

The ignorePaging flag is there for counting. If you want “showing page 3 of 12”, the total count has to ignore Skip/Take, otherwise you’d always get a count of at most one page.

Step 4: the repository gets three read methods and stops growing

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<T>> ListAsync(Specification<T> spec, CancellationToken ct = default);
    Task<T?> FirstOrDefaultAsync(Specification<T> spec, CancellationToken ct = default);
    Task<int> CountAsync(Specification<T> spec, CancellationToken ct = default);
    Task AddAsync(T entity, CancellationToken ct = default);
    void Update(T entity);
    void Remove(T entity);
}

public class Repository<T> : IRepository<T> where T : class
{
    private readonly AppDbContext _context;

    public Repository(AppDbContext context) => _context = context;

    public async Task<T?> GetByIdAsync(int id, CancellationToken ct = default) =>
        await _context.Set<T>().FindAsync(new object[] { id }, ct);

    public async Task<IReadOnlyList<T>> ListAsync(Specification<T> spec, CancellationToken ct = default) =>
        await Apply(spec).ToListAsync(ct);

    public async Task<T?> FirstOrDefaultAsync(Specification<T> spec, CancellationToken ct = default) =>
        await Apply(spec).FirstOrDefaultAsync(ct);

    public async Task<int> CountAsync(Specification<T> spec, CancellationToken ct = default) =>
        await SpecificationEvaluator.GetQuery(_context.Set<T>(), spec, ignorePaging: true).CountAsync(ct);

    public async Task AddAsync(T entity, CancellationToken ct = default) =>
        await _context.Set<T>().AddAsync(entity, ct);

    public void Update(T entity) => _context.Set<T>().Update(entity);

    public void Remove(T entity) => _context.Set<T>().Remove(entity);

    private IQueryable<T> Apply(Specification<T> spec) =>
        SpecificationEvaluator.GetQuery(_context.Set<T>(), spec);
}

Register it once for every entity type:

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

The important point: IQueryable never leaves the repository. Callers pass in a specification and get back a finished list. The query is still fully composed and translated to SQL inside EF Core.

Step 5: using it from a service

public class ProductService
{
    private readonly IRepository<Product> _products;

    public ProductService(IRepository<Product> products) => _products = products;

    public Task<IReadOnlyList<Product>> GetActiveInCategoryAsync(string category, CancellationToken ct) =>
        _products.ListAsync(new ActiveProductsInCategorySpec(category), ct);

    public Task<Product?> GetWithReviewsAsync(int id, CancellationToken ct) =>
        _products.FirstOrDefaultAsync(new ProductWithReviewsSpec(id), ct);

    public async Task<PagedResult<Product>> GetNewestAsync(int page, int size, CancellationToken ct)
    {
        var spec = new NewestProductsPagedSpec(page, size);

        var items = await _products.ListAsync(spec, ct);
        var total = await _products.CountAsync(spec, ct); // same filter, paging ignored

        return new PagedResult<Product>(items, total, page, size);
    }
}

public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize);

Look at GetNewestAsync: the same specification drives both the page of results and the total count. There’s no chance of the two queries drifting apart because someone updated the filter in one place and forgot the other.

And the SQL that ActiveProductsInCategorySpec("Laptops") produces is exactly what you’d write by hand:

SELECT p.Id, p.Name, p.Category, p.Price, p.Stock, p.IsActive, p.CreatedAt
FROM Products AS p
WHERE p.IsActive = CAST(1 AS bit) AND p.Category = @category
ORDER BY p.Name

The filtering happens in the database, not in memory. That’s the whole point of the expression-based version.

Combining specifications with And, Or, and Not

A big selling point of the pattern is composition: build small rules and combine them. “Active laptops between $500 and $1,500” should be active AND inCategory AND inPriceRange, not a new class.

The bug most implementations have

The obvious approach is to glue the two expression bodies together:

// Looks right. Isn't.
var combined = Expression.Lambda<Func<T, bool>>(
    Expression.AndAlso(left.Body, right.Body),
    left.Parameters[0]);

This compiles, and it may even seem to work in a quick test. But left is p => p.IsActive and right is x => x.Price < 100. Those are two different parameter objects, even if you named them both p. The combined expression uses left’s parameter for the lambda, but right.Body still refers to its own parameter, which is no longer declared anywhere. EF Core fails at runtime with an error along the lines of “The LINQ expression could not be translated” or “variable ‘x’ of type ‘Product’ referenced from scope ‘’, but it is not defined”.

The fix: rewrite one parameter into the other

The solution is a tiny ExpressionVisitor that walks through the right-hand expression and swaps its parameter for the left-hand one, so both halves refer to the same p:

using System.Linq.Expressions;

internal sealed class ParameterReplacer : ExpressionVisitor
{
    private readonly ParameterExpression _from;
    private readonly ParameterExpression _to;

    public ParameterReplacer(ParameterExpression from, ParameterExpression to)
    {
        _from = from;
        _to = to;
    }

    protected override Expression VisitParameter(ParameterExpression node) =>
        node == _from ? _to : base.VisitParameter(node);
}

public static class ExpressionExtensions
{
    public static Expression<Func<T, bool>> AndAlso<T>(
        this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right) =>
        Combine(left, right, Expression.AndAlso);

    public static Expression<Func<T, bool>> OrElse<T>(
        this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right) =>
        Combine(left, right, Expression.OrElse);

    public static Expression<Func<T, bool>> Negate<T>(this Expression<Func<T, bool>> expression) =>
        Expression.Lambda<Func<T, bool>>(Expression.Not(expression.Body), expression.Parameters[0]);

    private static Expression<Func<T, bool>> Combine<T>(
        Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right,
        Func<Expression, Expression, BinaryExpression> merge)
    {
        var parameter = left.Parameters[0];
        var rightBody = new ParameterReplacer(right.Parameters[0], parameter).Visit(right.Body);

        return Expression.Lambda<Func<T, bool>>(merge(left.Body, rightBody), parameter);
    }
}

This is also the AndAlso the base class’s Where method uses when you call it more than once.

The combinator specifications

Now And, Or, and Not are just specifications whose criteria are built from other specifications:

public sealed class AndSpecification<T> : Specification<T>
{
    public AndSpecification(Specification<T> left, Specification<T> right) =>
        Where(Required(left).AndAlso(Required(right)));

    internal static Expression<Func<T, bool>> Required(Specification<T> spec) =>
        spec.Criteria ?? throw new InvalidOperationException(
            $"{spec.GetType().Name} has no criteria and can't be combined.");
}

public sealed class OrSpecification<T> : Specification<T>
{
    public OrSpecification(Specification<T> left, Specification<T> right) =>
        Where(AndSpecification<T>.Required(left).OrElse(AndSpecification<T>.Required(right)));
}

public sealed class NotSpecification<T> : Specification<T>
{
    public NotSpecification(Specification<T> inner) =>
        Where(AndSpecification<T>.Required(inner).Negate());
}

public static class SpecificationExtensions
{
    public static Specification<T> And<T>(this Specification<T> left, Specification<T> right) =>
        new AndSpecification<T>(left, right);

    public static Specification<T> Or<T>(this Specification<T> left, Specification<T> right) =>
        new OrSpecification<T>(left, right);

    public static Specification<T> Not<T>(this Specification<T> spec) =>
        new NotSpecification<T>(spec);
}

With a few small, single-rule specifications:

public sealed class ActiveProductSpec : Specification<Product>
{
    public ActiveProductSpec() => Where(p => p.IsActive);
}

public sealed class InStockSpec : Specification<Product>
{
    public InStockSpec() => Where(p => p.Stock > 0);
}

public sealed class InCategorySpec : Specification<Product>
{
    public InCategorySpec(string category) => Where(p => p.Category == category);
}

you can now build queries that read like the business requirement:

var spec = new ActiveProductSpec()
    .And(new InStockSpec())
    .And(new InCategorySpec("Laptops"))
    .And(new ProductsInPriceRangeSpec(500, 1500));

var results = await _products.ListAsync(spec);

// "Everything that is out of stock or inactive", for an admin cleanup screen
var needsAttention = new InStockSpec().Not().Or(new ActiveProductSpec().Not());

All of that still becomes a single SQL WHERE clause.

One honest limitation: combinators combine criteria only. What does it mean to “OR” two different sort orders or two different page sizes? Nothing sensible. So keep the filter rules small and composable, and put includes, ordering, and paging in the “query-shaped” specifications that sit on top, like NewestProductsPagedSpec. You can also pass a combined spec into a query-shaped one:

public sealed class SellableProductsPagedSpec : Specification<Product>
{
    public SellableProductsPagedSpec(int page, int size)
    {
        Where(AndSpecification<Product>.Required(new ActiveProductSpec().And(new InStockSpec())));
        ApplyOrderBy(p => p.Name);
        ApplyPaging(page, size);
        ApplyNoTracking();
    }
}

Projecting to DTOs: don’t load columns you don’t need

The previous post pointed out a real cost of generic repositories: they return full entities, so every column travels over the wire even when the UI needs three fields. Specifications can fix that too by adding a Select:

public abstract class Specification<T, TResult> : Specification<T>
{
    public Expression<Func<T, TResult>>? Selector { get; private set; }

    protected void Select(Expression<Func<T, TResult>> selector) => Selector = selector;
}

public sealed class ProductSummariesInCategorySpec : Specification<Product, ProductSummaryDto>
{
    public ProductSummariesInCategorySpec(string category)
    {
        Where(p => p.IsActive && p.Category == category);
        ApplyOrderBy(p => p.Name);
        Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price));
    }
}

public record ProductSummaryDto(int Id, string Name, decimal Price);

Add one overload to the repository:

public async Task<IReadOnlyList<TResult>> ListAsync<TResult>(
    Specification<T, TResult> spec, CancellationToken ct = default)
{
    var selector = spec.Selector
        ?? throw new InvalidOperationException("A projection spec needs a Select.");

    return await SpecificationEvaluator.GetQuery(_context.Set<T>(), spec)
        .Select(selector)
        .ToListAsync(ct);
}

Now the generated SQL only selects Id, Name, and Price. Projections don’t need change tracking, so you don’t need AsNoTracking here either: EF Core doesn’t track DTOs.

Testing specifications

Because a specification is just an object holding an expression, you can test the business rule itself with no database at all:

public class ActiveProductsInCategorySpecTests
{
    [Fact]
    public void Matches_active_products_in_the_category()
    {
        var spec = new ActiveProductsInCategorySpec("Laptops");

        Assert.True(spec.IsSatisfiedBy(new Product { IsActive = true, Category = "Laptops" }));
    }

    [Theory]
    [InlineData(false, "Laptops")] // inactive
    [InlineData(true, "Phones")]   // wrong category
    public void Rejects_everything_else(bool isActive, string category)
    {
        var spec = new ActiveProductsInCategorySpec("Laptops");

        Assert.False(spec.IsSatisfiedBy(new Product { IsActive = isActive, Category = category }));
    }

    [Fact]
    public void Combined_specs_use_the_same_rules()
    {
        var spec = new ActiveProductSpec().And(new InStockSpec());

        Assert.False(spec.IsSatisfiedBy(new Product { IsActive = true, Stock = 0 }));
        Assert.True(spec.IsSatisfiedBy(new Product { IsActive = true, Stock = 3 }));
    }
}

These tests are fast, and they pin down what the rule means. What they can’t tell you is whether EF Core can translate the rule to SQL. An expression like p => MyHelper.IsCheap(p) passes these tests and still fails at runtime against a real database. So pair them with a few integration tests that run your specifications through the real repository against a real database. The Testcontainers setup from the last post works unchanged here.

A bonus: the same rule in queries and in validation

This is something the method-per-query approach can never give you. Say the business rule is “a product can only be sold if it’s active and in stock”. You need that rule in two places:

  • when querying the storefront listing (database, SQL), and
  • when validating an order before checkout (one object, in memory).

With specifications, it’s literally the same object:

var sellable = new ActiveProductSpec().And(new InStockSpec());

// 1. In a query: translated to SQL
var listing = await _products.ListAsync(sellable);

// 2. In validation: evaluated in memory against one product
if (!sellable.IsSatisfiedBy(product))
    throw new InvalidOperationException($"Product {product.Id} can't be sold right now.");

If the rule changes, both places change together, because there’s only one place.

Should you write your own or use Ardalis.Specification?

Everything above is worth understanding, and building it once is the best way to really get the pattern. But for production code, Ardalis.Specification (by Steve Smith) is the de facto standard library in .NET, and it’s what Microsoft’s eShopOnWeb reference app uses. It covers things our version doesn’t: ThenInclude, multiple sort levels (ThenBy), search/LIKE helpers, caching hints, split queries, and an evaluator that’s been battle-tested against many EF Core versions.

dotnet add package Ardalis.Specification
dotnet add package Ardalis.Specification.EntityFrameworkCore

The same specification, written with the library’s fluent builder:

using Ardalis.Specification;

public sealed class ActiveProductsInCategorySpec : Specification<Product>
{
    public ActiveProductsInCategorySpec(string category)
    {
        Query.Where(p => p.IsActive && p.Category == category)
             .OrderBy(p => p.Name)
             .AsNoTracking();
    }
}

The library also ships a ready-made generic repository, so the repository from Step 4 shrinks to:

using Ardalis.Specification.EntityFrameworkCore;

public class EfRepository<T> : RepositoryBase<T> where T : class
{
    public EfRepository(AppDbContext dbContext) : base(dbContext) { }
}

builder.Services.AddScoped(typeof(IRepositoryBase<>), typeof(EfRepository<>));
var laptops = await _repository.ListAsync(new ActiveProductsInCategorySpec("Laptops"));

And if you’ve decided you don’t want repositories at all (the “use DbContext directly” conclusion from the last post), you can still use specifications straight against DbContext:

var laptops = await _context.Products
    .WithSpecification(new ActiveProductsInCategorySpec("Laptops"))
    .ToListAsync();

That’s a useful point to take away: the Specification pattern doesn’t require the Repository pattern. It’s a way of naming and reusing queries. A repository is just one place to run them.

Rule of thumb: write your own if you need a handful of simple specs and want zero dependencies, or if you’re learning. Use Ardalis.Specification as soon as you need ThenInclude, multi-level sorting, or you’d rather not maintain an expression evaluator yourself.

Common mistakes and when not to use it

1. Using IsSatisfiedBy (or any compiled Func) to query. This is the “whole table in memory” trap from earlier. If it’s meant to hit the database, it must be an Expression.

2. Calling non-translatable methods inside criteria. Where(p => IsPremium(p)), where IsPremium is your own C# method, compiles fine and fails at runtime (or worse, in older EF versions, silently evaluated on the client). Keep criteria to things EF Core can translate: properties, comparisons, string.Contains/StartsWith, collection Contains, and so on.

3. One specification per screen. If you end up with ProductsForHomePageSpec, ProductsForAdminGridSpec, ProductsForMobileAppSpec, all slightly different, you’ve just moved the “one method per query” problem into classes. Build small, reusable rules (ActiveProductSpec, InStockSpec) and compose them.

4. Specifications for queries that are only used once. A specification earns its keep when a rule is reused or is genuinely a business concept. A one-off report query used in a single handler is often clearer written directly against DbContext, especially in a Vertical Slice Architecture where each slice owns its own data access.

5. Forgetting that paging needs a stable order. Skip/Take without OrderBy gives you unpredictable pages. If a spec applies paging, it should apply ordering too, and ideally a unique tie-breaker like Id.

Decision framework

Signal Recommendation
Your repository interface keeps growing a new GetBy... method for every screen Specification pattern. Move each query into a named spec and keep the repository at ListAsync/FirstOrDefaultAsync/CountAsync.
The same business rule (“active”, “sellable”, “overdue”) is copied across services Specification pattern. Define the rule once and compose it.
A rule is needed both in database queries and in in-memory validation Expression-based specification, using Criteria for queries and IsSatisfiedBy for validation.
You need ThenInclude, multi-level sorting, or don’t want to maintain an evaluator Use Ardalis.Specification instead of rolling your own.
You use DbContext directly with no repositories Specifications still work via WithSpecification(...). No repository required.
A query is used exactly once, in one handler Write it directly against DbContext. A spec adds indirection for no reuse.
Simple CRUD app with a handful of trivial queries You probably don’t need it yet. Add it when the duplication actually shows up.

Conclusion

A repository is good at CRUD and bad at expressing “find me the things that match these rules”. The Specification pattern fills that gap by turning a query into a small, named, testable object: the repository stays at three read methods, business rules get defined once, and those rules can be combined, unit tested, and reused for both querying and validation.

The detail that makes or breaks it in .NET is the one most tutorials skip: hold the rule as an Expression<Func<T, bool>>, not a method. Expressions get translated to SQL and filtered in the database. Methods force EF Core to pull every row into memory first. And when you combine expressions, rewrite the parameters so both halves refer to the same one.

Build it yourself once to understand it, then reach for Ardalis.Specification when your needs grow. And remember the pattern doesn’t depend on repositories at all. Whether you keep a repository layer or use DbContext directly, it’s simply a good way to give your queries names.

Further reading

Found this useful? Share it

XLinkedIn

Discussion