BrandGhost
Combining C# Specifications With AND, OR, and NOT

Combining C# Specifications With AND, OR, and NOT

Combining C# specifications looks easy at first: AND means both rules, OR means either rule, and NOT reverses one rule. The implementation becomes more careful when the same criteria must work as pure in-memory logic and as an expression tree that EF Core can translate.

This article owns predicate composition only. It covers pure specifications, expression specifications, truth tables, Boolean laws, null policy, short-circuit caveats, and parameter rebinding with ExpressionVisitor. It does not merge includes, ordering, projections, paging, cache metadata, or any other full query-envelope state.

Combining C# Specifications Starts With Two Predicate Models

A pure domain specification evaluates a candidate now:

bool IsSatisfiedBy(T candidate)

An expression specification describes a predicate as data:

Expression<Func<T, bool>> Criteria

The distinction is not cosmetic. A pure specification can call ordinary .NET code because it runs as a delegate. An expression specification may be inspected by a LINQ provider, so both the expression-tree shape and the provider's supported translations matter.

The original Evans/Fowler Specification paper describes specifications as encapsulated predicates and includes Boolean composition among their intended capabilities. That establishes the conceptual goal, not a current EF Core translation guarantee.

For general predicate syntax before composition, this LINQ filtering guide covers Where, Any, All, and related operators.

AND, OR, and NOT Truth Tables

For total, side-effect-free predicates that return ordinary bool, the truth tables are straightforward.

A B A AND B A OR B
false false false false
false true false true
true false false true
true true true true

NOT has one input:

A NOT A
false true
true false

These tables are more than introductory material. They are executable requirements for the composition helpers. A refactor that changes one row has changed the meaning of the abstraction.

For this sample's pure, two-valued predicates, the following identities and De Morgan transformations are invariants enforced by exhaustive tests:

A AND true = A
A OR false = A
A AND false = false
A OR true = true
A AND A = A
A OR A = A
NOT NOT A = A
NOT (A AND B) = (NOT A) OR (NOT B)
NOT (A OR B) = (NOT A) AND (NOT B)

The last two are De Morgan's laws. Testing them across all Boolean input combinations gives the implementation a stronger safety net than a single happy-path example.

Use laws to test behavior, not tree formatting

The most durable tests ask whether the composed predicate returns the right answer. They do not depend on the expression tree's ToString() output or on one provider's SQL formatting.

A truth-table test catches swapped operators immediately. The identity laws catch broken handling of constant rules. Double negation catches a malformed NOT helper. De Morgan tests exercise AND, OR, and NOT together, which makes them valuable after refactoring shared composition code.

These tests also clarify their own limit. Compiling an expression and applying it to a BooleanPair proves .NET Boolean semantics for the produced tree. It does not prove that a database provider can translate a customer-specific criterion. That is why the example has both algebraic unit tests and a relational execution test.

For larger rule sets, property-based testing can generate Boolean combinations automatically, but four explicit rows remain useful documentation. A reader can see the contract without knowing the property-testing library.

Short-Circuit Semantics Need a Boundary

C# && and || are conditional logical operators: the right operand is evaluated only when the left operand does not already determine the answer. By contrast, & and | evaluate both operands.

That makes && and || the correct operators for pure in-memory specification composition. A false left side prevents the right side of AND from running, while a true left side prevents the right side of OR from running.

However, do not build correctness around side effects, exception order, counters, I/O, or timing in either operand. After an expression tree is handed to a database provider, the provider translates it into its own query language and the database controls execution. The Boolean result should matter; an assumed operand execution sequence should not.

This is also why specifications should remain side-effect-free. Composition is easier to reason about when evaluating a rule only returns an answer.

Null Is a Policy, Not an Afterthought

There are two different null questions.

First, what happens when the candidate itself is null? The sample's pure specification throws ArgumentNullException. That makes misuse visible instead of quietly turning a missing object into a business answer.

