Architecture and Core Concepts
This guide explains why Pragmatic.Specification exists, how its pieces fit together, and how to think about composable predicates in a real codebase. Read this before the individual feature guides.
The Problem
Section titled “The Problem”Business rules live inside queries. Those queries are scattered across repositories, services, and controllers — each with its own inline lambda. When the rules change, you update them in multiple places and hope you found them all.
Scattered, duplicated filter logic
Section titled “Scattered, duplicated filter logic”// Repositorypublic async Task<List<Order>> GetActiveOrdersAsync(CancellationToken ct){ return await _db.Orders .Where(o => !o.IsCancelled && !o.IsDeleted) // rule #1 .ToListAsync(ct);}
// Servicepublic async Task<List<Order>> GetActiveHighValueOrdersAsync(decimal threshold, CancellationToken ct){ return await _db.Orders .Where(o => !o.IsCancelled && !o.IsDeleted) // rule #1 again .Where(o => o.Total >= threshold) .ToListAsync(ct);}
// Controllerpublic async Task<int> CountActiveForCustomerAsync(Guid customerId, CancellationToken ct){ return await _db.Orders .Where(o => !o.IsCancelled && !o.IsDeleted) // rule #1 yet again .Where(o => o.CustomerId == customerId) .CountAsync(ct);}Three methods, three copies of “active order” logic. When you add a new status field — say IsArchived — you need to find and update every copy. Worse, the in-memory validation code often duplicates the same predicate using different syntax:
// Validation helperpublic bool IsOrderActive(Order order){ return !order.IsCancelled && !order.IsDeleted; // rule #1, fourth copy}Now you have four copies of the same rule, split between expression trees (for EF Core) and compiled delegates (for in-memory). They will drift apart.
The consequences
Section titled “The consequences”| Problem | Impact |
|---|---|
| Duplicated predicates | One change requires updating N locations |
| Expression vs. delegate split | Database queries and in-memory checks use different code paths |
| No composability | Complex filters are built by chaining .Where() calls, which cannot be reused as building blocks |
| Untestable predicates | Inline lambdas inside repository methods cannot be unit-tested in isolation |
| Dynamic filters are painful | Building optional WHERE clauses from user input requires if/else chains that are hard to read and maintain |
The Solution
Section titled “The Solution”Define each predicate once as a Specification<T>. Compose specifications with And, Or, and Not. Use the same specification for database queries (expression tree), in-memory filtering (compiled delegate), and unit tests (IsSatisfiedBy).
// 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);
public static Specification<Order> ForCustomer(Guid customerId) => Spec<Order>.Where(o => o.CustomerId == customerId);}
// Use everywherevar activeOrders = await db.Orders.Where(OrderSpecs.Active).ToListAsync(ct);var highValue = await db.Orders.Where(OrderSpecs.Active & OrderSpecs.HighValue(1000m)).ToListAsync(ct);var count = await db.Orders.Count(OrderSpecs.Active & OrderSpecs.ForCustomer(customerId));bool isActive = OrderSpecs.Active.IsSatisfiedBy(order);One definition. Four usages. Zero duplication. And every specification is independently testable:
[Fact]public void Active_ExcludesCancelledOrders(){ var order = new Order { IsCancelled = true }; OrderSpecs.Active.IsSatisfiedBy(order).Should().BeFalse();}How It Works
Section titled “How It Works”Dual-path execution
Section titled “Dual-path execution”Every specification carries an expression tree (Expression<Func<T, bool>>). This expression tree serves two purposes:
- IQueryable path (database): The expression is handed directly to the query provider (EF Core, Dapper, etc.), which translates it to SQL. No compilation occurs.
- IEnumerable path (in-memory): The expression is compiled to a
Func<T, bool>delegate on first use, then cached viaLazy<T>for subsequent calls.
Specification<T> | +-- ToExpression() | | | +-- IQueryable<T>.Where(expr) --> SQL WHERE clause | | | +-- Compiled to Func<T, bool> --> In-memory filtering | +-- IsSatisfiedBy(entity) --> Uses cached compiled delegateThis means you write the predicate once and the library chooses the optimal execution path based on context.
Expression tree composition
Section titled “Expression tree composition”When you combine two specifications with And, Or, or Not, the library creates a new expression tree that merges both. A ParameterReplacer unifies the lambda parameters so that the combined expression uses a single parameter — this is required for EF Core to translate the combined expression to a single SQL query.
// Two separate expressions:// left: o => !o.IsCancelled// right: o => o.Total >= 1000
// After And composition:// combined: x => !x.IsCancelled && x.Total >= 1000// ^ unified parameterWithout parameter unification, EF Core would see two different parameter objects and fail to translate the expression.
Lazy compilation and caching
Section titled “Lazy compilation and caching”The compiled delegate is created only when IsSatisfiedBy is first called:
// Specification<T> base classprivate readonly Lazy<Func<T, bool>> _compiled;
protected Specification(){ _compiled = new Lazy<Func<T, bool>>(() => ToExpression().Compile());}
public bool IsSatisfiedBy(T entity){ return _compiled.Value(entity);}If you only use specifications with IQueryable<T>.Where(spec), no compilation ever occurs — the expression tree is passed directly to the query provider. Compilation only happens on the first IsSatisfiedBy call, and the result is cached thread-safely.
Core Types
Section titled “Core Types”ISpecification<T> — The Contract
Section titled “ISpecification<T> — The Contract”The interface that all specifications implement:
public interface ISpecification<T>{ Expression<Func<T, bool>> ToExpression(); bool IsSatisfiedBy(T entity);}Two methods, one responsibility: define a predicate. ToExpression() returns the expression tree for query providers. IsSatisfiedBy() evaluates the predicate against a single entity.
Specification<T> — The Base Class
Section titled “Specification<T> — The Base Class”Abstract base class that adds composition. This is what you inherit from when creating custom specification classes:
public abstract class Specification<T> : ISpecification<T>{ public abstract Expression<Func<T, bool>> ToExpression(); public bool IsSatisfiedBy(T entity);
// Composition public Specification<T> And(Specification<T> other); public Specification<T> Or(Specification<T> other); public Specification<T> Not(); public Specification<T> AndIf(bool condition, Specification<T> other); public Specification<T> OrIf(bool condition, Specification<T> other);
// Operators public static Specification<T> operator &(left, right); // And public static Specification<T> operator |(left, right); // Or public static Specification<T> operator !(spec); // Not}The composition methods create new specification instances — they never mutate the original. This makes specifications safe to share across threads and to store as static properties.
Spec<T> — The Factory
Section titled “Spec<T> — The Factory”Static factory for creating specifications without inheriting from Specification<T>:
public static class Spec<T>{ public static Specification<T> Where(Expression<Func<T, bool>> predicate); public static Specification<T> True { get; } // Always matches public static Specification<T> False { get; } // Never matches}Spec<T>.Where() wraps a lambda in an ExpressionSpecification<T>. This is the most common way to create specifications for simple predicates.
Spec<T>.True and Spec<T>.False are singletons (one per type T). They serve as identity elements for composition:
| Identity | With And | With Or |
|---|---|---|
True | True.And(x) returns x’s behavior | True.Or(x) returns True |
False | False.And(x) returns False | False.Or(x) returns x’s behavior |
Spec<T>.True is the standard starting point for building dynamic filters from user input.
Composing Specifications
Section titled “Composing Specifications”And, Or, Not
Section titled “And, Or, Not”// AND: both must matchvar spec = OrderSpecs.Active.And(OrderSpecs.HighValue(1000m));
// OR: either matchesvar spec = OrderSpecs.ForCustomer(id1).Or(OrderSpecs.ForCustomer(id2));
// NOT: negatevar spec = OrderSpecs.Active.Not();Operator syntax
Section titled “Operator syntax”C# operators provide a more compact alternative:
var spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m); // Andvar spec = OrderSpecs.ForCustomer(id1) | OrderSpecs.ForCustomer(id2); // Orvar spec = !OrderSpecs.Active; // NotComplex compositions
Section titled “Complex compositions”Parentheses control precedence:
// (overdue OR priority) AND NOT deletedvar urgent = (OrderSpecs.Overdue | OrderSpecs.Priority) & !OrderSpecs.Deleted;This produces a single expression tree that EF Core translates to:
WHERE (IsOverdue = 1 OR Priority = 1) AND IsDeleted = 0Conditional composition
Section titled “Conditional composition”AndIf and OrIf apply the second specification only when the condition is true. When the condition is false, the original specification is returned unchanged — no expression tree modification occurs.
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);
return spec;}When no conditions are applied, the result is Spec<T>.True, which matches everything. EF Core optimizes this to a query without a WHERE clause.
Two Ways to Define Specifications
Section titled “Two Ways to Define Specifications”Inline with Spec<T>.Where()
Section titled “Inline with Spec<T>.Where()”For simple predicates, use the factory:
public static class ProductSpecs{ public static Specification<Product> InStock => Spec<Product>.Where(p => p.StockQuantity > 0);
public static Specification<Product> PricedBelow(decimal max) => Spec<Product>.Where(p => p.Price <= max);}Best for: predicates that are a single lambda expression with zero or few parameters.
Custom class extending Specification<T>
Section titled “Custom class extending Specification<T>”For predicates with constructor parameters, validation, or complex logic:
public sealed class PriceRangeSpec(decimal min, decimal max) : Specification<Product>{ public override Expression<Func<Product, bool>> ToExpression() => product => product.Price >= min && product.Price <= max;}Best for: predicates with multiple parameters, predicates that need documentation, and predicates shared across bounded contexts.
Organizing specifications
Section titled “Organizing specifications”Group related specifications in a static class:
public static class OrderSpecs{ // Parameterless -- static property public static Specification<Order> Active { get; } = Spec<Order>.Where(o => !o.IsCancelled && !o.IsDeleted);
// With parameters -- static method public static Specification<Order> ForCustomer(Guid customerId) => Spec<Order>.Where(o => o.CustomerId == customerId);
// Complex -- dedicated class public static Specification<Order> InDateRange(DateTimeOffset from, DateTimeOffset to) => new DateRangeSpec(from, to);}Extension Methods
Section titled “Extension Methods”SpecificationExtensions provides LINQ-style methods that accept specifications. All are marked [MethodImpl(MethodImplOptions.AggressiveInlining)] for zero overhead.
Available methods
Section titled “Available methods”| Method | IQueryable | IEnumerable |
|---|---|---|
Where(spec) | Expression tree to SQL | Compiled delegate |
Any(spec) | Expression tree to SQL | Compiled delegate |
All(spec) | Expression tree to SQL | Compiled delegate |
Count(spec) | Expression tree to SQL | Compiled delegate |
FirstOrDefault(spec) | Expression tree to SQL | Compiled delegate |
SingleOrDefault(spec) | Expression tree to SQL | Compiled delegate |
using Pragmatic.Specification.Extensions;
// IQueryable (EF Core -- translates to SQL)var orders = await db.Orders.Where(OrderSpecs.Active).ToListAsync(ct);bool hasActive = await db.Orders.Any(OrderSpecs.Active);int activeCount = await db.Orders.Count(OrderSpecs.Active);
// IEnumerable (in-memory)var filtered = allOrders.Where(OrderSpecs.Active).ToList();var first = allOrders.FirstOrDefault(OrderSpecs.HighValue(500m));The extension methods choose the right path automatically:
IQueryable<T>overloads callspec.ToExpression()and pass the expression to the query provider.IEnumerable<T>overloads callspec.IsSatisfiedByas the predicate delegate.
Integration with Pragmatic.Persistence
Section titled “Integration with Pragmatic.Persistence”Specifications integrate directly with the Pragmatic.Persistence module’s repository pattern.
Repository queries
Section titled “Repository queries”IReadRepository<T, TId> accepts specifications for filtering:
public class OrderQueryHandler(IReadRepository<Order, Guid> orders){ public async Task<List<Order>> GetActiveForCustomer(Guid customerId, CancellationToken ct) { var spec = OrderSpecs.Active & OrderSpecs.ForCustomer(customerId); return await orders.FindAsync(spec, ct); }
public async Task<bool> HasHighValueOrders(Guid customerId, CancellationToken ct) { var spec = OrderSpecs.ForCustomer(customerId) & OrderSpecs.HighValue(1000m); return await orders.ExistsAsync(spec, ct); }
public async Task<int> CountActive(CancellationToken ct) { return await orders.CountAsync(OrderSpecs.Active, ct); }}ComputedFilter
Section titled “ComputedFilter”The [ComputedFilter] attribute in Pragmatic.Persistence generates specifications automatically from entity properties. These generated specifications compose with hand-written ones using the same And, Or, Not operators.
Nested Property Access
Section titled “Nested Property Access”Specifications support lambda access to nested properties and collections. EF Core translates these to JOINs and subqueries:
public static class CustomerSpecs{ // Nested property -- becomes JOIN in SQL public static Specification<Customer> InCity(string city) => Spec<Customer>.Where(c => c.Address.City == city);
// Collection navigation -- becomes subquery in SQL public static Specification<Customer> HasRecentOrders => Spec<Customer>.Where(c => c.Orders.Any(o => o.CreatedAt > DateTimeOffset.UtcNow.AddDays(-30)));
// String operations -- supported by all providers public static Specification<Customer> NameContains(string term) => Spec<Customer>.Where(c => c.Name.Contains(term));}Unit Testing
Section titled “Unit Testing”Specifications are independently testable without a database, using IsSatisfiedBy:
public class OrderSpecsTests{ [Fact] public void Active_WithCancelledOrder_ReturnsFalse() { var order = new Order { IsCancelled = true }; OrderSpecs.Active.IsSatisfiedBy(order).Should().BeFalse(); }
[Fact] public void Active_WithValidOrder_ReturnsTrue() { var order = new Order { IsCancelled = false, IsDeleted = false }; OrderSpecs.Active.IsSatisfiedBy(order).Should().BeTrue(); }
[Fact] public void ComposedSpec_BothMatch_ReturnsTrue() { var order = new Order { IsCancelled = false, IsDeleted = false, Total = 1500m }; var spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m); spec.IsSatisfiedBy(order).Should().BeTrue(); }
[Fact] public void ComposedSpec_OneFails_ReturnsFalse() { var order = new Order { IsCancelled = false, IsDeleted = false, Total = 500m }; var spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m); spec.IsSatisfiedBy(order).Should().BeFalse(); }}No mocking, no database setup, no HTTP requests. Create an entity, pass it to the specification, assert the result.
Internal Specification Types
Section titled “Internal Specification Types”The library provides six internal implementations of Specification<T>. You never instantiate these directly — they are created by Spec<T>.Where(), And(), Or(), and Not().
| Type | Created By | Expression |
|---|---|---|
ExpressionSpecification<T> | Spec<T>.Where(lambda) | Wraps the user-provided lambda |
AndSpecification<T> | .And() or & | Expression.AndAlso(left, right) |
OrSpecification<T> | .Or() or | | Expression.OrElse(left, right) |
NotSpecification<T> | .Not() or ! | Expression.Not(inner) |
TrueSpecification<T> | Spec<T>.True | _ => true (singleton) |
FalseSpecification<T> | Spec<T>.False | _ => false (singleton) |
All composition types use ParameterReplacer to unify lambda parameters, ensuring the resulting expression tree is valid for query translation.
Design Principles
Section titled “Design Principles”-
Specifications are predicates only. They define WHERE clauses. Sorting, paging, projection, and includes 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). You never write separate implementations. -
Immutability. Composition creates new instances. The originals are never modified. This makes specifications safe for static fields and concurrent access.
-
Lazy compilation. The expression is not compiled until
IsSatisfiedByis called. If the specification is only used withIQueryable, no compilation ever occurs. -
Thread-safe caching. The compiled delegate is cached via
Lazy<T>, which provides thread-safe initialization by default. -
Zero dependencies. The library depends only on
System.Linq.Expressionsfrom the base class library. No NuGet packages, no source generator.
See Also
Section titled “See Also”| Topic | Location |
|---|---|
| Getting Started | getting-started.md |
| Composition Patterns | composition-patterns.md |
| Common Mistakes | common-mistakes.md |
| Troubleshooting | troubleshooting.md |
| Pragmatic.Persistence | ../../Pragmatic.Persistence/README.md |