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.
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 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
whererule). - 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
Steps
Specification
Holds the rules as expressions
Evaluator
Applies rules to IQueryable
DbSet / SQL
EF Core builds one SQL query
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.
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
Steps
HTTP request
User asks for customers in a city
Pick spec
Service builds the right specification
Evaluate
Rules applied to DbSet
One SQL query
Single round trip to the database
Return list
Mapped results sent back
Repository or no repository?
You do not have to abandon repositories entirely. Both choices are valid. The table below helps you decide.
| Question | Use a thin repository | Apply spec to DbSet directly |
|---|---|---|
| Do you want to hide EF Core from your domain layer? | Yes | No |
| Do you mock data access in unit tests a lot? | Yes | Not so much |
| Do you value the least amount of code? | No | Yes |
| Is your team comfortable with EF Core everywhere? | No | Yes |
| Do you need to swap the database engine later? | Maybe | Probably 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.
Common mistakes to avoid
The pattern is simple, but a few traps catch people. Here is a quick reference.
| Mistake | Why it hurts | Better approach |
|---|---|---|
Returning a List from inside the specification | The spec should describe, not execute | Keep IQueryable until the service calls ToListAsync |
| Applying paging before filtering | You page the wrong rows | Always page last in the evaluator |
One giant specification with many if flags | Becomes a fat repository in disguise | Many small specs, combine when needed |
Using Include for a column you only filter on | Loads data you do not need | Only include navigation properties you actually read |
| Putting business logic inside the evaluator | Mixes "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
Steps
Domain layer
Holds specifications, the rules
Application layer
Services pick the right spec
Infrastructure layer
Evaluator and DbContext run it
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
DbContextalready 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
- Implementing the infrastructure persistence layer with EF Core — Microsoft Learn
- Add Sort, Filter, and Paging with EF Core — Microsoft Learn
- Specification Pattern in EF Core: Flexible Data Access Without Repositories — antondevtips
- Why You Don't Need a Repository in EF Core — antondevtips
- Implementing Query Specification pattern in Entity Framework Core — Gunnar Peipman
- Specification Pattern in ASP.NET Core — codewithmukesh
Related Patterns
The Repository Pattern in .NET: A Friendly, Complete Guide
Learn the Repository Pattern in .NET with simple real-life examples, EF Core code, diagrams, and honest advice on when to use it and when to skip it.
Implementing the Unit of Work Pattern in EF Core
Learn the Unit of Work pattern in EF Core with simple C# examples, diagrams, transactions, and when DbContext alone is already enough for you.
CQRS Pattern with MediatR in .NET: A Friendly Guide
Learn the CQRS pattern with MediatR in .NET using simple words, clear diagrams, and real C# code. Beginner friendly, with pitfalls and licensing notes.
Scaling the Outbox Pattern in .NET: From Hundreds to Billions of Messages
Scale the Outbox Pattern in .NET to billions of messages a day with batching, indexes, SKIP LOCKED, and parallel workers — explained simply with diagrams.
CQRS Pattern in .NET: The Way It Should Have Been From the Start
A friendly, hands-on guide to the CQRS pattern in .NET 10. Learn commands, queries, handlers, diagrams, and when to actually use it.
Decorator Pattern in ASP.NET Core: A Friendly Guide
Learn the Decorator pattern in ASP.NET Core with simple examples, Scrutor, caching and logging decorators, and clear diagrams for beginners.