Second, what does a nullable property mean? A nullable nickname, date, or foreign key may mean unknown, absent, or not applicable. The expression must encode the intended meaning explicitly, such as customer.Nickname != null.

C# nullable Boolean operators also require care. bool? uses three-valued behavior with & and |, while && and || do not accept bool? operands.

SQL has its own three-valued null logic. EF Core adds null compensation for many comparisons so results align with C# semantics, but the generated query can become more complex. When null affects a material rule, run the composed expression against the intended relational provider.

Make nullable-property rules name the intended state

HasNickname uses customer.Nickname != null. That name and expression agree: a missing nickname does not satisfy the rule.

Suppose the business question were different: "Can the customer be contacted by a preferred name?" An empty string might need to fail as well. That would be a different specification with different provider-visible criteria. The generic AND, OR, and NOT helpers should not guess that policy.

The same discipline applies to nullable dates and numbers. RenewalDate == null could mean "not scheduled," "unknown," or "not renewable." Give each meaning a named criterion rather than scattering negated null checks through a long composition chain.

Explicit naming helps when NOT enters the picture. HasNickname().Not() is mechanically correct, but a named MissingNickname criterion may communicate the use case more directly. Composition is a tool for reuse, not a requirement to express every concept as nested negation.

A Complete Composition Implementation

The example targets net10.0 on .NET 10, C# 14, and EF Core 10.0.10. It uses SQLite 10.0.10 for a relational translation test and excludes .NET 11, C# 15, and EF Core 11.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <NoWarn>$(NoWarn);CA1707</NoWarn>
    <OutputType>Exe</OutputType>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit.v3" Version="3.0.0" />
  </ItemGroup>
</Project>

The next four C# blocks are consecutive sections of one compile-valid file. The first block defines the model and both specification forms.

using System;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

using Xunit;

namespace CombiningSpecificationsExample;

public sealed class Customer
{
    private Customer()
    {
        Name = string.Empty;
    }

    public Customer(
        int id,
        string name,
        bool isActive,
        bool isSuspended,
        decimal creditLimit,
        string? nickname)
    {
        Id = id;
        Name = name;
        IsActive = isActive;
        IsSuspended = isSuspended;
        CreditLimit = creditLimit;
        Nickname = nickname;
    }

    public int Id { get; private set; }

    public string Name { get; private set; }

    public bool IsActive { get; private set; }

    public bool IsSuspended { get; private set; }

    public decimal CreditLimit { get; private set; }

    public string? Nickname { get; private set; }
}

public sealed class CustomerDbContext(
    DbContextOptions<CustomerDbContext> options)
    : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();
}

public interface IDomainSpecification<in T>
    where T : class
{
    bool IsSatisfiedBy(T candidate);
}

public sealed class DomainSpecification<T>(Func<T, bool> predicate)
    : IDomainSpecification<T>
    where T : class
{
    private readonly Func<T, bool> _predicate =
        predicate ?? throw new ArgumentNullException(nameof(predicate));

    public bool IsSatisfiedBy(T candidate)
    {
        ArgumentNullException.ThrowIfNull(candidate);

        return _predicate(candidate);
    }
}

public interface IExpressionSpecification<T>
    where T : class
{
    Expression<Func<T, bool>> Criteria { get; }
}

public sealed class ExpressionSpecification<T>(
    Expression<Func<T, bool>> criteria)
    : IExpressionSpecification<T>
    where T : class
{
    public Expression<Func<T, bool>> Criteria { get; } =
        criteria ?? throw new ArgumentNullException(nameof(criteria));
}

public sealed record BooleanPair(bool A, bool B);

The second block implements the composition helpers. The expression path creates one new parameter, replaces both original parameters, and emits ordinary AndAlso, OrElse, or Not nodes.

public static class DomainSpecificationExtensions
{
    public static IDomainSpecification<T> And<T>(
        this IDomainSpecification<T> left,
        IDomainSpecification<T> right)
        where T : class
    {
        ArgumentNullException.ThrowIfNull(left);
        ArgumentNullException.ThrowIfNull(right);

        return new DomainSpecification<T>(
            candidate =>
                left.IsSatisfiedBy(candidate) &&
                right.IsSatisfiedBy(candidate));
    }

