Skip to main content
SEMastery

Specification Pattern in EF Core: Flexible Data Access Without Repositories

Learn the Specification pattern in EF Core to build reusable, testable, composable queries without piling up repository methods or hiding IQueryable.

13 min readUpdated April 27, 2026

A shopping list at the vegetable market

Imagine your mother sends you to the sabzi market. She does not walk with you to every stall. Instead, she hands you a small list written on paper: "Get tomatoes that are red and firm. Only the ones under 40 rupees a kilo. Bring half a kilo."

That paper is a specification. It describes what you want, but it does not say how to walk to the market, which stall to visit, or how to count the money. You, the shopkeeper, and the weighing scale handle the "how". The paper only carries the rules.

The Specification pattern in EF Core works exactly like that paper list. A specification is a small object that describes the rules of a query: which rows to keep, what related data to bring, how to sort, and which page to show. Your data layer reads the paper and turns it into one SQL query. The rules stay separate from the machinery.

This post shows you how to build that paper list in C#, how to apply it to EF Core, and why you often do not need a repository on top.

The problem we are trying to solve

When a project is young, data access is easy. You write a query, it works, you move on. But projects grow. New filters arrive. New sort orders arrive. New screens need slightly different data.

Many teams answer this by adding more and more methods to a repository class:

public interface ICustomerRepository
{
    Task<Customer?> GetByIdAsync(int id);
    Task<List<Customer>> GetActiveCustomersAsync();
    Task<List<Customer>> GetActiveCustomersFromCityAsync(string city);
    Task<List<Customer>> GetActiveCustomersWithOrdersAsync();
    Task<List<Customer>> GetActiveCustomersFromCityWithOrdersPagedAsync(
        string city, int page, int size);
    // ...this list never stops growing
}

Each new requirement adds a new method. Soon the interface has thirty methods, and half of them differ by only one filter. This is sometimes called the "fat repository" smell. The repository either does too little (so people bypass it) or too much (so nobody understands it).

The fat repository keeps growing one method at a time.

The Specification pattern fixes this by moving each rule into its own small, named object. Instead of a new method per query, you write a new specification only when you have a genuinely new rule. And you can mix small rules together.

What is a specification, really?

A specification is a plain object that holds the parts of a query as expressions:

  • A filter (the where rule).
  • A list of includes (related data to load).
  • An order by rule.
  • Paging information (skip and take).

It does not run the query. It does not touch the database. It just carries the description. Something else reads that description and applies it to EF Core's IQueryable.

Here is a clear mental picture of the three roles.

Three roles in the pattern

Specification
Evaluator
DbSet / SQL

Steps

1

Specification

Holds the rules as expressions

2

Evaluator

Applies rules to IQueryable

3

DbSet / SQL

EF Core builds one SQL query

The specification describes; the evaluator translates; EF Core runs.

Building the base specification

We start with a base class that every concrete specification will inherit from. It stores the expressions and gives small helper methods so the rules read nicely.

using System.Linq.Expressions;
 
public abstract class Specification<T>
{
    // The "where" rule. Null means "no filter".
    public Expression<Func<T, bool>>? Criteria { get; private set; }
 
    // Related tables to load with Include.
    public List<Expression<Func<T, object>>> Includes { get; } = new();
 
    // Sorting rules.
    public Expression<Func<T, object>>? OrderBy { get; private set; }
    public Expression<Func<T, object>>? OrderByDescending { get; private set; }
 
    // Paging.
    public int Take { get; private set; }
    public int Skip { get; private set; }
    public bool IsPagingEnabled { get; private set; }
 
    protected void Where(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;
        IsPagingEnabled = true;
    }
}

Notice that nothing here runs a query. The class only remembers choices. Think of it as the empty paper your mother fills in.

Writing a real specification

Now let us write a concrete rule. Say we want active customers from a given city, sorted by name, with their orders loaded. We write a class for that.

public class ActiveCustomersFromCitySpec : Specification<Customer>
{
    public ActiveCustomersFromCitySpec(string city)
    {
        Where(c => c.IsActive && c.City == city);
        AddInclude(c => c.Orders);
        ApplyOrderBy(c => c.Name);
    }
}

