Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/a-specification-that-never-specifies-anything && read --section="top" 0%
.NET

A Specification That Never Specifies Anything

A textbook Specification base class whose one clever feature has zero usages, whose combinators have none either, and whose only used method recompiles an expression tree on every call - inside loops.

By Kumar Chandrachooda 03 Dec 2025 6 min read
A tree being rebuilt from scratch on each pass of a loop

The Specification pattern has one genuinely good reason to exist in a .NET codebase, and it is not “encapsulating a business rule in an object”. That part is easy — a static method does it. The reason is dual evaluation: one rule definition that a database can translate into SQL and that in-memory code can invoke on an object it already has.

Part 10 closed the persistence arc on a query. This part is about a base class in Shared.Types that is built entirely around dual evaluation, and then only ever used one way.

Fourteen lines

// src/Shared/GroupFlights.Shared.Types/Specification/Specification.cs:5-18
public abstract class Specification<T>
{
    protected abstract Expression<Func<T, bool>> AsPredicateExpression();

    public static implicit operator Expression<Func<T, bool>>(Specification<T> specification)
    {
        return specification.AsPredicateExpression();
    }

    public bool Check(T obj)
    {
        return AsPredicateExpression().Compile().Invoke(obj);
    }
}

That is a good abstraction on paper, and it is worth saying so before taking it apart. The subclass writes one expression. The implicit operator lets you hand the specification object straight to Where(...) on an IQueryable, where EF translates the tree to SQL. Check compiles the same tree to a delegate and runs it against an object in memory. Most Specification implementations expose only bool IsSatisfiedBy(T) and lose the SQL half entirely; this one keeps both.

Adoption is real, too. Eight concrete specifications exist across two modules — IsVariantOverdue, CanVariantBePresentedToClient, IsLongHaulFlight, EverythingProvidedToConfirmReservation, PaymentSetupCoversTravelCost, IsDeadlineOverdue (twice, once per module) — and they read well:

// src/Sales/.../Offers/Specifications/IsVariantOverdue.cs:17-20
protected override Expression<Func<OfferVariant, bool>> AsPredicateExpression()
{
    return variant => variant.ValidTo.ValidToForClient < _clock.UtcNow;
}

One rule, named, testable, injectable clock. Nothing wrong with it.

The implicit conversion has zero usages

Now grep the estate for a specification handed to a queryable. There is not one.

Every single call site in src/ is .Check(...). Nineteen of them, all in the two Sales aggregates, the fee policy and one specification calling another. The implicit Expression<Func<T, bool>> conversion — the entire justification for the base class being expression-based rather than delegate-based — is never invoked.

That is not a small observation. Strip the operator out and the abstract member could be protected abstract Func<T, bool> AsPredicate(), a plain delegate. Nothing in the codebase would change except that Check would stop compiling IL at runtime.

And there are two structural reasons the conversion could not be used even if somebody wanted to.

Sales' data is in a jsonb column. Part 7 showed there is no IQueryable<Offer> anywhere; there is an IQueryable<OfferDbModel> whose only meaningful column is a document. A predicate over OfferVariant has nothing to attach to.

Two of the specifications are untranslatable anyway.

// src/Sales/.../Reservations/Specifications/PaymentSetupCoversTravelCost.cs:17-33
protected override Expression<Func<Reservation, bool>> AsPredicateExpression()
{
    Money totalAttemptedPayment = null;

    foreach (var payment in _attemptedPaymentsSetup)
    {
        if (totalAttemptedPayment is null)
        {
            totalAttemptedPayment = payment.Amount;
            continue;
        }

        totalAttemptedPayment += payment.Amount;
    }

    return reservation => reservation.Cost.TotalCost == totalAttemptedPayment;
}

The fold runs outside the expression tree and is captured in a closure. The returned lambda compares against a Money object no database has ever heard of. EverythingProvidedToConfirmReservation does the same thing differently — its expression body calls two ordinary private methods, which no LINQ provider can translate.

So the abstraction was built for a capability the model cannot supply, and half the implementations would not honour it if it could. The pattern is here as a shape, not as a mechanism.

Check recompiles the tree on every call

Read that method again with the call sites in mind:

public bool Check(T obj)
{
    return AsPredicateExpression().Compile().Invoke(obj);
}

Three operations per call. Build a fresh expression tree. Compile it — which emits IL through LambdaCompiler, allocates a dynamic method, and costs on the order of tens of microseconds. Invoke it once. Discard everything.

