This guide explains how to write reusable, composable query specifications using the Ardalis.Specification library. Specifications encapsulate EF Core Where, OrderBy, Include, Take, and Skip logic so it stays out of repositories and services.
The specification pattern separates query logic from the data-access layer:
Service
→ new ProductSpecification(filter) ← encapsulates all query logic
→ repository.ListAsync(specification) ← RepositoryBase executes it
→ EF Core generates optimised SQL
The RepositoryBase<T> in this project inherits from Ardalis.Specification.EntityFrameworkCore.RepositoryBase<T>, which provides ListAsync(spec), CountAsync(spec), and FirstOrDefaultAsync(spec) out of the box.
A Specification<TEntity, TResult> selects a projection DTO directly in SQL. Do not include Skip/Take — pagination is handled by RepositoryBase.GetPagedAsync:
// Application/Specifications/ProductSpecification.cs
public sealed class ProductSpecification : Specification<Product, ProductResponse>
{
public ProductSpecification(ProductFilter filter)
{
Query.ApplyFilter(filter);
Query.OrderByDescending(p => p.CreatedAt)
.Select(p => new ProductResponse(p.Id, p.Name, p.Description, p.Price, p.CreatedAt));
// No Skip/Take here — pagination is handled by repository.GetPagedAsync()
}
}Important:
GetPagedAsync(spec, pageNumber, pageSize, ct)applies pagination and retrieves the total count in a single SQL query, eliminating the need for a separate count specification.
Extract reusable Where clauses into an extension method on ISpecificationBuilder<T>:
// Application/Specifications/ProductFilterCriteria.cs
internal static class ProductFilterCriteria
{
internal static void ApplyFilter(this ISpecificationBuilder<Product> query, ProductFilter filter)
{
if (!string.IsNullOrWhiteSpace(filter.Name))
query.Where(p => p.Name.Contains(filter.Name));
if (filter.MinPrice.HasValue)
query.Where(p => p.Price >= filter.MinPrice.Value);
if (filter.MaxPrice.HasValue)
query.Where(p => p.Price <= filter.MaxPrice.Value);
if (filter.CreatedFrom.HasValue)
query.Where(p => p.CreatedAt >= filter.CreatedFrom.Value);
if (filter.CreatedTo.HasValue)
query.Where(p => p.CreatedAt <= filter.CreatedTo.Value);
}
}Define filter criteria for your entity. By convention, name the file <Entity>FilterCriteria.cs.
src/APITemplate/Application/Specifications/OrderFilterCriteria.cs
using Ardalis.Specification;
using APITemplate.Application.DTOs;
using APITemplate.Domain.Entities;
namespace APITemplate.Application.Specifications;
internal static class OrderFilterCriteria
{
internal static void ApplyFilter(this ISpecificationBuilder<Order> query, OrderFilter filter)
{
if (filter.CustomerId.HasValue)
query.Where(o => o.CustomerId == filter.CustomerId.Value);
if (filter.MinAmount.HasValue)
query.Where(o => o.TotalAmount >= filter.MinAmount.Value);
if (filter.MaxAmount.HasValue)
query.Where(o => o.TotalAmount <= filter.MaxAmount.Value);
if (filter.CreatedFrom.HasValue)
query.Where(o => o.CreatedAt >= filter.CreatedFrom.Value);
if (filter.CreatedTo.HasValue)
query.Where(o => o.CreatedAt <= filter.CreatedTo.Value);
}
}Combines filtering, ordering, and projection in one place. Do not add Skip/Take — pagination is handled by RepositoryBase.GetPagedAsync.
src/APITemplate/Application/Specifications/OrderSpecification.cs
using Ardalis.Specification;
using APITemplate.Application.DTOs;
using APITemplate.Domain.Entities;
namespace APITemplate.Application.Specifications;
public sealed class OrderSpecification : Specification<Order, OrderResponse>
{
public OrderSpecification(OrderFilter filter)
{
Query.ApplyFilter(filter);
Query.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderResponse(o.Id, o.CustomerId, o.TotalAmount, o.CreatedAt));
// No Skip/Take here — pagination is handled by repository.GetPagedAsync()
}
}When you need to load related entities, use .Include() instead of returning a projection:
src/APITemplate/Application/Specifications/OrderByIdSpecification.cs
using Ardalis.Specification;
using APITemplate.Domain.Entities;
namespace APITemplate.Application.Specifications;
public sealed class OrderByIdSpecification : Specification<Order>
{
public OrderByIdSpecification(Guid id)
{
Query.Where(o => o.Id == id)
.Include(o => o.Items) // eager load order items
.AsNoTracking(); // read-only — no change tracking overhead
}
}Tip: Always use
.AsNoTracking()for read-only queries to improve performance.
public async Task<PagedResponse<OrderResponse>> GetAllAsync(
OrderFilter filter, CancellationToken ct = default)
{
return await _repository.GetPagedAsync(
new OrderSpecification(filter), filter.PageNumber, filter.PageSize, ct);
}
public async Task<OrderResponse?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
// Using RepositoryBase.FirstOrDefaultAsync with a specification:
var order = await _repository.FirstOrDefaultAsync(new OrderByIdSpecification(id), ct);
return order?.ToResponse();
}These are provided by RepositoryBase<T> (via Ardalis):
| Method | Description |
|---|---|
GetPagedAsync(spec, pageNumber, pageSize, ct) |
Returns paginated results with total count in a single SQL query |
ListAsync(spec) |
Returns all matching entities (or projected DTOs) |
CountAsync(spec) |
Returns the count of matching entities |
FirstOrDefaultAsync(spec) |
Returns first match or null |
AnyAsync(spec) |
Returns true if any match exists |
GetByIdAsync(id) |
Finds by primary key (no specification needed) |
AddAsync(entity) |
Stages entity for insert |
UpdateAsync(entity) |
Stages entity for update |
DeleteAsync(id) |
Finds and stages entity for delete |
AsQueryable() |
Raw IQueryable<T> — used by GraphQL resolvers |
Filter by a navigation property:
public sealed class OrdersByCustomerSpecification : Specification<Order, OrderResponse>
{
public OrdersByCustomerSpecification(Guid customerId)
{
Query.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderResponse(o.Id, o.CustomerId, o.TotalAmount, o.CreatedAt));
}
}Filter based on a related entity's property (EF Core generates a JOIN):
public sealed class ReviewByProductIdSpecification : Specification<ProductReview, ProductReviewResponse>
{
public ReviewByProductIdSpecification(Guid productId)
{
Query.Where(r => r.ProductId == productId)
.OrderByDescending(r => r.CreatedAt)
.Select(r => new ProductReviewResponse(
r.Id, r.ProductId, r.ReviewerName, r.Comment, r.Rating, r.CreatedAt));
}
}- Create
<Entity>FilterCriteria.cswithApplyFilter()extension method - Create
<Entity>Specification.cs(filter + sort + projection — no Skip/Take) - Create single-item specifications as needed (with
.Include()) - Use
repository.GetPagedAsync(spec, pageNumber, pageSize, ct)in the service - No registration needed — specifications are plain classes
| File | Purpose |
|---|---|
Application/Features/Product/Specifications/ProductSpecification.cs |
Filter + sort + projection example (no Skip/Take) |
Application/Features/Product/Specifications/ProductFilterCriteria.cs |
Shared Where criteria extension |
Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs |
Single-relation filter |
Infrastructure/Repositories/RepositoryBase.cs |
Base repository — provides GetPagedAsync for single-query pagination |
Application/Features/Product/Services/ProductService.cs |
Usage of GetPagedAsync(spec, pageNumber, pageSize, ct) |