Skip to ContentSkip to Content
Criteria Pattern

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:

InterfaceReturnsUse 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

PracticeReason
One class per querySingle responsibility — easier to name, find, and test
Accept parameters in the constructorCriteria objects are lightweight value-like objects
Name criteria after the query intentActiveProductsByCategory not ProductCriteria1
Use records for output DTOsImmutable, structural equality, great for API responses
Keep criteria in a /Criteria folderConsistent project structure across teams
Avoid joining across aggregate rootsCriteria should stay within one aggregate boundary