    public static IDomainSpecification<T> Or<T>(
        this IDomainSpecification<T> left,
        IDomainSpecification<T> right)
        where T : class
    {
        ArgumentNullException.ThrowIfNull(left);
        ArgumentNullException.ThrowIfNull(right);

        return new DomainSpecification<T>(
            candidate =>
                left.IsSatisfiedBy(candidate) ||
                right.IsSatisfiedBy(candidate));
    }

    public static IDomainSpecification<T> Not<T>(
        this IDomainSpecification<T> specification)
        where T : class
    {
        ArgumentNullException.ThrowIfNull(specification);

        return new DomainSpecification<T>(
            candidate => !specification.IsSatisfiedBy(candidate));
    }
}

public static class ExpressionSpecificationExtensions
{
    public static IExpressionSpecification<T> And<T>(
        this IExpressionSpecification<T> left,
        IExpressionSpecification<T> right)
        where T : class
    {
        return Compose(left, right, Expression.AndAlso);
    }

    public static IExpressionSpecification<T> Or<T>(
        this IExpressionSpecification<T> left,
        IExpressionSpecification<T> right)
        where T : class
    {
        return Compose(left, right, Expression.OrElse);
    }

    public static IExpressionSpecification<T> Not<T>(
        this IExpressionSpecification<T> specification)
        where T : class
    {
        ArgumentNullException.ThrowIfNull(specification);

        var parameter = Expression.Parameter(typeof(T), "candidate");
        var body = new ParameterReplacingVisitor(
            specification.Criteria.Parameters[0],
            parameter).Visit(specification.Criteria.Body)!;

        return new ExpressionSpecification<T>(
            Expression.Lambda<Func<T, bool>>(
                Expression.Not(body),
                parameter));
    }

    private static IExpressionSpecification<T> Compose<T>(
        IExpressionSpecification<T> left,
        IExpressionSpecification<T> right,
        Func<Expression, Expression, BinaryExpression> merge)
        where T : class
    {
        ArgumentNullException.ThrowIfNull(left);
        ArgumentNullException.ThrowIfNull(right);
        ArgumentNullException.ThrowIfNull(merge);

        var parameter = Expression.Parameter(typeof(T), "candidate");
        var leftBody = new ParameterReplacingVisitor(
            left.Criteria.Parameters[0],
            parameter).Visit(left.Criteria.Body)!;
        var rightBody = new ParameterReplacingVisitor(
            right.Criteria.Parameters[0],
            parameter).Visit(right.Criteria.Body)!;

        return new ExpressionSpecification<T>(
            Expression.Lambda<Func<T, bool>>(
                merge(leftBody, rightBody),
                parameter));
    }
}

public sealed class ParameterReplacingVisitor(
    ParameterExpression source,
    ParameterExpression target)
    : ExpressionVisitor
{
    protected override Expression VisitParameter(ParameterExpression node)
    {
        return ReferenceEquals(node, source)
            ? target
            : base.VisitParameter(node);
    }
}

public static class CustomerSpecifications
{
    public static IExpressionSpecification<Customer> Active()
    {
        return new ExpressionSpecification<Customer>(
            customer => customer.IsActive);
    }

    public static IExpressionSpecification<Customer> Suspended()
    {
        return new ExpressionSpecification<Customer>(
            customer => customer.IsSuspended);
    }

    public static IExpressionSpecification<Customer> CreditAtLeast(
        decimal minimum)
    {
        return new ExpressionSpecification<Customer>(
            customer => customer.CreditLimit >= minimum);
    }

    public static IExpressionSpecification<Customer> HasNickname()
    {
        return new ExpressionSpecification<Customer>(
            customer => customer.Nickname != null);
    }
}

The third block verifies pure Boolean behavior, algebraic laws, and short-circuiting.

public sealed class DomainCompositionTests
{
    [Theory]
    [InlineData(false, false, false, false, true)]
    [InlineData(false, true, false, true, true)]
    [InlineData(true, false, false, true, false)]
    [InlineData(true, true, true, true, false)]
    public void Composition_AllBooleanInputs_MatchesTruthTables(
        bool a,
        bool b,
        bool expectedAnd,
        bool expectedOr,
        bool expectedNotA)
    {
        var left = new DomainSpecification<BooleanPair>(pair => pair.A);
        var right = new DomainSpecification<BooleanPair>(pair => pair.B);
        var candidate = new BooleanPair(a, b);

        Assert.Equal(expectedAnd, left.And(right).IsSatisfiedBy(candidate));
        Assert.Equal(expectedOr, left.Or(right).IsSatisfiedBy(candidate));
        Assert.Equal(expectedNotA, left.Not().IsSatisfiedBy(candidate));
    }

    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public void Composition_AllBooleanInputs_ObeysDeMorganLaws(
        bool a,
        bool b)
    {
        var left = new DomainSpecification<BooleanPair>(pair => pair.A);
        var right = new DomainSpecification<BooleanPair>(pair => pair.B);
        var candidate = new BooleanPair(a, b);

        var notAnd = left.And(right).Not().IsSatisfiedBy(candidate);
        var notAOrNotB = left.Not().Or(right.Not())
            .IsSatisfiedBy(candidate);
        var notOr = left.Or(right).Not().IsSatisfiedBy(candidate);
        var notAAndNotB = left.Not().And(right.Not())
            .IsSatisfiedBy(candidate);

        Assert.Equal(notAnd, notAOrNotB);
        Assert.Equal(notOr, notAAndNotB);
    }

    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void Composition_AllBooleanInputs_ObeysCoreIdentityLaws(
        bool value)
    {
        var candidate = new BooleanPair(value, false);
        var rule = new DomainSpecification<BooleanPair>(pair => pair.A);
        var trueRule = new DomainSpecification<BooleanPair>(_ => true);
        var falseRule = new DomainSpecification<BooleanPair>(_ => false);

        Assert.Equal(value, rule.And(trueRule).IsSatisfiedBy(candidate));
        Assert.Equal(value, rule.Or(falseRule).IsSatisfiedBy(candidate));
        Assert.Equal(value, rule.And(rule).IsSatisfiedBy(candidate));
        Assert.Equal(value, rule.Or(rule).IsSatisfiedBy(candidate));
        Assert.Equal(
            value,
            rule.Not().Not().IsSatisfiedBy(candidate));
        Assert.False(rule.And(falseRule).IsSatisfiedBy(candidate));
        Assert.True(rule.Or(trueRule).IsSatisfiedBy(candidate));
    }

    [Theory]
    [InlineData(false, false, false)]
    [InlineData(false, false, true)]
    [InlineData(false, true, false)]
    [InlineData(false, true, true)]
    [InlineData(true, false, false)]
    [InlineData(true, false, true)]
    [InlineData(true, true, false)]
    [InlineData(true, true, true)]
    public void Composition_AllBooleanInputs_ObeysAssociativity(
        bool a,
        bool b,
        bool c)
    {
        var candidate = new BooleanPair(false, false);
        var left = new DomainSpecification<BooleanPair>(_ => a);
        var middle = new DomainSpecification<BooleanPair>(_ => b);
        var right = new DomainSpecification<BooleanPair>(_ => c);

        Assert.Equal(
            left.And(middle).And(right).IsSatisfiedBy(candidate),
            left.And(middle.And(right)).IsSatisfiedBy(candidate));
        Assert.Equal(
            left.Or(middle).Or(right).IsSatisfiedBy(candidate),
            left.Or(middle.Or(right)).IsSatisfiedBy(candidate));
    }

    [Fact]
    public void And_LeftIsFalse_DoesNotEvaluateRight()
    {
        var rightEvaluations = 0;
        var left = new DomainSpecification<BooleanPair>(_ => false);
        var right = new DomainSpecification<BooleanPair>(_ =>
        {
            rightEvaluations++;
            return true;
        });

        var result = left.And(right)
            .IsSatisfiedBy(new BooleanPair(false, true));

        Assert.False(result);
        Assert.Equal(0, rightEvaluations);
    }
}

The final block verifies expression semantics, confirms that composition introduced no invocation nodes, and executes the composed criterion through SQLite.

public sealed class ExpressionCompositionTests
{
    [Theory]
    [InlineData(false, false, false, false)]
    [InlineData(false, true, false, true)]
    [InlineData(true, false, false, true)]
    [InlineData(true, true, true, true)]
    public void Composition_AllBooleanInputs_MatchesExpressionTruthTable(
        bool a,
        bool b,
        bool expectedAnd,
        bool expectedOr)
    {
        IExpressionSpecification<BooleanPair> left =
            new ExpressionSpecification<BooleanPair>(pair => pair.A);
        IExpressionSpecification<BooleanPair> right =
            new ExpressionSpecification<BooleanPair>(pair => pair.B);
        var candidate = new BooleanPair(a, b);

        Assert.Equal(
            expectedAnd,
            left.And(right).Criteria.Compile()(candidate));
        Assert.Equal(
            expectedOr,
            left.Or(right).Criteria.Compile()(candidate));
    }

    [Fact]
    public async Task ComposedCustomerCriteria_RelationalProvider_Executes()
    {
        var testCancellation = TestContext.Current.CancellationToken;
        await using var connection = new SqliteConnection("Data Source=:memory:");
        await connection.OpenAsync(testCancellation);

        var options = new DbContextOptionsBuilder<CustomerDbContext>()
            .UseSqlite(connection)
            .Options;

        await using var dbContext = new CustomerDbContext(options);
        await dbContext.Database.EnsureCreatedAsync(testCancellation);

        dbContext.Customers.AddRange(
            new Customer(1, "Ada", true, false, 2000m, "ada"),
            new Customer(2, "Grace", true, true, 3000m, "grace"),
            new Customer(3, "Linus", true, false, 500m, "linus"),
            new Customer(4, "Sam", true, false, 2500m, null));

        await dbContext.SaveChangesAsync(testCancellation);

        var criteria = CustomerSpecifications.Active()
            .And(CustomerSpecifications.Suspended().Not())
            .And(CustomerSpecifications.CreditAtLeast(1000m))
            .And(CustomerSpecifications.HasNickname());

        var invocationCount = InvocationCountingVisitor.Count(
            criteria.Criteria);
        Assert.Equal(0, invocationCount);
        Assert.Single(criteria.Criteria.Parameters);

        var query = dbContext.Customers
            .AsNoTracking()
            .Where(criteria.Criteria)
            .OrderBy(customer => customer.Id)
            .Select(customer => customer.Name);

        var sql = query.ToQueryString();
        Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase);

        var names = await query.ToListAsync(testCancellation);

        Assert.Equal(["Ada"], names);
    }

    private sealed class InvocationCountingVisitor : ExpressionVisitor
    {
        private int _count;

        public static int Count(Expression expression)
        {
            var visitor = new InvocationCountingVisitor();
            visitor.Visit(expression);
            return visitor._count;
        }

        protected override Expression VisitInvocation(
            InvocationExpression node)
        {
            _count++;
            return base.VisitInvocation(node);
        }
    }
}

The calls to Compile() are confined to the expression truth-table unit test. The EF Core query receives the original composed expression tree.

Read the Expression Composition Step by Step

Consider the first call:

CustomerSpecifications.Active()
    .And(CustomerSpecifications.Suspended().Not())

Active() contains one lambda parameter, and Suspended() contains another. They may both print as customer, but object identity matters inside an expression tree.

Not() first creates a fresh parameter for its result. The visitor walks the suspended expression and replaces its original parameter with that fresh parameter. Expression.Not then wraps the rewritten body.

