Skip to content

Pragmatic.Specification

Specification pattern implementation for composable, reusable query predicates in .NET 10.

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.

// Repository
return await db.Orders.Where(o => !o.IsCancelled && !o.IsDeleted).ToListAsync(ct);
// Service -- same predicate, different location
return await db.Orders.Where(o => !o.IsCancelled && !o.IsDeleted)
.Where(o => o.Total >= threshold).ToListAsync(ct);
// Validation helper -- same rule, different syntax
return !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 if chains that concatenate .Where() calls. Each new filter adds another branch.

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 once
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);
}
// Use everywhere
var orders = await db.Orders.Where(OrderSpecs.Active).ToListAsync(ct); // SQL
var filtered = allOrders.Where(OrderSpecs.Active & OrderSpecs.HighValue(1000m)); // In-memory
bool isActive = OrderSpecs.Active.IsSatisfiedBy(order); // Unit test

One definition. Dual-path execution (expression tree for SQL, compiled delegate for in-memory). Zero duplication.


Terminal window
dotnet add package Pragmatic.Specification

No source generator reference is needed. This is a pure runtime library with zero NuGet dependencies.


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);
}
// Method chaining
var spec = OrderSpecs.Active.And(OrderSpecs.HighValue(1000m));
// Operator syntax
var spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m);
// Negation
var notCancelled = !Spec<Order>.Where(o => o.IsCancelled);
// Complex composition
var vipOrders = OrderSpecs.Active
& OrderSpecs.HighValue(5000m)
& OrderSpecs.PlacedAfter(DateTimeOffset.UtcNow.AddDays(-30));
using Pragmatic.Specification.Extensions;
var orders = await db.Orders
.Where(spec) // Translates to SQL WHERE clause
.OrderByDescending(o => o.Total)
.ToListAsync();
var filtered = allOrders.Where(spec); // Uses compiled delegate
bool matches = spec.IsSatisfiedBy(order); // No database needed

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
}

Abstract base class with built-in composition. The expression is compiled lazily on first IsSatisfiedBy call and cached (thread-safe via Lazy<T>):

MethodDescription
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:

OperatorEquivalent
left & rightleft.And(right)
left | rightleft.Or(right)
!specspec.Not()

Static factory class:

MemberDescription
Spec<T>.Where(expression)Creates a specification from a lambda
Spec<T>.TrueSingleton that matches everything. Identity for And composition.
Spec<T>.FalseSingleton that matches nothing. Identity for Or composition.

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).

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;
}

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);
}
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);
}

Pragmatic.Specification.Extensions.SpecificationExtensions provides LINQ integration for both IQueryable<T> and IEnumerable<T>:

MethodIQueryableIEnumerableDescription
Where(spec)Uses ToExpression()Uses IsSatisfiedByFilter elements
Any(spec)Uses ToExpression()Uses IsSatisfiedByCheck if any match
All(spec)Uses ToExpression()Uses IsSatisfiedByCheck if all match
Count(spec)Uses ToExpression()Uses IsSatisfiedByCount matching
FirstOrDefault(spec)Uses ToExpression()Uses IsSatisfiedByFirst match or default
SingleOrDefault(spec)Uses ToExpression()Uses IsSatisfiedBySingle 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.


The library provides several internal Specification<T> implementations:

TypeDescription
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.


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();
}

  1. Specifications are predicates only (WHERE clauses). They do not handle sorting, paging, or projection — those concerns belong to the query system (Pragmatic.Persistence).

  2. Dual-path execution: The same specification works with IQueryable (expression tree for SQL) and IEnumerable (compiled delegate for in-memory). No separate implementations needed.

  3. Lazy compilation: The expression is not compiled until IsSatisfiedBy is called. If only used with IQueryable.Where(spec), no compilation occurs.

  4. Thread-safe caching: The compiled delegate is cached via Lazy<T>, which is thread-safe by default.

  5. No dependencies: The library has zero NuGet dependencies. It uses only System.Linq.Expressions from the base class library.

  6. Sentinel values: Spec<T>.True and Spec<T>.False are singletons. They serve as identity elements for And and Or composition, enabling clean dynamic filter building.


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;
}
// Usage
var spec = new OverdueInvoiceSpec(clock);
var overdueInvoices = await repository.FindAsync(spec, ct);
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);
}
}
// Usage
var 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));

  1. Avoid creating specifications in hot loops. Each Spec<T>.Where() call allocates a new ExpressionSpecification<T>. For repeated evaluations, cache the specification:
// Cache in a static field or local variable
private static readonly Specification<Order> ActiveSpec = OrderSpecs.Active;
  1. Composition creates new instances. spec1 & spec2 creates a new AndSpecification<T>. This is fine for query building but avoid excessive chaining in tight loops.

  2. Lazy compilation is thread-safe. Multiple threads can call IsSatisfiedBy concurrently. The first call compiles the expression; subsequent calls reuse the cached delegate.


ProblemSolution
Business predicates duplicated across filesDefine once as Specification<T>, reuse everywhere
SQL and in-memory logic drift apartSame spec works with IQueryable and IEnumerable
Inline lambdas cannot be unit testedIsSatisfiedBy(entity) — no database needed
Dynamic filters require if-chain boilerplateSpec<T>.True + AndIf() for conditional composition
Composition requires manual expression stitching&, |, ! operators and And(), Or(), Not() methods
OR-based search filtersSpec<T>.False + OrIf() for additive matching
Expression parameter mismatch in EF CoreParameterReplacer unifies lambda parameters
Unnecessary WHERE clause when no filters applySpec<T>.True is identity — passes through cleanly

With ModuleIntegration
Pragmatic.PersistenceRepository FindAsync(Spec<T>) accepts specifications
Pragmatic.Persistence[ComputedFilter] generates specifications from entity properties
Pragmatic.AbstractionsIReadRepository depends on Specification<T>

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).

  • .NET 10.0+

Part of the Pragmatic.Design ecosystem.