That is the whole rule. It reads almost like an English sentence. Anyone on the team can open this file and understand exactly what data it asks for. Compare that to a method named GetActiveCustomersFromCityWithOrdersOrderedByName buried in a 600-line repository.

The evaluator: turning paper into SQL

Something must read the specification and apply it to EF Core. That something is the evaluator. It takes a starting IQueryable<T> and the specification, then layers each rule on top.

public static class SpecificationEvaluator
{
    public static IQueryable<T> Apply<T>(
        IQueryable<T> input,
        Specification<T> spec) where T : class
    {
        var query = input;
 
        // 1. Apply the where filter, if any.
        if (spec.Criteria is not null)
            query = query.Where(spec.Criteria);
 
        // 2. Apply all the includes.
        query = spec.Includes.Aggregate(
            query, (current, include) => current.Include(include));
 
        // 3. Apply ordering.
        if (spec.OrderBy is not null)
            query = query.OrderBy(spec.OrderBy);
        else if (spec.OrderByDescending is not null)
            query = query.OrderByDescending(spec.OrderByDescending);
 
        // 4. Apply paging last.
        if (spec.IsPagingEnabled)
            query = query.Skip(spec.Skip).Take(spec.Take);
 
        return query;
    }
}

The order matters. Filter first, then includes, then ordering, then paging. Paging must come last, otherwise you would skip and take before filtering, which gives the wrong page.

How the evaluator layers each rule onto the query.

Using it without a repository

Here is the part many people find surprising. EF Core's DbContext already implements both the Repository pattern and the Unit of Work pattern for you. A DbSet<Customer> is a queryable collection. SaveChanges is the unit of work commit. So you often do not need to wrap EF Core in another repository.

You can apply the specification straight to the DbSet:

public class CustomerService
{
    private readonly AppDbContext _db;
 
    public CustomerService(AppDbContext db) => _db = db;
 
    public async Task<List<Customer>> GetAsync(string city)
    {
        var spec = new ActiveCustomersFromCitySpec(city);
 
        // Apply the specification to the DbSet, then run it.
        return await SpecificationEvaluator
            .Apply(_db.Customers, spec)
            .ToListAsync();
    }
}

This is clean. The query rules live in the specification. The service just picks the right specification and runs it. EF Core turns the whole thing into one SQL statement, with the WHERE, the JOIN for includes, the ORDER BY, and the OFFSET ... FETCH for paging, all in a single round trip to the database.

Request to result, no repository

HTTP request
Pick spec
Evaluate
One SQL query
Return list

Steps

1

HTTP request

User asks for customers in a city

2

Pick spec

Service builds the right specification

3

Evaluate

Rules applied to DbSet

4

One SQL query

Single round trip to the database

5

Return list

Mapped results sent back

The service chooses a spec, the evaluator builds the query, EF Core runs one SQL statement.

Repository or no repository?

You do not have to abandon repositories entirely. Both choices are valid. The table below helps you decide.

QuestionUse a thin repositoryApply spec to DbSet directly
Do you want to hide EF Core from your domain layer?YesNo
Do you mock data access in unit tests a lot?YesNot so much
Do you value the least amount of code?NoYes
Is your team comfortable with EF Core everywhere?NoYes
Do you need to swap the database engine later?MaybeProbably not needed

If you do want a repository, keep it generic and let it take any specification. That gives you one small method instead of thirty:

public interface IReadRepository<T> where T : class
{
    Task<List<T>> ListAsync(Specification<T> spec);
    Task<T?> FirstOrDefaultAsync(Specification<T> spec);
}
 
public class ReadRepository<T> : IReadRepository<T> where T : class
{
    private readonly AppDbContext _db;
    public ReadRepository(AppDbContext db) => _db = db;
 
    public Task<List<T>> ListAsync(Specification<T> spec)
        => SpecificationEvaluator.Apply(_db.Set<T>(), spec).ToListAsync();
 
    public Task<T?> FirstOrDefaultAsync(Specification<T> spec)
        => SpecificationEvaluator.Apply(_db.Set<T>(), spec).FirstOrDefaultAsync();
}

One generic repository plus many small specifications beats one fat repository with many methods. The rules become data you pass in, not code you bake in.

