Pragmatic.Specification -- Getting Started
This guide covers defining specifications, composing them, using them with EF Core and in-memory collections, and writing unit tests.
Installation
Section titled “Installation”dotnet add package Pragmatic.SpecificationNo additional setup is required. Import the namespaces:
using Pragmatic.Specification;using Pragmatic.Specification.Extensions;Your First Specification
Section titled “Your First Specification”A specification is a reusable predicate. Define it using Spec<T>.Where():
public static class ProductSpecs{ public static Specification<Product> InStock => Spec<Product>.Where(p => p.StockQuantity > 0);
public static Specification<Product> InCategory(string category) => Spec<Product>.Where(p => p.Category == category);
public static Specification<Product> PricedBelow(decimal maxPrice) => Spec<Product>.Where(p => p.Price <= maxPrice);}Use it:
// With EF Corevar products = await db.Products .Where(ProductSpecs.InStock) .ToListAsync();
// With in-memory listvar filtered = allProducts.Where(ProductSpecs.InStock).ToList();
// Test a single entitybool available = ProductSpecs.InStock.IsSatisfiedBy(product);Composition
Section titled “Composition”And / Or / Not
Section titled “And / Or / Not”// AND: products in stock AND priced below 100var spec = ProductSpecs.InStock.And(ProductSpecs.PricedBelow(100m));
// OR: products in "Electronics" OR "Books"var spec = ProductSpecs.InCategory("Electronics") .Or(ProductSpecs.InCategory("Books"));
// NOT: products NOT in stockvar spec = ProductSpecs.InStock.Not();Operator Syntax
Section titled “Operator Syntax”C# operators provide a more compact syntax:
// & for ANDvar spec = ProductSpecs.InStock & ProductSpecs.PricedBelow(100m);
// | for ORvar spec = ProductSpecs.InCategory("Electronics") | ProductSpecs.InCategory("Books");
// ! for NOTvar spec = !ProductSpecs.InStock;Conditional Composition
Section titled “Conditional Composition”AndIf and OrIf only apply the second specification when the condition is true:
public Specification<Product> BuildFilter(ProductFilterRequest request){ var spec = Spec<Product>.True; // Start with "match all"
spec = spec.AndIf(request.InStockOnly, ProductSpecs.InStock); spec = spec.AndIf(request.MaxPrice.HasValue, ProductSpecs.PricedBelow(request.MaxPrice ?? 0)); spec = spec.AndIf(!string.IsNullOrEmpty(request.Category), ProductSpecs.InCategory(request.Category!));
return spec;}When no conditions are applied, Spec<T>.True passes through with no WHERE clause overhead.
Using with EF Core
Section titled “Using with EF Core”The Where extension method passes the specification’s expression tree to EF Core, which translates it to SQL:
using Pragmatic.Specification.Extensions;
// Single specvar result = await db.Products .Where(ProductSpecs.InStock) .OrderBy(p => p.Name) .Take(10) .ToListAsync();
// Composed specvar affordable = ProductSpecs.InStock & ProductSpecs.PricedBelow(50m);var count = db.Products.Count(affordable);
// Check existencebool hasElectronics = db.Products.Any(ProductSpecs.InCategory("Electronics"));The expression tree is never compiled — EF Core translates it directly to SQL.
Using with In-Memory Collections
Section titled “Using with In-Memory Collections”The same specification works with IEnumerable<T>. The expression is compiled to a delegate lazily on first use:
var products = GetAllProducts();
var inStock = products.Where(ProductSpecs.InStock);var first = products.FirstOrDefault(ProductSpecs.InCategory("Books"));var allExpensive = products.All(ProductSpecs.PricedBelow(1000m));Extension Methods
Section titled “Extension Methods”All extensions work with both IQueryable<T> and IEnumerable<T>:
// Filteringcollection.Where(spec)
// Existence checkscollection.Any(spec)collection.All(spec)
// Countingcollection.Count(spec)
// Single elementcollection.FirstOrDefault(spec)collection.SingleOrDefault(spec)Unit Testing Specifications
Section titled “Unit Testing Specifications”Test specifications in isolation without a database using IsSatisfiedBy:
public class ProductSpecsTests{ [Fact] public void InStock_WithPositiveQuantity_ReturnsTrue() { var product = new Product { StockQuantity = 5 }; ProductSpecs.InStock.IsSatisfiedBy(product).Should().BeTrue(); }
[Fact] public void InStock_WithZeroQuantity_ReturnsFalse() { var product = new Product { StockQuantity = 0 }; ProductSpecs.InStock.IsSatisfiedBy(product).Should().BeFalse(); }
[Fact] public void ComposedSpec_AndBothMatch_ReturnsTrue() { var product = new Product { StockQuantity = 10, Price = 25m }; var spec = ProductSpecs.InStock & ProductSpecs.PricedBelow(50m); spec.IsSatisfiedBy(product).Should().BeTrue(); }
[Fact] public void ComposedSpec_OneDoesNotMatch_ReturnsFalse() { var product = new Product { StockQuantity = 10, Price = 75m }; var spec = ProductSpecs.InStock & ProductSpecs.PricedBelow(50m); spec.IsSatisfiedBy(product).Should().BeFalse(); }}Custom Specification Classes
Section titled “Custom Specification Classes”For complex specifications that need constructor parameters or additional logic, extend Specification<T>:
public sealed class RecentOrdersSpec : Specification<Order>{ private readonly int _days;
public RecentOrdersSpec(int days = 30) { _days = days; }
public override Expression<Func<Order, bool>> ToExpression() { var cutoff = DateTimeOffset.UtcNow.AddDays(-_days); return o => o.CreatedAt >= cutoff; }}
// Usagevar recentOrders = new RecentOrdersSpec(7);var orders = await db.Orders.Where(recentOrders).ToListAsync();Nested Property Access
Section titled “Nested Property Access”Specifications support natural lambda access to nested properties:
public static class CustomerSpecs{ public static Specification<Customer> InCity(string city) => Spec<Customer>.Where(c => c.Address.City == city);
public static Specification<Customer> HasRecentOrders => Spec<Customer>.Where(c => c.Orders.Any(o => o.CreatedAt > DateTimeOffset.UtcNow.AddDays(-30)));}These translate to SQL JOINs and subqueries in EF Core.
Spec.True and Spec.False
Section titled “Spec.True and Spec.False”These are identity elements for composition:
| Identity | With And | With Or |
|---|---|---|
True | True.And(x) == x | True.Or(x) == True |
False | False.And(x) == False | False.Or(x) == x |
Both are singletons (one instance per T), so there is no allocation overhead when using them as starting points for dynamic filter building.