When you query data with EF Core, the same filters, includes and ordering often show up in more than one place.
Over time that logic gets copied across endpoints, handlers and repositories. Small differences creep in and the same business rule is expressed two or three ways.
Repositories then grow into long lists of methods like GetOrdersByEmail, GetOrderWithProducts and GetOrdersByEmailPaged. Each one hardcodes another query shape.
That's where a query specification comes in. It packages a query's criteria and shape into a reusable object you can apply to any IQueryable.
Specification Pattern
The name Specification is easy to mix up. The classic GoF / DDD pattern is not the same thing as what we'll build for EF Core.
A classic specification is a business rule over an object already in memory. It answers, does this entity satisfy the rule?
public interface ISpecification<in T>
{
bool IsSatisfiedBy(T candidate);
}
Those specs often compose with And, Or and Not. A typical example is, is this order eligible for a discount? You evaluate a loaded entity. No database involved.
What we'll use here is a query specification (sometimes called a query object). It does not answer IsSatisfiedBy. It describes an EF Core query:
- Criteria - Maps to Where(...)
- IncludeExpressions - Maps to Include(...)
- OrderBy / paging - Maps to sort and page size
So this is query encapsulation, not composition of domain rules. That is why there is no IsSatisfiedBy, And or Or in the sample.
Both approaches are valid. With EF Core, the query style is often more useful day to day. Libraries like Ardalis.Specification follow the same idea.
A query specification describes what to query, not how to talk to the database. Filtering, eager loading, sorting and paging live in one place.
Callers pass a specification into a shared evaluator. The evaluator turns that object into an EF Core query.
This keeps query shapes testable and reusable. Endpoints stay thin and you avoid scattering the same Where and Include calls everywhere.
It pairs well with EF Core features you've probably used already. For related data, see my post on Eager Loading. For paging approaches, check out Keyset Pagination.
Domain Model
We'll use a simple order and product model on PostgreSQL with ApplicationDbContext.
Entities share a small base type with an id:
public abstract class Entity
{
public Guid Id { get; private set; }
protected Entity(Guid id) => Id = id;
}
An Order belongs to a customer email, has a price and can include many products:
public class Order : Entity
{
public string CustomerEmail { get; private set; } = string.Empty;
public decimal Price { get; private set; }
public ICollection<Product> Products { get; private set; } = new List<Product>();
private Order(Guid id) : base(id)
{
}
public static Order Create(string customerEmail, decimal price) =>
new(Guid.NewGuid())
{
CustomerEmail = customerEmail,
Price = price
};
public void AddProduct(Product product)
{
if (Products.Any(p => p.Id == product.Id))
{
return;
}
Products.Add(product);
}
}
And a Product with a name and price:
public class Product : Entity
{
public string Name { get; private set; } = string.Empty;
public decimal Price { get; private set; }
public ICollection<Order> Orders { get; private set; } = new List<Order>();
private Product(Guid id) : base(id)
{
}
public static Product Create(string name, decimal price) =>
new(Guid.NewGuid())
{
Name = name,
Price = price
};
}
Base Specification
The base class holds everything a query might need, criteria, includes, ordering and optional paging:
public abstract class Specification<TEntity> where TEntity : Entity
{
protected Specification(Expression<Func<TEntity, bool>>? criteria) =>
Criteria = criteria;
public Expression<Func<TEntity, bool>>? Criteria { get; }
public List<Expression<Func<TEntity, object>>> IncludeExpressions { get; } = [];
public Expression<Func<TEntity, object>>? OrderByExpression { get; private set; }
public Expression<Func<TEntity, object>>? OrderByDescendingExpression { get; private set; }
public int? Skip { get; private set; }
public int? Take { get; private set; }
public bool IsPagingEnabled => Skip.HasValue && Take.HasValue;
protected void AddInclude(Expression<Func<TEntity, object>> includeExpression) =>
IncludeExpressions.Add(includeExpression);
protected void AddOrderBy(Expression<Func<TEntity, object>> orderByExpression) =>
OrderByExpression = orderByExpression;
protected void AddOrderByDescendingExpression(
Expression<Func<TEntity, object>> orderByDescendingExpression) =>
OrderByDescendingExpression = orderByDescendingExpression;
protected void ApplyPaging(int page, int pageSize)
{
ArgumentOutOfRangeException.ThrowIfLessThan(page, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 1);
Skip = (page - 1) * pageSize;
Take = pageSize;
}
}
Criteria is the filter expression. Protected helpers let derived specs add includes, sorting and paging without exposing mutable setters to callers.
NOTE: Expressions stay as expression trees so EF Core can translate them to SQL. Do not force client evaluation inside a specification.
Specification Evaluator
The evaluator applies a specification to an IQueryable. This is the only place that knows about EF Core APIs like Include and AsNoTracking:
public static class SpecificationEvaluator
{
public static IQueryable<TEntity> GetQuery<TEntity>(
IQueryable<TEntity> inQueryable,
Specification<TEntity> specification) where TEntity : Entity
{
var queryable = inQueryable.AsNoTracking();
if (specification.Criteria is not null)
{
queryable = queryable.Where(specification.Criteria);
}
queryable = specification.IncludeExpressions.Aggregate(
queryable,
(current, includeExpression) => current.Include(includeExpression));
if (specification.OrderByExpression is not null)
{
queryable = queryable.OrderBy(specification.OrderByExpression);
}
else if (specification.OrderByDescendingExpression is not null)
{
queryable = queryable.OrderByDescending(specification.OrderByDescendingExpression);
}
if (specification.IsPagingEnabled)
{
queryable = queryable
.Skip(specification.Skip!.Value)
.Take(specification.Take!.Value);
}
return queryable;
}
}
Order matters. Filter first, then includes, then sorting, then paging. That matches how you usually build LINQ queries by hand.
Read queries use AsNoTracking here because specifications in this sample are for queries, not tracked updates.
Concrete Specifications
Each use case becomes a small sealed class. Loading one order with its products looks like this:
public sealed class OrderByIdWithProducts : Specification<Order>
{
public OrderByIdWithProducts(Guid orderId)
: base(order => order.Id == orderId)
{
AddInclude(order => order.Products);
}
}
Criteria go in the base constructor. Includes are added in the body.
Listing orders for a customer email with optional paging:
public sealed class OrdersByCustomerEmail : Specification<Order>
{
public OrdersByCustomerEmail(string email, int? page = null, int? pageSize = null)
: base(order => order.CustomerEmail == email)
{
AddOrderByDescendingExpression(order => order.Price);
if (page is not null && pageSize is not null)
{
ApplyPaging(page.Value, pageSize.Value);
}
}
}
The name of the class documents the intent. You no longer need a repository method for every combination of filter and include.
Using Specifications
In a Minimal API endpoint, create the specification and pass it to the evaluator with your DbSet:
app.MapGet("orders/{id:guid}", async (Guid id, ApplicationDbContext dbContext) =>
{
var spec = new OrderByIdWithProducts(id);
var order = await SpecificationEvaluator
.GetQuery(dbContext.Orders, spec)
.FirstOrDefaultAsync();
if (order is null)
{
return Results.NotFound();
}
return Results.Ok(new OrderResponse(
order.Id,
order.CustomerEmail,
order.Price,
order.Products
.Select(p => new ProductResponse(p.Id, p.Name, p.Price))
.ToList()));
});
Listing by email follows the same pattern:
app.MapGet("orders", async (
string? email,
int? page,
int? pageSize,
ApplicationDbContext dbContext) =>
{
if (string.IsNullOrWhiteSpace(email))
{
return Results.BadRequest("Query parameter 'email' is required.");
}
var orders = await SpecificationEvaluator
.GetQuery(dbContext.Orders, new OrdersByCustomerEmail(email, page, pageSize))
.ToListAsync();
return Results.Ok(orders.Select(order => new OrderResponse(
order.Id,
order.CustomerEmail,
order.Price,
[])));
});
The endpoint validates input, builds a specification and maps the result. Query composition stays out of the handler body.
You can use the same specifications from a repository or a MediatR handler. The evaluator does not care where the IQueryable comes from.
Ardalis.Specification
If you want this approach without building the base class and evaluator yourself, use Ardalis.Specification.
Install the core package plus the EF Core integration:
dotnet add package Ardalis.Specification
dotnet add package Ardalis.Specification.EntityFrameworkCore
Specs inherit from Specification<T> and configure the query through the Query builder:
public sealed class OrdersByCustomerEmailSpec : Specification<Order>
{
public OrdersByCustomerEmailSpec(string email)
{
Query
.Where(order => order.CustomerEmail == email)
.OrderByDescending(order => order.Price);
}
}
Apply it with WithSpecification on any DbSet or IQueryable:
var spec = new OrdersByCustomerEmailSpec(email);
var orders = await dbContext.Orders
.WithSpecification(spec)
.ToListAsync();
Same idea as our handmade sample, filters, includes, ordering and paging encapsulated as named query objects, with a maintained library behind it.
Building it yourself is great for learning and for a small surface area. For a larger app, Ardalis is often the faster path.
Docs and examples live at specification.ardalis.com.
Conclusion
This post covers query specifications with EF Core, encapsulating Criteria, Include, OrderBy and paging, not the classic GoF Specification with IsSatisfiedBy / And / Or.
A thin base class plus a SpecificationEvaluator is enough to keep endpoints clean and queries consistent. Or use Ardalis.Specification when you want that out of the box.
Start with the queries you repeat most. Once those live as specifications, adding new shapes becomes straightforward.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!



