Criteria Pattern
The criteria pattern is Forge.Repository’s answer to the “fat repository” problem. Instead of adding an endless list of query methods to your repository or scattering complex LINQ expressions across your service layer, you encapsulate each query in a dedicated, single-responsibility class.
Every criteria object receives an IQueryable<TEntity> and returns results — so you get the full power of LINQ, EF Core’s query pipeline, and lazy composition, while keeping your services clean.
The Interfaces
Forge defines five criteria interfaces covering every query shape:
| Interface | Returns | Use case |
|---|---|---|
ICriteriaAsync<T> | Task<IList<T>> | Filtered list of the same entity type |
ICriteriaSingleAsync<T> | Task<T> | Single entity of the same type |
ICriteriaForeignEntity<T, TF> | Task<IList<TF>> | Projected list — DTOs, view models |
ICriteriaSingleForeignEntity<T, TF> | Task<TF> | Single projection |
ICountCriteriaAsync<T> | Task<int> | Count with complex filtering logic |
ICriteriaAsync<T>
Use when you need a filtered, ordered, or paginated list of the same entity type.
namespace Forge.Interfaces;
public interface ICriteriaAsync<TEntity> where TEntity : class, IBaseModel
{
Task<IList<TEntity>> MatchQueryFromAsync(
IQueryable<TEntity> data,
CancellationToken cancellationToken);
}Example — filtered and sorted list
public class ActiveProductsByCategory : ICriteriaAsync<Product>
{
private readonly string _categoryId;
private readonly int _page;
private readonly int _pageSize;
public ActiveProductsByCategory(string categoryId, int page = 1, int pageSize = 20)
{
_categoryId = categoryId;
_page = page;
_pageSize = pageSize;
}
public async Task<IList<Product>> MatchQueryFromAsync(
IQueryable<Product> data, CancellationToken ct)
=> await data
.Where(p => p.CategoryId == _categoryId && !p.IsDeleted)
.OrderByDescending(p => p.CreatedOn)
.Skip((_page - 1) * _pageSize)
.Take(_pageSize)
.ToListAsync(ct);
}Usage
var products = await _repo.MatchAsync(
new ActiveProductsByCategory("electronics", page: 2, pageSize: 10), ct);ICriteriaSingleAsync<T>
Use when you need exactly one entity matching complex conditions that a simple predicate cannot express.
namespace Forge.Interfaces;
public interface ICriteriaSingleAsync<TEntity> where TEntity : class, IBaseModel
{
Task<TEntity> MatchQueryFromAsync(
IQueryable<TEntity> data,
CancellationToken cancellationToken);
}Example — single entity with includes
public class FeaturedProductCriteria : ICriteriaSingleAsync<Product>
{
public async Task<Product> MatchQueryFromAsync(
IQueryable<Product> data, CancellationToken ct)
=> await data
.Include(p => p.Images)
.Include(p => p.Tags)
.Where(p => p.IsFeatured && !p.IsDeleted)
.OrderByDescending(p => p.ModifiedOn)
.FirstOrDefaultAsync(ct);
}Usage
var featured = await _repo.MatchAsync(new FeaturedProductCriteria(), ct);ICriteriaForeignEntity<T, TF>
Use when you want to project entities into a different type — a DTO, a view model, or an anonymous projection. The source entity type T is the repository’s entity; TF is the output type.
namespace Forge.Interfaces;
public interface ICriteriaForeignEntity<in TEntity, TFEntity>
where TEntity : class, IBaseModel
where TFEntity : class
{
Task<IList<TFEntity>> MatchQueryFromAsync(
IQueryable<TEntity> data,
CancellationToken cancellationToken);
}Example — DTO projection
// The output DTO
public record ProductSummaryDto(
string Id,
string Name,
decimal Price,
string CategoryId,
DateTime CreatedOn);
// The criteria
public class ProductSummaryQuery
: ICriteriaForeignEntity<Product, ProductSummaryDto>
{
private readonly string? _categoryId;
public ProductSummaryQuery(string? categoryId = null)
=> _categoryId = categoryId;
public async Task<IList<ProductSummaryDto>> MatchQueryFromAsync(
IQueryable<Product> data, CancellationToken ct)
{
var query = data.Where(p => !p.IsDeleted);
if (_categoryId is not null)
query = query.Where(p => p.CategoryId == _categoryId);
return await query
.OrderBy(p => p.Name)
.Select(p => new ProductSummaryDto(
p.Id, p.Name, p.Price, p.CategoryId, p.CreatedOn))
.ToListAsync(ct);
}
}Usage
// All products as DTOs
var all = await _repo.MatchAsync<ProductSummaryDto>(
new ProductSummaryQuery(), ct);
// Filtered to a category
var electronics = await _repo.MatchAsync<ProductSummaryDto>(
new ProductSummaryQuery("electronics"), ct);ICriteriaSingleForeignEntity<T, TF>
Projection for a single output entity.
namespace Forge.Interfaces;
public interface ICriteriaSingleForeignEntity<in TEntity, TFEntity>
where TEntity : class, IBaseModel
where TFEntity : class
{
Task<TFEntity> MatchQueryFromAsync(
IQueryable<TEntity> data,
CancellationToken cancellationToken);
}Example — detail view model
public record ProductDetailDto(
string Id, string Name, decimal Price,
string Description, IList<string> Tags, IList<string> ImageUrls);
public class ProductDetailQuery
: ICriteriaSingleForeignEntity<Product, ProductDetailDto>
{
private readonly string _id;
public ProductDetailQuery(string id) => _id = id;
public async Task<ProductDetailDto> MatchQueryFromAsync(
IQueryable<Product> data, CancellationToken ct)
=> await data
.Include(p => p.Tags)
.Include(p => p.Images)
.Where(p => p.Id == _id && !p.IsDeleted)
.Select(p => new ProductDetailDto(
p.Id,
p.Name,
p.Price,
p.Description,
p.Tags.Select(t => t.Name).ToList(),
p.Images.Select(i => i.Url).ToList()))
.FirstOrDefaultAsync(ct);
}Usage
var detail = await _repo.MatchAsync<ProductDetailDto>(
new ProductDetailQuery(id), ct);ICountCriteriaAsync<T>
Use for complex count queries that cannot be expressed as a simple predicate.
namespace Forge.Interfaces;
public interface ICountCriteriaAsync<TEntity> where TEntity : class, IBaseModel
{
Task<int> GetCountFromAsync(
IQueryable<TEntity> data,
CancellationToken cancellationToken);
}Example
public class ActiveProductsInCategoryCriteria : ICountCriteriaAsync<Product>
{
private readonly string _categoryId;
private readonly decimal _maxPrice;
public ActiveProductsInCategoryCriteria(string categoryId, decimal maxPrice = decimal.MaxValue)
{
_categoryId = categoryId;
_maxPrice = maxPrice;
}
public async Task<int> GetCountFromAsync(
IQueryable<Product> data, CancellationToken ct)
=> await data
.CountAsync(p =>
p.CategoryId == _categoryId
&& !p.IsDeleted
&& p.Price <= _maxPrice, ct);
}Usage
var count = await _repo.CountAsync(
new ActiveProductsInCategoryCriteria("electronics", maxPrice: 500m), ct);Combining Criteria in Services
The real power of criteria shows in services — the service method becomes a one-liner, and all query logic lives in dedicated, testable classes.
public class CatalogService(IUnitOfWork uow)
{
private readonly IRepositoryAsync<Product> _products = uow.GetRepositoryAsync<Product>();
// Clean one-liner service methods
public Task<IList<Product>> GetByCategoryAsync(string cat, int page, CancellationToken ct)
=> _products.MatchAsync(new ActiveProductsByCategory(cat, page), ct);
public Task<ProductDetailDto> GetDetailAsync(string id, CancellationToken ct)
=> _products.MatchAsync<ProductDetailDto>(new ProductDetailQuery(id), ct);
public Task<IList<ProductSummaryDto>> GetSummariesAsync(CancellationToken ct)
=> _products.MatchAsync<ProductSummaryDto>(new ProductSummaryQuery(), ct);
public Task<int> GetActiveCountAsync(string cat, CancellationToken ct)
=> _products.CountAsync(new ActiveProductsInCategoryCriteria(cat), ct);
}Unit Testing Criteria
Because criteria receive an IQueryable<T>, you can unit test them with an in-memory list — no database required.
public class ActiveProductsByCategoryTests
{
[Fact]
public async Task Returns_only_active_products_in_category()
{
var products = new[]
{
new Product { Id = "1", CategoryId = "electronics", IsDeleted = false },
new Product { Id = "2", CategoryId = "electronics", IsDeleted = true },
new Product { Id = "3", CategoryId = "books", IsDeleted = false },
}.AsQueryable();
var criteria = new ActiveProductsByCategory("electronics");
var results = await criteria.MatchQueryFromAsync(products, CancellationToken.None);
Assert.Single(results);
Assert.Equal("1", results[0].Id);
}
}For criteria that use EF Core-specific methods like .Include() or .ToListAsync(), use an in-memory EF Core database (UseInMemoryDatabase) in tests rather than a plain IQueryable.
Best Practices
| Practice | Reason |
|---|---|
| One class per query | Single responsibility — easier to name, find, and test |
| Accept parameters in the constructor | Criteria objects are lightweight value-like objects |
| Name criteria after the query intent | ActiveProductsByCategory not ProductCriteria1 |
| Use records for output DTOs | Immutable, structural equality, great for API responses |
Keep criteria in a /Criteria folder | Consistent project structure across teams |
| Avoid joining across aggregate roots | Criteria should stay within one aggregate boundary |