And() creates another fresh parameter for the combined result. It visits the active body and the already-negated suspended body, replacing each lambda's current parameter with the new shared parameter. Only then does it call Expression.AndAlso.

The result has one lambda parameter and a body shaped conceptually like this:

candidate =>
    candidate.IsActive &&
    !candidate.IsSuspended

The later calls repeat the same process for the credit threshold and nickname rule. Each composition creates a new immutable tree; none of the original specifications is mutated.

This is why the test checks both Assert.Single(criteria.Criteria.Parameters) and the absence of InvocationExpression. The first assertion verifies one bound parameter at the lambda boundary. The second verifies that the helper combined bodies directly rather than inserting invocation nodes.

Why Parameter Rebinding Is Necessary

Two lambdas can both display a parameter named customer while containing different ParameterExpression instances. Combining their bodies without rewriting the parameters can produce a lambda whose body refers to a parameter that the final lambda did not bind.

Expression trees are immutable, and ExpressionVisitor is the standard API for traversing and rewriting them.

The helper creates one parameter and replaces each original parameter with it. Expression.AndAlso and Expression.OrElse then create ordinary conditional Boolean nodes. Expression.Not creates the unary negation.

This shape is easy to inspect, compile for an in-memory test, and hand to a LINQ provider.

Grouping is visible even when Boolean results are equivalent

For this sample's pure Boolean predicates, equal truth values for (A AND B) AND C and A AND (B AND C) are a contract enforced across all eight input combinations. Their expression trees are still grouped differently because each call creates a binary node around the operands supplied at that moment.

That distinction usually does not change the intended result, but it is another reason not to assert a complete printed tree or SQL string unless grouping is part of a provider-specific contract. Test the Boolean behavior, inspect the important node types, and execute the final query.

Grouping also affects readability. A chain of four positive criteria is easy to scan. A deeply nested mixture of AND, OR, and NOT may be logically correct but difficult to review. Introduce a named intermediate specification when the grouping carries domain meaning:

EligibleForCreditReview =
    Active AND NOT Suspended AND CreditAtLeastMinimum

Then combine that named concept with HasNickname only if the use case actually requires both. A meaningful name can reveal an accidental grouping choice before a test does.

Why This Example Does Not Use Expression.Invoke

Expression.Invoke creates an InvocationExpression; it does not itself merge two lambda bodies. EF Core 10.0.10 contains an internal visitor that can inline some direct lambda invocation nodes. Remaining invocation nodes are not translated by the relational SQL visitor. Those implementation details are not a provider-neutral compatibility contract.

Parameter rebinding avoids depending on that internal reduction path.

The rule is not "Invoke can never work." The defensible rule is narrower: provider-bound composition should emit ordinary expression nodes rather than require an invocation node to be reduced.

Predicate Composition Is Not Query-Envelope Composition

The helpers combine exactly one thing: Boolean criteria.

They do not answer any of these questions:

  • Which of two orderings wins?
  • Can two projections be merged?
  • Should page sizes be added, minimized, or rejected?
  • Are two include graphs compatible?
  • How should cache keys or post-processing steps combine?

Those are not Boolean questions. Treating a full query envelope as if it were only a predicate creates ambiguous behavior.

Ardalis.Specification 9.3.1 explicitly does not offer arbitrary whole-specification composition because complete specifications may carry conflicting query state. Its FAQ recommends more explicit reuse mechanisms instead.

A composed specification tree may resemble the structural mechanics of Composite. If you want broader context on tree-shaped object composition, see the modern Composite pattern guide and the earlier Composite pattern walkthrough. The implementation here remains focused on predicate meaning rather than a general pattern comparison.

Composition Guardrails for Production Code

The helper is intentionally small, but the code that uses it still needs conventions.

Prefer atomic criteria that are deterministic and side-effect-free. Avoid reaching into services, the network, the clock, or mutable global state from a provider-bound expression. Beyond translation risk, those dependencies make truth-table reasoning unreliable.

