Common Mistakes
These are the most common issues developers encounter when using Pragmatic.Specification. Each section shows the wrong approach, the correct approach, and explains why.
1. Using Inline Lambdas Instead of Specifications
Section titled “1. Using Inline Lambdas Instead of Specifications”Wrong:
public class OrderService(AppDbContext db){ public async Task<List<Order>> GetActiveAsync(CancellationToken ct) { return await db.Orders .Where(o => !o.IsCancelled && !o.IsDeleted) .ToListAsync(ct); }
public async Task<int> CountActiveAsync(CancellationToken ct) { return await db.Orders .Where(o => !o.IsCancelled && !o.IsDeleted) .CountAsync(ct); }}Right:
public static class OrderSpecs{ public static Specification<Order> Active => Spec<Order>.Where(o => !o.IsCancelled && !o.IsDeleted);}
public class OrderService(AppDbContext db){ public async Task<List<Order>> GetActiveAsync(CancellationToken ct) { return await db.Orders.Where(OrderSpecs.Active).ToListAsync(ct); }
public async Task<int> CountActiveAsync(CancellationToken ct) { return await db.Orders.Count(OrderSpecs.Active); }}Why: Inline lambdas cannot be reused, tested in isolation, or composed with other predicates. When the “active” rule changes (e.g., adding && !o.IsArchived), you update one specification instead of hunting through every repository and service method.
2. Calling Methods That EF Core Cannot Translate
Section titled “2. Calling Methods That EF Core Cannot Translate”Wrong:
public static Specification<Order> RecentOrders => Spec<Order>.Where(o => IsRecent(o.CreatedAt));
private static bool IsRecent(DateTimeOffset date){ return date > DateTimeOffset.UtcNow.AddDays(-30);}Runtime result: InvalidOperationException — “The LINQ expression could not be translated.”
Right:
public static Specification<Order> RecentOrders => Spec<Order>.Where(o => o.CreatedAt > DateTimeOffset.UtcNow.AddDays(-30));Why: EF Core translates expression trees to SQL. It can translate built-in operators and known method calls (like DateTimeOffset.AddDays), but it cannot translate arbitrary C# methods. Keep the expression body limited to operations that have SQL equivalents.
Common operations that work with EF Core:
- Comparison operators (
==,!=,<,>,<=,>=) - String methods (
Contains,StartsWith,EndsWith,ToLower,ToUpper) DateTimeOffset/DateTimearithmetic (AddDays,AddHours)- Collection methods (
Any,All,Count) - Null checks (
== null,!= null)
Operations that do not work:
- Custom C# methods
ToString()on non-string types- Complex LINQ operations inside the predicate
Regexmatching
3. Building Dynamic Filters with if/else Instead of AndIf
Section titled “3. Building Dynamic Filters with if/else Instead of AndIf”Wrong:
public IQueryable<Product> BuildQuery(ProductFilter filter, IQueryable<Product> query){ if (filter.Category is not null) query = query.Where(p => p.Category == filter.Category);
if (filter.MinPrice.HasValue) query = query.Where(p => p.Price >= filter.MinPrice.Value);
if (filter.InStockOnly) query = query.Where(p => p.StockQuantity > 0);
return query;}Right:
public Specification<Product> BuildFilter(ProductFilter filter){ var spec = Spec<Product>.True;
spec = spec.AndIf(filter.Category is not null, ProductSpecs.InCategory(filter.Category!));
spec = spec.AndIf(filter.MinPrice.HasValue, ProductSpecs.PricedAbove(filter.MinPrice!.Value));
spec = spec.AndIf(filter.InStockOnly, ProductSpecs.InStock);
return spec;}Why: The if/else approach works but loses the benefits of specifications: the resulting query is not testable in isolation, not reusable, and not composable with other specifications. With AndIf, you get a single Specification<T> that can be tested with IsSatisfiedBy, passed to repository methods, or further composed.
4. Newing Up Specifications Repeatedly Instead of Caching
Section titled “4. Newing Up Specifications Repeatedly Instead of Caching”Wrong:
public async Task<List<Product>> GetActiveProductsAsync(CancellationToken ct){ // New allocation on every call var spec = Spec<Product>.Where(p => p.IsActive && !p.IsDeleted); return await db.Products.Where(spec).ToListAsync(ct);}Right:
public static class ProductSpecs{ // Allocated once, reused everywhere public static Specification<Product> Active { get; } = Spec<Product>.Where(p => p.IsActive && !p.IsDeleted);}
public async Task<List<Product>> GetActiveProductsAsync(CancellationToken ct){ return await db.Products.Where(ProductSpecs.Active).ToListAsync(ct);}Why: Parameterless specifications have no reason to be created on every call. Store them as static properties. This avoids unnecessary allocations and makes the predicate discoverable from a single location. Note: specifications with parameters (like ForCustomer(Guid id)) naturally create new instances per call — that is expected.
5. Using && / || Instead of & / | for Composition
Section titled “5. Using && / || Instead of & / | for Composition”Wrong:
// Compiler error: cannot apply operator '&&' to Specification<T>var spec = OrderSpecs.Active && OrderSpecs.HighValue(1000m);Right:
// Use single & and | (bitwise operators, overloaded by Specification<T>)var spec = OrderSpecs.Active & OrderSpecs.HighValue(1000m);var spec = OrderSpecs.Active | OrderSpecs.HighValue(1000m);
// Or use the method syntaxvar spec = OrderSpecs.Active.And(OrderSpecs.HighValue(1000m));Why: C# only allows overloading &, |, and ! (bitwise operators), not &&, || (short-circuit logical operators). The Specification<T> class overloads the single-character operators. If you accidentally type &&, you get a compile error.
6. Forgetting the Extensions Namespace
Section titled “6. Forgetting the Extensions Namespace”Wrong:
// Compiler error: IQueryable<T> does not contain a definition for 'Where'// that accepts ISpecification<T>var results = db.Products.Where(ProductSpecs.Active).ToListAsync();Right:
using Pragmatic.Specification.Extensions;
var results = db.Products.Where(ProductSpecs.Active).ToListAsync();Why: The Where, Any, All, Count, FirstOrDefault, and SingleOrDefault overloads that accept ISpecification<T> are extension methods defined in Pragmatic.Specification.Extensions.SpecificationExtensions. Without the using directive, the compiler cannot find them.
If you find yourself adding this using statement frequently, add it to a GlobalUsings.cs file:
global using Pragmatic.Specification.Extensions;7. Mixing Specification and ISpecification in Composition
Section titled “7. Mixing Specification and ISpecification in Composition”Wrong:
ISpecification<Order> mySpec = GetSpecFromSomewhere();var combined = OrderSpecs.Active.And(mySpec); // Compile errorRight:
// Option 1: ensure GetSpecFromSomewhere returns Specification<T>Specification<Order> mySpec = GetSpecFromSomewhere();var combined = OrderSpecs.Active.And(mySpec);
// Option 2: use the IQueryable extension directlyvar results = db.Orders .Where(OrderSpecs.Active) .Where(mySpec) // ISpecification<T> works with extension methods .ToListAsync();Why: The And, Or, and Not methods on Specification<T> accept Specification<T>, not ISpecification<T>. This is by design — composition creates new Specification<T> subclasses (AndSpecification, OrSpecification, NotSpecification) that need concrete type access to merge expression trees. If you have an ISpecification<T> reference, either cast it or chain .Where() calls on the queryable.
8. Putting Business Logic Inside ToExpression()
Section titled “8. Putting Business Logic Inside ToExpression()”Wrong:
public sealed class DiscountEligibleSpec : Specification<Customer>{ public override Expression<Func<Customer, bool>> ToExpression() { // Side effect inside expression! _logger.LogInformation("Checking discount eligibility");
return c => c.OrderCount > 10 && c.TotalSpent > 5000m; }}Right:
public sealed class DiscountEligibleSpec : Specification<Customer>{ public override Expression<Func<Customer, bool>> ToExpression() => c => c.OrderCount > 10 && c.TotalSpent > 5000m;}Why: ToExpression() returns an expression tree that may be called multiple times (for And/Or composition, for query translation, for compilation). It should be a pure function that returns a lambda expression with no side effects. Logging, validation, and other side effects belong in the service layer, not inside the specification.
9. Using Spec.False as a Starting Point for AndIf
Section titled “9. Using Spec.False as a Starting Point for AndIf”Wrong:
public Specification<Order> BuildFilter(OrderFilterRequest request){ var spec = Spec<Order>.False; // Nothing matches!
spec = spec.AndIf(request.ActiveOnly, OrderSpecs.Active); spec = spec.AndIf(request.MinTotal.HasValue, OrderSpecs.HighValue(request.MinTotal!.Value));
return spec; // Always returns no results}Right:
public Specification<Order> BuildFilter(OrderFilterRequest request){ var spec = Spec<Order>.True; // Everything matches initially
spec = spec.AndIf(request.ActiveOnly, OrderSpecs.Active); spec = spec.AndIf(request.MinTotal.HasValue, OrderSpecs.HighValue(request.MinTotal!.Value));
return spec;}Why: Spec<T>.False never matches anything. False.And(anything) is still False — it short-circuits. Use Spec<T>.True as the starting point for AndIf chains (it matches everything and progressively narrows). Use Spec<T>.False as the starting point for OrIf chains (it matches nothing and progressively widens).
| Starting Point | Composition | Use Case |
|---|---|---|
Spec<T>.True | AndIf | Narrow down from “all” |
Spec<T>.False | OrIf | Widen from “none” |
10. Creating Specifications That Include Sorting or Projection
Section titled “10. Creating Specifications That Include Sorting or Projection”Wrong:
public sealed class TopCustomersSpec : Specification<Customer>{ public override Expression<Func<Customer, bool>> ToExpression() => c => c.TotalSpent > 10000m;
// These don't belong here public Expression<Func<Customer, decimal>> OrderBy => c => c.TotalSpent; public int Take => 10;}Right:
// Specification: predicate onlypublic sealed class HighValueCustomerSpec : Specification<Customer>{ public override Expression<Func<Customer, bool>> ToExpression() => c => c.TotalSpent > 10000m;}
// Sorting and paging are query concerns, not specification concernsvar topCustomers = await db.Customers .Where(new HighValueCustomerSpec()) .OrderByDescending(c => c.TotalSpent) .Take(10) .ToListAsync(ct);Why: Specifications define WHERE clauses — nothing more. Sorting, paging, and projection are separate concerns that belong in the query layer. Mixing them into specifications breaks composability: you cannot combine a “top 10” specification with a “recent orders” specification in a meaningful way. Keep specifications focused on predicates and use Pragmatic.Persistence for the full query pipeline.