Skip to content

Add query-plan (Specification pattern) support for EF Core#2526

Merged
jeremydmiller merged 1 commit intomainfrom
feature/ef-core-query-plans-2505
Apr 17, 2026
Merged

Add query-plan (Specification pattern) support for EF Core#2526
jeremydmiller merged 1 commit intomainfrom
feature/ef-core-query-plans-2505

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Summary

Phase 1 of #2505. Introduces Wolverine.EntityFrameworkCore.IQueryPlan<TDbContext, TResult> plus convenience base classes — the Specification pattern implemented as a first-class concept, patterned after Marten's IQueryPlan and Ardalis.Specification.

This fills the gap between [Entity] (primary-key lookup only) and a full repository/adapter layer. Handlers can now encapsulate reusable LINQ over a DbContext in a testable class, without any code generation or DI plumbing.

API

// Interface — minimal, matches Marten's shape
public interface IQueryPlan<in TDbContext, TResult> where TDbContext : DbContext
{
    Task<TResult> FetchAsync(TDbContext dbContext, CancellationToken cancellation);
}

// Convenience base: single result via FirstOrDefaultAsync
public abstract class QueryPlan<TDb, TEntity> : IQueryPlan<TDb, TEntity?>
    where TDb : DbContext where TEntity : class
{
    public abstract IQueryable<TEntity> Query(TDb db);
}

// Convenience base: list result via ToListAsync
public abstract class QueryListPlan<TDb, TEntity> : IQueryPlan<TDb, IReadOnlyList<TEntity>>
    where TDb : DbContext where TEntity : class
{
    public abstract IQueryable<TEntity> Query(TDb db);
}

// Sugar on DbContext (mirrors Marten's session.QueryByPlanAsync)
await db.QueryByPlanAsync(new ActiveOrderForCustomer(customerId), ct);

User code

public class ActiveOrderForCustomer(Guid customerId) : QueryPlan<OrderDbContext, Order>
{
    public override IQueryable<Order> Query(OrderDbContext db)
        => db.Orders.Where(x => x.CustomerId == customerId && !x.IsArchived)
                    .OrderByDescending(x => x.CreatedAt);
}

public static async Task Handle(ApproveOrder msg, OrderDbContext db, CancellationToken ct)
{
    var order = await db.QueryByPlanAsync(new ActiveOrderForCustomer(msg.CustomerId), ct);
    order?.Approve();
}

Design notes

  • Naming matches Marten (IQueryPlan / QueryPlan / QueryListPlan) rather than ISpecification — consistency inside the Critter Stack wins, and avoids conflict with users already using Ardalis.Specification
  • No code generation — plans are plain classes. Zero DI registration, zero source generators, zero runtime reflection
  • Full IQueryable<T> surface — no custom DSL to learn. Include, OrderBy, Select, Skip, Take, projection to DTOs all work
  • Testable in isolation — unit tests use EF Core's in-memory provider, no host required
  • Multi-tenancy compatible — the plan receives whatever DbContext the handler receives (already tenant-scoped by existing middleware)

Out of scope (deferred to follow-up)

  • Phase 2: [FromQueryPlan(typeof(TPlan))] parameter attribute that constructs the plan from message fields at codegen time (mirrors how [Entity] resolves its identity). Worth its own PR once the core shape is settled.
  • Phase 3: batched query plan (if there's demand — existing BatchedLoadEntityFrame infrastructure is the template)

Files

  • New under src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/: IQueryPlan.cs, QueryPlan.cs, QueryListPlan.cs, QueryPlanExtensions.cs
  • New tests under src/Persistence/EfCoreTests/QueryPlans/: QueryPlan_specs.cs (9 unit tests), QueryPlan_end_to_end.cs (1 SQL Server integration test)
  • New docs: docs/guide/durability/efcore/query-plans.md + nav entry in .vitepress/config.mts

Test plan

  • 9 unit tests against EF Core in-memory provider — constructor parameters, empty results, null results, extension method, ordering, null-arg validation
  • 1 end-to-end integration test: handler consumes a plan against real SQL Server + Wolverine's transactional middleware; verifies downstream SaveChangesAsync commits the mutations the plan's results triggered
  • 18 existing batch_query_tests + end_to_end_efcore_persistence tests still pass (no regressions)

🤖 Generated with Claude Code

Introduces Wolverine.EntityFrameworkCore.IQueryPlan<TDbContext, TResult>,
QueryPlan<TDb, TEntity>, and QueryListPlan<TDb, TEntity>. This is the
Specification pattern implemented as a first-class concept, patterned
after Marten's IQueryPlan and Ardalis.Specification.

Motivation: handlers often need queries richer than a primary-key lookup
(which [Entity] already covers) but not complex enough to justify a
repository layer. A query plan lets authors encapsulate reusable LINQ
over a DbContext in its own testable class.

Phase 1 scope:
- Base types in Wolverine.EntityFrameworkCore.QueryPlans
- QueryByPlanAsync extension on DbContext (parallels Marten's
  IQuerySession.QueryByPlanAsync)
- Zero code-generation — plans are plain classes, no DI, no reflection,
  no source generators
- 9 unit tests against the EF Core in-memory provider
- 1 end-to-end integration test using SQL Server + Wolverine's
  transactional middleware
- New documentation page: docs/guide/durability/efcore/query-plans.md

Deferred to follow-up: [FromQueryPlan(typeof(TPlan))] parameter
attribute that constructs the plan from message fields at codegen time,
mirroring how [Entity] resolves its identity.

Closes #2505

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant