Pragmatic.Specification
Specification pattern implementation for composable, reusable query predicates in .NET 10.
The Problem
Section titled “The Problem”Business rules that live inside queries get duplicated across repositories, services, and controllers. Each copy uses its own inline lambda. When the rules change, you hunt through every method to update them. Worse, database queries (expression trees) and in-memory checks (compiled delegates) use different code paths that drift apart.
// Repositoryreturn await db.Orders.Where(o => !o.IsCancelled && !o.IsDeleted).ToListAsync(ct);
// Service -- same predicate, different locationreturn await db.Orders.Where(o => !o.IsCancelled && !o.IsDeleted) .Where(o => o.Total >= threshold).ToListAsync(ct);
// Validation helper -- same rule, different syntaxreturn !order.IsCancelled && !order.IsDeleted;Three copies. Zero reuse. No composability. No isolated testing.
The problems multiply:
- Every rule change requires a multi-file hunt. The “active order” rule exists in the repository, the service, the validation helper, and three different reports. Change one, miss another, and you have a subtle data inconsistency.
- SQL and in-memory diverge. The repository uses
Expression<Func<T, bool>>for EF Core translation. The validation helper uses a compiled delegate. When you update the expression, the delegate lags behind (or vice versa). - No testability. Inline lambdas inside repository methods cannot be unit-tested independently. You need a database (or mock) to test a simple business predicate.
- Dynamic filters are brittle. Building a WHERE clause from user input requires conditional
ifchains that concatenate.Where()calls. Each new filter adds another branch.
The Solution
Section titled “The Solution”Define each predicate once as a Specification<T>. Compose with And, Or, Not. Use the same specification for database queries, in-memory filtering, and unit tests.
// Define oncepublic static class OrderSpecs{ public static Specification<Order> Active => Spec<Order>.Where(o => !o.IsCancelled && !o.IsDeleted);
public static Specification<Order> HighValue(decimal threshold) => Spec<Order>.Where(o => o.Total >= threshold);}
// Use everywherevar orders = await db.Orders.Where(OrderSpecs.Active).ToListAsync(ct); // SQLvar filtered = allOrders.Where(OrderSpecs.Active & OrderSpecs.HighValue(1000m)); // In-memorybool isActive = OrderSpecs.Active.IsSatisfiedBy(order); // Unit testOne definition. Dual-path execution (expression tree for SQL, compiled delegate for in-memory). Zero duplication.
Installation
Section titled “Installation”dotnet add package Pragmatic.SpecificationNo source generator reference is needed. This is a pure runtime library with zero NuGet dependencies.
Quick Start
Section titled “Quick Start”Define Specifications
Section titled “Define Specifications”using Pragmatic.Specification;
public static class OrderSpecs{ public static Specification<Order> Active => Spec<Order>.Where(o => !o.IsCancelled && !o.IsDeleted);
public static Specification<Order> HighValue(decimal threshold) => Spec<Order>.Where(o => o.Total >= threshold);
public static Specification<Order> ForCustomer(Guid customerId) => Spec<Order>.Where(o => o.CustomerId == customerId);
public static Specification<Order> PlacedAfter(DateTimeOffset date) => Spec<Order>.Where(o => o.PlacedAt > date);
public static Specification<Order> WithStatus(OrderStatus status) => Spec<Order>.Where(o => o.Status == status);}Compose
Section titled “Compose”// Method chainingvar spec = OrderSpecs.Active.And(OrderSpecs.HighValue(1000m));
// Operator syntaxvar spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m);
// Negationvar notCancelled = !Spec<Order>.Where(o => o.IsCancelled);
// Complex compositionvar vipOrders = OrderSpecs.Active & OrderSpecs.HighValue(5000m) & OrderSpecs.PlacedAfter(DateTimeOffset.UtcNow.AddDays(-30));Use with EF Core (SQL Translation)
Section titled “Use with EF Core (SQL Translation)”using Pragmatic.Specification.Extensions;
var orders = await db.Orders .Where(spec) // Translates to SQL WHERE clause .OrderByDescending(o => o.Total) .ToListAsync();Use with In-Memory Collections
Section titled “Use with In-Memory Collections”var filtered = allOrders.Where(spec); // Uses compiled delegateTest Individual Entities
Section titled “Test Individual Entities”bool matches = spec.IsSatisfiedBy(order); // No database neededCore Types
Section titled “Core Types”ISpecification<T>
Section titled “ISpecification<T>”The contract for all specifications:
public interface ISpecification<T>{ Expression<Func<T, bool>> ToExpression(); // For IQueryable (SQL) bool IsSatisfiedBy(T entity); // For in-memory evaluation}Specification<T>
Section titled “Specification<T>”Abstract base class with built-in composition. The expression is compiled lazily on first IsSatisfiedBy call and cached (thread-safe via Lazy<T>):
| Method | Description |
|---|---|
And(other) | Logical AND: (this AND other) |
Or(other) | Logical OR: (this OR other) |
Not() | Logical NOT: NOT(this) |
AndIf(condition, other) | Conditional AND: applies other only if condition is true |
OrIf(condition, other) | Conditional OR: applies other only if condition is true |
IsSatisfiedBy(entity) | Evaluates against a single entity |
ToExpression() | Returns the expression tree |
Operator overloads:
| Operator | Equivalent |
|---|---|
left & right | left.And(right) |
left | right | left.Or(right) |
!spec | spec.Not() |
Spec<T>
Section titled “Spec<T>”Static factory class:
| Member | Description |
|---|---|
Spec<T>.Where(expression) | Creates a specification from a lambda |
Spec<T>.True | Singleton that matches everything. Identity for And composition. |
Spec<T>.False | Singleton that matches nothing. Identity for Or composition. |
Dynamic Filters from User Input
Section titled “Dynamic Filters from User Input”Use Spec<T>.True as a starting point and conditionally compose:
public Specification<Order> BuildFilter(OrderFilterRequest request){ var spec = Spec<Order>.True;
spec = spec.AndIf(request.CustomerId.HasValue, OrderSpecs.ForCustomer(request.CustomerId!.Value));
spec = spec.AndIf(request.MinTotal.HasValue, OrderSpecs.HighValue(request.MinTotal!.Value));
spec = spec.AndIf(request.ActiveOnly, OrderSpecs.Active);
spec = spec.AndIf(request.Status.HasValue, OrderSpecs.WithStatus(request.Status!.Value));
spec = spec.AndIf(request.PlacedAfter.HasValue, OrderSpecs.PlacedAfter(request.PlacedAfter!.Value));
return spec;}If no conditions match, Spec<T>.True passes through (no unnecessary WHERE clause).
OR-based Filters
Section titled “OR-based Filters”For “any of these criteria” scenarios, start with Spec<T>.False:
public Specification<Product> BuildSearchFilter(ProductSearchRequest request){ var spec = Spec<Product>.False;
if (!string.IsNullOrEmpty(request.NameContains)) spec = spec | Spec<Product>.Where(p => p.Name.Contains(request.NameContains));
if (!string.IsNullOrEmpty(request.SkuEquals)) spec = spec | Spec<Product>.Where(p => p.Sku == request.SkuEquals);
if (request.CategoryId.HasValue) spec = spec | Spec<Product>.Where(p => p.CategoryId == request.CategoryId);
return spec;}Entity Specification Patterns
Section titled “Entity Specification Patterns”Reservation Specifications
Section titled “Reservation Specifications”public static class ReservationSpecs{ public static Specification<Reservation> Active => Spec<Reservation>.Where(r => r.Status != ReservationStatus.Cancelled && r.Status != ReservationStatus.Completed);
public static Specification<Reservation> ForGuest(Guid guestId) => Spec<Reservation>.Where(r => r.GuestId == guestId);
public static Specification<Reservation> OverlappingDates(DateTimeOffset checkIn, DateTimeOffset checkOut) => Spec<Reservation>.Where(r => r.CheckIn < checkOut && r.CheckOut > checkIn);
public static Specification<Reservation> ForProperty(Guid propertyId) => Spec<Reservation>.Where(r => r.PropertyId == propertyId);
// Compose: "Is there an active reservation for this property overlapping these dates?" public static Specification<Reservation> ConflictsWith( Guid propertyId, DateTimeOffset checkIn, DateTimeOffset checkOut) => Active & ForProperty(propertyId) & OverlappingDates(checkIn, checkOut);}Invoice Specifications
Section titled “Invoice Specifications”public static class InvoiceSpecs{ public static Specification<Invoice> Unpaid => Spec<Invoice>.Where(i => i.PaidAt == null);
public static Specification<Invoice> Overdue(IClock clock) => Unpaid & Spec<Invoice>.Where(i => i.DueDate < clock.UtcNow);
public static Specification<Invoice> ForCurrency(string currencyCode) => Spec<Invoice>.Where(i => i.CurrencyCode == currencyCode);}Extension Methods
Section titled “Extension Methods”Pragmatic.Specification.Extensions.SpecificationExtensions provides LINQ integration for both IQueryable<T> and IEnumerable<T>:
| Method | IQueryable | IEnumerable | Description |
|---|---|---|---|
Where(spec) | Uses ToExpression() | Uses IsSatisfiedBy | Filter elements |
Any(spec) | Uses ToExpression() | Uses IsSatisfiedBy | Check if any match |
All(spec) | Uses ToExpression() | Uses IsSatisfiedBy | Check if all match |
Count(spec) | Uses ToExpression() | Uses IsSatisfiedBy | Count matching |
FirstOrDefault(spec) | Uses ToExpression() | Uses IsSatisfiedBy | First match or default |
SingleOrDefault(spec) | Uses ToExpression() | Uses IsSatisfiedBy | Single match or default |
All extension methods are marked [MethodImpl(MethodImplOptions.AggressiveInlining)] for optimal performance.
IQueryable path: The expression tree is passed to the query provider (e.g., EF Core), which translates it to SQL. No in-memory compilation occurs.
IEnumerable path: The expression is compiled to a delegate on first use via Lazy<Func<T, bool>> and cached for subsequent calls.
Internal Specification Types
Section titled “Internal Specification Types”The library provides several internal Specification<T> implementations:
| Type | Description |
|---|---|
ExpressionSpecification<T> | Wraps a user-provided Expression<Func<T, bool>> |
AndSpecification<T> | Combines two specs with Expression.AndAlso |
OrSpecification<T> | Combines two specs with Expression.OrElse |
NotSpecification<T> | Negates a spec with Expression.Not |
TrueSpecification<T> | Singleton, always returns true |
FalseSpecification<T> | Singleton, always returns false |
Expression trees are combined using a ParameterReplacer that unifies lambda parameters, ensuring EF Core can translate the combined expression to a single SQL query.
Testing with Specifications
Section titled “Testing with Specifications”Specifications are independently testable without any database:
[Fact]public void Active_ExcludesCancelledOrders(){ var cancelled = new Order { IsCancelled = true, IsDeleted = false }; var deleted = new Order { IsCancelled = false, IsDeleted = true }; var active = new Order { IsCancelled = false, IsDeleted = false };
OrderSpecs.Active.IsSatisfiedBy(cancelled).Should().BeFalse(); OrderSpecs.Active.IsSatisfiedBy(deleted).Should().BeFalse(); OrderSpecs.Active.IsSatisfiedBy(active).Should().BeTrue();}
[Fact]public void HighValue_FiltersCorrectly(){ var low = new Order { Total = 500m }; var high = new Order { Total = 1500m };
var spec = OrderSpecs.HighValue(1000m); spec.IsSatisfiedBy(low).Should().BeFalse(); spec.IsSatisfiedBy(high).Should().BeTrue();}
[Fact]public void Composition_CombinesCorrectly(){ var order = new Order { IsCancelled = false, IsDeleted = false, Total = 2000m, CustomerId = Guid.Parse("abc...") };
var spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m) & OrderSpecs.ForCustomer(order.CustomerId);
spec.IsSatisfiedBy(order).Should().BeTrue();}Design Principles
Section titled “Design Principles”-
Specifications are predicates only (WHERE clauses). They do not handle sorting, paging, or projection — those concerns belong to the query system (
Pragmatic.Persistence). -
Dual-path execution: The same specification works with
IQueryable(expression tree for SQL) andIEnumerable(compiled delegate for in-memory). No separate implementations needed. -
Lazy compilation: The expression is not compiled until
IsSatisfiedByis called. If only used withIQueryable.Where(spec), no compilation occurs. -
Thread-safe caching: The compiled delegate is cached via
Lazy<T>, which is thread-safe by default. -
No dependencies: The library has zero NuGet dependencies. It uses only
System.Linq.Expressionsfrom the base class library. -
Sentinel values:
Spec<T>.TrueandSpec<T>.Falseare singletons. They serve as identity elements forAndandOrcomposition, enabling clean dynamic filter building.
Custom Specification Classes
Section titled “Custom Specification Classes”For specifications with complex logic or multiple dependencies, create a dedicated class:
public class OverdueInvoiceSpec : Specification<Invoice>{ private readonly DateTimeOffset _asOf;
public OverdueInvoiceSpec(IClock clock) => _asOf = clock.UtcNow;
public override Expression<Func<Invoice, bool>> ToExpression() => invoice => invoice.PaidAt == null && invoice.DueDate < _asOf;}
// Usagevar spec = new OverdueInvoiceSpec(clock);var overdueInvoices = await repository.FindAsync(spec, ct);Parameterized Specifications
Section titled “Parameterized Specifications”public class DateRangeSpec<T> : Specification<T> where T : class{ private readonly Expression<Func<T, DateTimeOffset>> _dateSelector; private readonly DateTimeOffset _from; private readonly DateTimeOffset _to;
public DateRangeSpec( Expression<Func<T, DateTimeOffset>> dateSelector, DateTimeOffset from, DateTimeOffset to) { _dateSelector = dateSelector; _from = from; _to = to; }
public override Expression<Func<T, bool>> ToExpression() { var param = _dateSelector.Parameters[0]; var body = Expression.AndAlso( Expression.GreaterThanOrEqual(_dateSelector.Body, Expression.Constant(_from)), Expression.LessThan(_dateSelector.Body, Expression.Constant(_to))); return Expression.Lambda<Func<T, bool>>(body, param); }}
// Usagevar marchOrders = new DateRangeSpec<Order>( o => o.PlacedAt, new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero));Performance Considerations
Section titled “Performance Considerations”- Avoid creating specifications in hot loops. Each
Spec<T>.Where()call allocates a newExpressionSpecification<T>. For repeated evaluations, cache the specification:
// Cache in a static field or local variableprivate static readonly Specification<Order> ActiveSpec = OrderSpecs.Active;-
Composition creates new instances.
spec1 & spec2creates a newAndSpecification<T>. This is fine for query building but avoid excessive chaining in tight loops. -
Lazy compilation is thread-safe. Multiple threads can call
IsSatisfiedByconcurrently. The first call compiles the expression; subsequent calls reuse the cached delegate.
Feature Summary
Section titled “Feature Summary”| Problem | Solution |
|---|---|
| Business predicates duplicated across files | Define once as Specification<T>, reuse everywhere |
| SQL and in-memory logic drift apart | Same spec works with IQueryable and IEnumerable |
| Inline lambdas cannot be unit tested | IsSatisfiedBy(entity) — no database needed |
| Dynamic filters require if-chain boilerplate | Spec<T>.True + AndIf() for conditional composition |
| Composition requires manual expression stitching | &, |, ! operators and And(), Or(), Not() methods |
| OR-based search filters | Spec<T>.False + OrIf() for additive matching |
| Expression parameter mismatch in EF Core | ParameterReplacer unifies lambda parameters |
| Unnecessary WHERE clause when no filters apply | Spec<T>.True is identity — passes through cleanly |
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Persistence | Repository FindAsync(Spec<T>) accepts specifications |
| Pragmatic.Persistence | [ComputedFilter] generates specifications from entity properties |
| Pragmatic.Abstractions | IReadRepository depends on Specification<T> |
Samples
Section titled “Samples”See samples/Pragmatic.Specification.Samples/ for 8 scenarios: basic usage, composition (operators & | !), dynamic filtering (AndIf/OrIf), testing with IsSatisfiedBy, IQueryable integration, order specifications (parameterized), complex business rules, and sentinels/edge cases (Spec.True/False, double negation).
Requirements
Section titled “Requirements”- .NET 10.0+
License
Section titled “License”Part of the Pragmatic.Design ecosystem.