Keep the original positive concepts easy to find. A chain dominated by .Not() calls often signals that the vocabulary needs improvement. OperationalCustomer may be clearer than Active.And(Suspended.Not()) when that combination recurs throughout the application.

Review the final IQueryable<T> boundary separately from the predicate. Composition does not supply ordering, projection, tracking, result limits, cancellation, or materialization. Those choices still need to be visible in the use case that executes the query.

Finally, add a provider integration test when a new atomic expression enters a database path. The composition helper may already be proven, but the new member access or method call introduces a new translation surface.

What the Tests Prove

The pure tests prove the in-memory truth tables, identities, De Morgan transformations, associativity across all eight three-input cases, and one short-circuit case for the provided helpers. The expression truth-table test proves the rewritten tree has the expected .NET Boolean result after compilation.

The relational test proves that this specific composed tree contains no invocation node, has one bound parameter, translates with the EF Core 10.0.10 SQLite provider, executes against SQLite, and returns the expected seeded row.

It does not prove identical SQL or null behavior for every relational provider. EF Core providers can translate differently, so production-provider claims require meaningful production-provider tests.

Pros and Cons of Composable Predicates

Composable predicates are most useful when the smaller criteria have stable names and genuinely recur. They are less useful when composition only turns one readable inline expression into several layers of indirection.

Advantages

Composition lets small named criteria become larger rules without copying predicate bodies. Truth tables provide a precise contract, and the expression implementation can preserve provider visibility without compiling delegates inside a query.

Parameter rebinding also produces a conventional tree shape. Reviewers can inspect AndAlso, OrElse, and Not instead of reasoning about an extra invocation layer.

Tradeoffs

Composition can hide readability problems if every query becomes a long chain of tiny generic rules. A well-named combined concept may be clearer than ten chained fragments.

Database translation remains a separate constraint. A perfectly correct Boolean composition helper cannot make an unsupported member or method translatable.

Null semantics also require domain decisions. Generic combinators cannot decide what a missing property means for a particular business rule.

Common Questions About Combining Specifications in C#

These questions separate Boolean meaning from expression-tree mechanics and from full query-envelope behavior.

Should AND use Expression.And or Expression.AndAlso?

Use AndAlso for ordinary conditional Boolean composition because it corresponds to &&; And corresponds to & and evaluates both sides in compiled .NET code.

Can I compile each expression and combine the delegates?

You can for purely in-memory evaluation. Do not pass those compiled delegates into an EF Core query if server translation is required.

Does NOT require parameter rebinding?

It can reuse the original parameter safely, but rebinding to a fresh shared parameter keeps the implementation consistent and produces an independently constructed tree.

Can nullable bool criteria use the same helpers?

Not without defining different semantics. These helpers return ordinary bool. A bool? design needs an explicit three-valued contract rather than silently borrowing the two-valued truth tables.

Can I merge two complete EF query specifications this way?

No. These helpers merge predicates only. Includes, orderings, projections, paging, cache metadata, and post-processing need separate, explicit conflict rules.

A Reliable Composition Rule

Combine pure specifications with &&, ||, and !. Combine provider-bound expressions by rebinding both lambdas to one parameter and emitting AndAlso, OrElse, and Not. Test the Boolean laws in memory, then execute the resulting expression through the relational provider that matters.

Most importantly, stop at the predicate boundary. Boolean algebra tells you how criteria combine. It does not tell you how to merge an entire query plan.

Specification Pattern With EF Core Without a Repository

Use the Specification Pattern with EF Core DbContext queries while keeping tracking, result limits, cancellation, SQL checks, and relational tests explicit.

How to Implement a Specification in C# From Scratch

Build a dependency-free C# specification pattern example with immutable named rules, an explicit null policy, focused tests, and balanced design tradeoffs.

Do You REALLY Need To Write Tests? - Dev Leader Weekly 63

Welcome to another issue of Dev Leader Weekly! In this issue, I discuss situations where we may or may not want or need to write tests!

An error has occurred. This application may no longer respond until reloaded. Reload