Skip to content

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.


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.

// Repository
public async Task<List<Order>> GetActiveOrdersAsync(CancellationToken ct)
{
return await _db.Orders
.Where(o => !o.IsCancelled && !o.IsDeleted) // rule #1
.ToListAsync(ct);
}
// Service
public 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);
}
// Controller
public 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 helper
public 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.

ProblemImpact
Duplicated predicatesOne change requires updating N locations
Expression vs. delegate splitDatabase queries and in-memory checks use different code paths
No composabilityComplex filters are built by chaining .Where() calls, which cannot be reused as building blocks
Untestable predicatesInline lambdas inside repository methods cannot be unit-tested in isolation
Dynamic filters are painfulBuilding optional WHERE clauses from user input requires if/else chains that are hard to read and maintain

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 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);
public static Specification<Order> ForCustomer(Guid customerId) =>
Spec<Order>.Where(o => o.CustomerId == customerId);
}
// Use everywhere
var 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();
}

Every specification carries an expression tree (Expression<Func<T, bool>>). This expression tree serves two purposes:

  1. IQueryable path (database): The expression is handed directly to the query provider (EF Core, Dapper, etc.), which translates it to SQL. No compilation occurs.
  2. IEnumerable path (in-memory): The expression is compiled to a Func<T, bool> delegate on first use, then cached via Lazy<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 delegate

This means you write the predicate once and the library chooses the optimal execution path based on context.

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 parameter

Without parameter unification, EF Core would see two different parameter objects and fail to translate the expression.

The compiled delegate is created only when IsSatisfiedBy is first called:

// Specification<T> base class
private 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.


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.

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.

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:

IdentityWith AndWith Or
TrueTrue.And(x) returns x’s behaviorTrue.Or(x) returns True
FalseFalse.And(x) returns FalseFalse.Or(x) returns x’s behavior

Spec<T>.True is the standard starting point for building dynamic filters from user input.


// AND: both must match
var spec = OrderSpecs.Active.And(OrderSpecs.HighValue(1000m));
// OR: either matches
var spec = OrderSpecs.ForCustomer(id1).Or(OrderSpecs.ForCustomer(id2));
// NOT: negate
var spec = OrderSpecs.Active.Not();

C# operators provide a more compact alternative:

var spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m); // And
var spec = OrderSpecs.ForCustomer(id1) | OrderSpecs.ForCustomer(id2); // Or
var spec = !OrderSpecs.Active; // Not

Parentheses control precedence:

// (overdue OR priority) AND NOT deleted
var 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 = 0

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.


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.

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.

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

SpecificationExtensions provides LINQ-style methods that accept specifications. All are marked [MethodImpl(MethodImplOptions.AggressiveInlining)] for zero overhead.

MethodIQueryableIEnumerable
Where(spec)Expression tree to SQLCompiled delegate
Any(spec)Expression tree to SQLCompiled delegate
All(spec)Expression tree to SQLCompiled delegate
Count(spec)Expression tree to SQLCompiled delegate
FirstOrDefault(spec)Expression tree to SQLCompiled delegate
SingleOrDefault(spec)Expression tree to SQLCompiled 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 call spec.ToExpression() and pass the expression to the query provider.
  • IEnumerable<T> overloads call spec.IsSatisfiedBy as the predicate delegate.

Specifications integrate directly with the Pragmatic.Persistence module’s repository pattern.

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

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.


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

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.


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

TypeCreated ByExpression
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.


  1. Specifications are predicates only. They define WHERE clauses. Sorting, paging, projection, and includes 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). You never write separate implementations.

  3. Immutability. Composition creates new instances. The originals are never modified. This makes specifications safe for static fields and concurrent access.

  4. Lazy compilation. The expression is not compiled until IsSatisfiedBy is called. If the specification is only used with IQueryable, no compilation ever occurs.

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

  6. Zero dependencies. The library depends only on System.Linq.Expressions from the base class library. No NuGet packages, no source generator.


TopicLocation
Getting Startedgetting-started.md
Composition Patternscomposition-patterns.md
Common Mistakescommon-mistakes.md
Troubleshootingtroubleshooting.md
Pragmatic.Persistence../../Pragmatic.Persistence/README.md