Combining specifications

Remember the market list. Sometimes you want "red tomatoes" and "under 40 rupees". Two simple rules joined together. You can do the same with specifications by combining their filter expressions.

Because the Criteria is just an Expression<Func<T, bool>>, you can write a small helper that joins two of them with And or Or. A common way is to use the open-source LinqKit library, which adds a PredicateBuilder, but you can also hand-write a tiny combinator. The idea looks like this:

public static class SpecExtensions
{
    public static Expression<Func<T, bool>> And<T>(
        this Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        var param = Expression.Parameter(typeof(T));
        var body = Expression.AndAlso(
            Expression.Invoke(left, param),
            Expression.Invoke(right, param));
        return Expression.Lambda<Func<T, bool>>(body, param);
    }
}

Now small rules like IsActive and FromCity can be reused and mixed in many screens. You write each rule once and combine them as needs change. That is the real power: composition.

Two small rules combine into one bigger query rule.

Common mistakes to avoid

The pattern is simple, but a few traps catch people. Here is a quick reference.

MistakeWhy it hurtsBetter approach
Returning a List from inside the specificationThe spec should describe, not executeKeep IQueryable until the service calls ToListAsync
Applying paging before filteringYou page the wrong rowsAlways page last in the evaluator
One giant specification with many if flagsBecomes a fat repository in disguiseMany small specs, combine when needed
Using Include for a column you only filter onLoads data you do not needOnly include navigation properties you actually read
Putting business logic inside the evaluatorMixes "what" with "how"Keep rules in specs, machinery in the evaluator

One more note for naming. Some people get the words mixed up. Criteria is the filter. Specification is the whole paper, which holds the criteria plus includes, sorting, and paging. A route like GET /customers/{id} is not part of the spec; the controller reads the id and passes it into the specification's constructor.

Where this fits in a clean architecture

In a layered or clean architecture, specifications usually live close to the domain, because they describe domain rules. The evaluator and the DbContext live in the infrastructure layer, because they know about EF Core. This keeps the dependency arrow pointing the right way: infrastructure depends on the domain, not the other way around.

Where each piece lives

Domain layer
Application layer
Infrastructure layer

Steps

1

Domain layer

Holds specifications, the rules

2

Application layer

Services pick the right spec

3

Infrastructure layer

Evaluator and DbContext run it

Rules sit near the domain; the machinery sits in infrastructure.

This separation is exactly why the pattern pairs so well with CQRS. On the read side, a query handler picks a specification and runs it. On the write side, you load an entity, change it, and call SaveChanges. The specification keeps your read queries tidy and reusable.

A note on performance and the licensing landscape

A specification only builds an IQueryable. EF Core still translates everything into one SQL query, so there is no extra database trip and no hidden N+1 problem, as long as your includes are sensible. The tiny cost of creating a small spec object is nothing compared to a database call.

If you reach for a popular ready-made library such as Ardalis.Specification, it is open source and free to use, which is convenient because you do not have to hand-write the evaluator. This is worth saying clearly because the .NET messaging space changed recently: MediatR and MassTransit moved to commercial licensing for newer versions. The Specification pattern itself is just a design idea with no license at all, and the common specification libraries remain free. So you can adopt this pattern without any licensing worry.

For very hot paths where you want the absolute fastest read, you can still drop down to a hand-written compiled query. The Specification pattern and raw queries are not enemies; use the spec for the common 95 percent and a tuned query for the rare hot spot.

Quick recap

  • A specification is a small object that describes a query: filter, includes, order, and paging. It is like a shopping list your mother writes.
  • The evaluator reads the specification and applies each rule to EF Core's IQueryable, in the right order, ending with paging.
  • EF Core's DbContext already gives you a repository and a unit of work, so you often do not need another repository on top.
  • If you do want one, keep it generic and let it accept any specification. One method beats thirty.
  • You can combine small specifications, which gives real reuse: write a rule once, mix it many ways.
  • Keep specs near the domain and the evaluator in infrastructure so dependencies point the right way.
  • There is no performance penalty: it is still one SQL query. The pattern is free of any licensing concerns, unlike newer MediatR and MassTransit versions.

References and further reading

Related Patterns