Infrastructure

Entity Framework Core

Keep IQueryable inside infrastructure, preflight relational translation, and account explicitly for nulls, collations, navigations, filters, and provider limits.

The EF Core adapter is deliberately a materializing infrastructure boundary. Applications pass Boolean rules to repositories; they do not receive deferred queries, provider expressions, or EF configuration.

The repository owns the query

The example application exposes a small domain-facing contract:

public interface IOrderRepository
{
    Task<IReadOnlyList<Order>> ListAsync(
        Spec<Order> specification,
        CancellationToken cancellationToken = default);

    Task<bool> AnyAsync(
        Spec<Order> specification,
        CancellationToken cancellationToken = default);
}

Application code supplies a specification and receives materialized data:

public static Task<IReadOnlyList<Order>> FindReadyOrdersAsync(
    IOrderRepository repository,
    CancellationToken cancellationToken = default) =>
    repository.ListAsync(
        CanShip.And(HighPriority.Or.ManualOverride),
        cancellationToken);

Sorting, paging, projections, includes, tracking, split queries, and cache policy remain repository concerns because they are not Boolean rules and are not meaningfully closed under And or Or.

Translation is checked before execution

The relational adapter composes the complete expression and asks the configured provider to translate it before executing a command. An unsupported filter produces structured rule and tree-path errors; it does not fetch an unbounded set and retry in memory:

[Fact]
public async Task Unsupported_filter_fails_before_any_select_is_executed()
{
    await using var database = await ExampleDatabase.CreateAsync();
    database.CommandCounter.Reset();
    var repository = new EfOrderRepository(database.Context);
    var inMemoryCandidate = new Order { CustomerName = "ALICE" };
    var rule = CustomerNamedIgnoringCase("alice");

    Assert.True(rule.Matches(inMemoryCandidate));

    var exception = await Assert.ThrowsAsync<SpecificationTranslationException>(() =>
        repository.ListAsync(rule));

    var error = Assert.Single(exception.Errors);
    Assert.Equal("ef-core-translation-failed", error.Code);
    Assert.Equal("order.customer-named-ignoring-case", error.RuleId);
    Assert.Equal("$", error.NodePath);
    Assert.Equal(0, database.CommandCounter.ReaderExecutions);
}

Modern EF Core also rejects untranslatable filter expressions rather than silently evaluating them on the client. Only the top-level projection permits limited client evaluation. See Microsoft’s client versus server evaluation guidance.

ListAsync, AnyAsync, and CountAsync return a list, Boolean, or integer. The adapter’s exported API is guarded against accidentally accepting or returning IQueryable:

[Fact]
public void Public_ef_adapter_api_never_returns_or_accepts_iqueryable()
{
    var offendingTypes = typeof(RelationalSpecExecutor<>).Assembly
        .GetExportedTypes()
        .SelectMany(PublicApiTypes)
        .Where(ContainsQueryable)
        .ToArray();

    Assert.Empty(offendingTypes);
}

In-memory success is not translation proof

A domain method or a StringComparison overload can work perfectly through Matches and still be unsupported by the provider. Treat translation as a capability of a particular provider, model, and EF version—not of the C# expression in isolation.

Captured rule arguments should become database parameters. Do not read mutable ambient state or clocks from inside a predicate.

Null semantics can diverge

EF normally adds SQL compensation so nullable comparisons behave more like CLR two-valued logic. The example proves parity for nullable inequality under the default mode:

[Fact]
public async Task Null_inequality_has_matching_clr_and_default_ef_semantics()
{
    await using var database = await ExampleDatabase.CreateAsync();
    var repository = new EfOrderRepository(database.Context);
    var rule = CustomerReferenceIsNot("BLOCKED");
    var inMemoryIds = database.VisibleSeedOrders
        .Where(rule.Matches)
        .Select(order => order.Id)
        .Order()
        .ToArray();

    var databaseIds = (await repository.ListAsync(rule))
        .Select(order => order.Id)
        .Order()
        .ToArray();

    Assert.Equal(inMemoryIds, databaseIds);
    Assert.Contains(1, databaseIds); // null != "BLOCKED" under compensated semantics
    Assert.DoesNotContain(4, databaseIds);
}

Enabling relational null semantics deliberately changes that result. Read EF Core query null semantics before changing the option, and test the exact predicates your application depends on.

Strings belong to the database collation

Case and accent behavior comes from the column or database collation. EF does not translate string.Equals overloads that take StringComparison, because it cannot infer an appropriate collation. Calling ToLower to force equality can also prevent index use. See collations and case sensitivity.

Tests should state the provider-specific result rather than naming it as a universal string rule.

Guard optional navigations explicitly when the rule must behave safely in memory. SQL translation may null-propagate an unsafe-looking navigation access, which can otherwise create a difference between CLR and database behavior.

A navigation predicate filters; it does not request eager loading. The example verifies that the related customer is not populated merely because the rule mentioned it, and that list materialization is no-tracking.

Global filters still apply

Always<T>() means the specification adds no restriction. It does not bypass tenant, soft-delete, or other EF model filters. Repositories should never rely on a specification to neutralize those safety boundaries.

Provider limits are real

SQLite is a useful relational test provider, but it has scalar limitations. In particular, ordering and comparison for types including DateTimeOffset, decimal, and TimeSpan can be unsupported. See the official SQLite provider limitations.

The adapter turns the demonstrated DateTimeOffset comparison failure into a structured translation error:

[Fact]
public async Task Provider_specific_scalar_limit_is_a_structured_translation_error()
{
    await using var database = await ExampleDatabase.CreateAsync();
    var repository = new EfOrderRepository(database.Context);
    var rule = CreatedBefore(DateTimeOffset.UtcNow);

    var exception = await Assert.ThrowsAsync<SpecificationTranslationException>(() =>
        repository.ListAsync(rule));

    var error = Assert.Single(exception.Errors);
    Assert.Equal("order.created-before", error.RuleId);
    Assert.Equal("$", error.NodePath);
}

What the SQLite suite does not prove

SQLite in-memory exercises a real relational translator and database. It does not establish SQL Server, PostgreSQL, or production-schema conformance. EF’s InMemory provider is used here only to prove that the relational adapter rejects a non-relational context—not to validate query semantics.

Microsoft recommends testing important queries against the actual production database and discourages the InMemory provider as a query fake. See choosing a testing strategy and the provider matrix.