Now the loops.

// src/Sales/.../Offers/Offer.cs:32-36
var isOverdue = new IsVariantOverdue(clock);
_variants = draft.Variants.Where(variant =>
        variant.HasBeenConfirmed &&
        isOverdue.Check(variant) is false)
    .ToList();

One compilation per variant. The isOverdue object is hoisted out of the loop, which looks like an optimisation and buys nothing, because the state that gets rebuilt is inside Check, not in the object.

It gets worse one file over:

// src/Sales/.../Offers/Specifications/CanVariantBePresentedToClient.cs:17-21
protected override Expression<Func<OfferVariant, bool>> AsPredicateExpression()
{
    var isOverdue = new IsVariantOverdue(_clock);
    return variant => variant.HasBeenConfirmed && isOverdue.Check(variant) == false;
}

A specification whose expression body calls Check on another specification. So OfferDraft.RevealToClient runs _variants.Any(variant => canBePresented.Check(variant)), and each element compiles the outer tree, invokes it, and the invocation compiles the inner tree. Two IL compilations per variant, on the path a cashier hits every time they publish an offer.

Reservation.cs:283 does it once per payment deadline inside an All. UnconfirmedReservation calls Check at four separate guard points.

The fix is one line:

public abstract class Specification<T>
{
    protected abstract Expression<Func<T, bool>> AsPredicateExpression();

    private Func<T, bool> _compiled;

    public static implicit operator Expression<Func<T, bool>>(Specification<T> specification)
        => specification.AsPredicateExpression();

    public bool Check(T obj) => (_compiled ??= AsPredicateExpression().Compile()).Invoke(obj);
}

One field, one null-coalescing assignment. Every specification in this estate is constructed per operation and captures immutable state, so instance-level caching is safe. The abstraction survives; the per-element cost collapses to one compilation per specification instance.

To be fair to the estate: none of this matters at teaching scale. A draft has a handful of variants and a reservation a handful of deadlines, so the real cost is microseconds nobody will ever measure. The finding is not “this is slow”; it is that Compile() inside a method named Check is invisible at the call site. isOverdue.Check(variant) looks like a field comparison. Nothing in the name suggests IL emission. That is the durable lesson: a cheap-looking API over an expensive operation is a trap that scales with adoption, and this one was adopted nineteen times.

The combinators nobody calls

AndSpecification<T> and OrSpecification<T> are sixty-five-line files that differ by exactly one token — Expression.And on line 43 versus Expression.Or on line 43. Each contains a nested twenty-six-line ReplaceExpressionVisitor that rebinds lambda parameters so two independently-authored expressions can share one. It is correct, non-trivial code that most people get wrong.

Both have zero usages repo-wide. So does Extensions.Or, the only combinator extension method — and there is no matching And, so conjunction would read new AndSpecification<T>(a, b) while disjunction reads a.Or(b). An asymmetric public API for a feature nobody uses.

Three latent defects sit in that unused code, and they are instructive because they are the kind you only find by reading:

  • Expression.And and Expression.Or are bitwise and non-short-circuiting. The short-circuiting factories are AndAlso and OrElse. EF translates both to the same SQL, so the queryable side is unaffected — but the in-memory Check path evaluates both operands unconditionally. A composed IsNotNull.And(HasProperty) would throw where the short-circuit version would return false. A bug that fires on only one leg of a dual-purpose abstraction.
  • AsPredicateExpression() returns null for an empty params array. new AndSpecification<T>() compiles fine and then throws at the conversion site.
  • The whole visitor is duplicated verbatim in both files rather than extracted.

What to take from it

I want to be careful here, because this is easy to read as a hit piece on somebody's shared kernel, and that is not what it is. The base class is a correct implementation of a named pattern, adopted eight times, in a repository built to teach. Most codebases with a Specification<T> in them have a worse one.

The lesson is about the gap between a pattern's shape and its purpose. The Specification pattern's payoff is dual evaluation; this implementation pays the entire cost of dual evaluation — expression trees, an implicit conversion, two combinator classes with a parameter rebinder — and collects none of the benefit, because the data it guards lives in a JSON blob no queryable can reach.

Which loops back to the persistence arc. The choice in Part 6 did not just cost queries. It quietly invalidated an abstraction two layers away in a project that does not know Sales exists.

Next, the messaging half of the series opens. At most once, and sometimes not at all — what the in-process dispatcher actually guarantees, and the one static keyword holding it together.