Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.5" />
<PackageVersion Include="System.Net.NameResolution" Version="4.3.0" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.5" />
<PackageVersion Include="Weasel.Core" Version="8.13.0" />
<PackageVersion Include="Weasel.EntityFrameworkCore" Version="8.13.0" />
<PackageVersion Include="Weasel.MySql" Version="8.13.0" />
<PackageVersion Include="Weasel.Oracle" Version="8.13.0" />
<PackageVersion Include="Weasel.Postgresql" Version="8.13.0" />
<PackageVersion Include="Weasel.SqlServer" Version="8.13.0" />
<PackageVersion Include="Weasel.Sqlite" Version="8.13.0" />
<PackageVersion Include="Weasel.Core" Version="8.14.0" />
<PackageVersion Include="Weasel.EntityFrameworkCore" Version="8.14.0" />
<PackageVersion Include="Weasel.MySql" Version="8.14.0" />
<PackageVersion Include="Weasel.Oracle" Version="8.14.0" />
<PackageVersion Include="Weasel.Postgresql" Version="8.14.0" />
<PackageVersion Include="Weasel.SqlServer" Version="8.14.0" />
<PackageVersion Include="Weasel.Sqlite" Version="8.14.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.assemblyfixture" Version="2.2.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
Expand Down
4 changes: 3 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ const config: UserConfig<DefaultTheme.Config> = {
{text: 'Multi-Tenancy', link: '/guide/durability/efcore/multi-tenancy'},
{text: 'Domain Events', link: '/guide/durability/efcore/domain-events'},
{text: 'Database Migrations', link: '/guide/durability/efcore/migrations'},
{text: 'Query Plans', link: '/guide/durability/efcore/query-plans'}
{text: 'Query Plans', link: '/guide/durability/efcore/query-plans'},
{text: 'Batch Queries', link: '/guide/durability/efcore/batch-queries'},
{text: 'Initial Data', link: '/guide/durability/efcore/initial-data'}

]},
{text: 'Managing Message Storage', link: '/guide/durability/managing'},
Expand Down
160 changes: 160 additions & 0 deletions docs/guide/durability/efcore/batch-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Batch Queries / Futures <Badge type="tip" text="5.32" />

Wolverine's EF Core integration can collapse multiple related `SELECT`s inside one handler into a single database round-trip, using [Weasel's `BatchedQuery`](https://weasel.jasperfx.net/efcore/batch-queries.html) (the EF Core counterpart to Marten's [batched query](https://martendb.io/documents/querying/batched-queries.html)) as the underlying mechanism.

This page is Wolverine-focused: handler patterns, auto-batching, what the code generator does for you. For the underlying `BatchedQuery` fluent API, see [Weasel's batch-queries guide](https://weasel.jasperfx.net/efcore/batch-queries.html).

## Why batch?

Every extra `SELECT` in a handler is another round-trip to the database. Four queries against a local SQL Server, same handler, four small rows:

| Strategy | Total (50 iterations) | Per handler | Relative |
|---|---|---|---|
| Sequential `await`s | 345.8 ms | **6.92 ms** | 1.0× |
| Batched via `BatchedQuery` | 124.3 ms | **2.49 ms** | **2.78×** |

Measured locally against `mcr.microsoft.com/mssql/server`, 4 keyed lookups per iteration, after warm-up. The speedup scales with (a) number of queries in the handler and (b) network latency — across a region-to-region hop, a four-query handler can drop from ~40 ms to ~12 ms.

## Pattern 1 — Two `IQueryPlan`s on one handler

The simplest win. If your handler [uses query plans](./query-plans) that implement *both* `IQueryPlan<TDbContext, TResult>` **and** `IBatchQueryPlan<TDbContext, TResult>` (which `QueryPlan<TDb, TEntity>` and `QueryListPlan<TDb, TEntity>` do automatically), Wolverine's code generator detects multiple batch-capable loads on the same handler and rewrites them into a shared `BatchedQuery`:

```csharp
public class ShipmentOverview
{
public Customer Customer { get; set; } = null!;
public IReadOnlyList<Order> RecentOrders { get; set; } = [];
}

public record GetShipmentOverview(Guid CustomerId);

public static class ShipmentOverviewHandler
{
public static async Task<ShipmentOverview> Handle(
GetShipmentOverview query,
CustomerById customerSpec, // IQueryPlan + IBatchQueryPlan via QueryPlan<>
RecentOrdersFor ordersSpec) // " " QueryListPlan<>
{
return new ShipmentOverview
{
Customer = await customerSpec.FetchAsync(...),
RecentOrders = await ordersSpec.FetchAsync(...)
};
}
}
```

Wolverine's `EFCoreBatchingPolicy` inspects the handler chain, detects the two batch-capable plans, and generates code equivalent to:

```csharp
// Generated (simplified)
var batch = db.CreateBatchQuery();
var customerTask = customerSpec.FetchAsync(batch, db);
var ordersTask = ordersSpec.FetchAsync(batch, db);
await batch.ExecuteAsync(cancellation);
var customer = await customerTask;
var orders = await ordersTask;
```

One round-trip, no manual batch wiring, no change to the plan classes.

## Pattern 2 — Manual `IBatchQueryPlan<TDbContext, TResult>`

When a query doesn't fit the `QueryPlan<>` / `QueryListPlan<>` shape — for example, a projection into a DTO or a pre-aggregated count — implement `IBatchQueryPlan` directly:

```csharp
public class OrderCountFor(Guid customerId) : IBatchQueryPlan<OrderDbContext, int>
{
public Task<int> FetchAsync(BatchedQuery batch, OrderDbContext db)
=> batch.QueryCount(db.Orders.Where(x => x.CustomerId == customerId));
}
```

Any handler parameter implementing `IBatchQueryPlan<TDb, TResult>` gets the same auto-batching treatment as `IQueryPlan`-derived plans. Use `IBatchQueryPlan` when you need full control over the batched shape; use `QueryPlan<>` / `QueryListPlan<>` when you want the plan to work both standalone and batched.

## Pattern 3 — `[Entity]` primary-key lookup + a spec in the same handler

`[Entity]` lookups and query plans batch together. A common shape — load the root aggregate by id, then fetch a related collection with a spec:

```csharp
public record ArchiveOrderLines(Guid OrderId);

public static class ArchiveOrderLinesHandler
{
public static async Task Handle(
ArchiveOrderLines cmd,
[Entity] Order order, // PK lookup — batch-capable
ActiveLinesFor linesSpec, // QueryListPlan — batch-capable
OrderDbContext db)
{
order.Archive();
foreach (var line in await linesSpec.FetchAsync(...))
{
line.Archive();
}
await db.SaveChangesAsync();
}
}
```

Both the `[Entity]` load and the spec fetch are enlisted into a single `BatchedQuery`, one round-trip for two reads.

## Pattern 4 — Count + page in one round-trip

Paginated list responses almost always want `(totalCount, currentPage)`. Two queries, same filter, one logical operation. Keep it one round-trip:

```csharp
public record OrderSearch(string? Customer, int Page, int PageSize);

public class OrderSearchCount(string? customer) : IBatchQueryPlan<OrderDbContext, int>
{
public Task<int> FetchAsync(BatchedQuery batch, OrderDbContext db)
=> batch.QueryCount(OrdersMatching(db, customer));
}

public class OrderSearchPage(string? customer, int page, int pageSize)
: IBatchQueryPlan<OrderDbContext, IReadOnlyList<Order>>
{
public async Task<IReadOnlyList<Order>> FetchAsync(BatchedQuery batch, OrderDbContext db)
{
var list = await batch.Query(
OrdersMatching(db, customer)
.OrderByDescending(x => x.PlacedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize));
return list.ToList();
}
}

static IQueryable<Order> OrdersMatching(OrderDbContext db, string? customer)
=> customer is null
? db.Orders
: db.Orders.Where(x => x.Customer.Name.Contains(customer));

public static class OrderSearchHandler
{
public static async Task<(int Total, IReadOnlyList<Order> Page)> Handle(
OrderSearch query,
OrderSearchCount countPlan,
OrderSearchPage pagePlan)
{
return (await countPlan.FetchAsync(...), await pagePlan.FetchAsync(...));
}
}
```

One `SELECT COUNT(*)` + one paginated `SELECT`, merged into a single `DbBatch` on the wire.

## Opting out

If a specific handler genuinely needs sequential round-trips (e.g. because a later query depends on an earlier query's result), just don't use batch-capable plans — the batching policy only fires when two or more `IBatchQueryPlan` parameters appear on the same handler.

## Testing batched handlers

The batching is transparent to tests. The same handler that uses `IQueryPlan` parameters can be unit-tested against an in-memory `DbContext` the normal way — query plans execute standalone through `FetchAsync(DbContext)` when invoked outside a `BatchedQuery`. See [Query Plans → Testing a plan in isolation](./query-plans#testing-a-plan-in-isolation).

## Further reading

- [Weasel — `BatchedQuery` fluent API reference](https://weasel.jasperfx.net/efcore/batch-queries.html)
- [Query Plans](./query-plans) — the Specification model that backs the auto-batching
- [Marten — Batched Queries](https://martendb.io/documents/querying/batched-queries.html) — the Critter Stack sibling this mirrors
75 changes: 75 additions & 0 deletions docs/guide/durability/efcore/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,78 @@ builder.UseWolverine(opts =>


Right now, we've tested Wolverine with EF Core using both [SQL Server](/guide/durability/sqlserver) and [PostgreSQL](/guide/durability/postgresql) persistence.

## Development-time usage <Badge type="tip" text="5.32" />

Wolverine + EF Core is designed to keep the dev loop short: fast schema iteration, cheap per-test database resets, declarative seed data. The three pillars below all work together — and all come for free the moment you call `UseEntityFrameworkCoreTransactions()`.

### Weasel-managed schema migrations

`UseEntityFrameworkCoreWolverineManagedMigrations()` hands schema management to [Weasel](https://weasel.jasperfx.net/efcore/migrations.html) rather than EF Core's migration-chain tooling. The shape of the story:

| | EF Core migrations | Weasel migrations |
|---|---|---|
| Model | Ordered chain of up/down scripts checked in alongside code | Diff the live database against the current `DbContext` model at startup |
| Authoring | Generate + edit migration classes | Nothing — just change your model |
| Iteration cost | Slow (script regeneration, merge conflicts on parallel branches) | None — restart the app |
| Best for | Production deployments with a change audit | Local dev, integration tests, short-lived branches |

Register it on `WolverineOptions`:

```csharp
builder.UseWolverine(opts =>
{
opts.Services.AddDbContextWithWolverineIntegration<ItemsDbContext>(
x => x.UseSqlServer(connectionString));

// Diff the DbContext against the live DB at startup and apply missing DDL.
opts.UseEntityFrameworkCoreWolverineManagedMigrations();
});
```

The [Weasel docs](https://weasel.jasperfx.net/efcore/migrations.html) go deeper on the diff engine, opt-outs, and how it handles schemas.

### IInitialData — declarative seed data

Implement `Weasel.EntityFrameworkCore.IInitialData<TContext>` (or register a lambda with `services.AddInitialData<TContext>(...)`) to declare data that should be present every time the database is reset:

```csharp
public class SeedItems : IInitialData<ItemsDbContext>
{
public async Task Populate(ItemsDbContext context, CancellationToken cancellation)
{
context.Items.Add(new Item { Name = "Seed" });
await context.SaveChangesAsync(cancellation);
}
}

builder.Services.AddInitialData<ItemsDbContext, SeedItems>();
```

Multiple seeders run in registration order. See the [dedicated page on initial data](./initial-data) for patterns around layered seeders, lambda-based registration, and multi-tenant seeding.

### Resetting data between tests

Two knobs, finest-grained first:

**Per-DbContext — `host.ResetAllDataAsync<T>()`** <Badge type="tip" text="5.32" />

Wipes one `DbContext`'s tables in FK-safe order and reruns that context's `IInitialData<T>` seeders. This is the right default for most integration tests:

```csharp
[Fact]
public async Task ordering_flow()
{
await _host.ResetAllDataAsync<ItemsDbContext>();

// arrange ... act ... assert
}
```

The underlying `DatabaseCleaner<T>` is registered automatically by `UseEntityFrameworkCoreTransactions()` — no `services.AddDatabaseCleaner<T>()` needed.

**Global — `host.ResetResourceState()`**

Resets every `IStatefulResource` registered with the host — Wolverine's message store, every broker, every `DbContext` cleaner. Bigger hammer; right when a test writes to multiple stores or you've seen cross-test contamination you can't isolate.

**Recommendation:** use the finest-grained mechanism the test actually needs. Resetting the world on every test multiplies your suite runtime for no benefit.
96 changes: 96 additions & 0 deletions docs/guide/durability/efcore/initial-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Initial Data <Badge type="tip" text="5.32" />

`Weasel.EntityFrameworkCore.IInitialData<TContext>` is the declarative seed-data hook that runs every time a `DbContext` is reset via `DatabaseCleaner<T>.ResetAllDataAsync()` or Wolverine's [`host.ResetAllDataAsync<T>()`](./index#resetting-data-between-tests). It's the recommended way to keep integration tests and local dev loops seeded with a known baseline without hand-rolling setup code per test.

See the [Weasel docs on `IInitialData`](https://weasel.jasperfx.net/efcore/database-cleaner.html#iinitialdata) for the authoritative reference — this page is a Wolverine-focused overview of how to use it well.

## Class-based seeders

The traditional form. Implement `Populate`, register with `services.AddInitialData<TContext, TData>()`:

```csharp
public class SeedCoreItems : IInitialData<ItemsDbContext>
{
public static readonly Item[] Items =
[
new Item { Name = "Alpha" },
new Item { Name = "Beta" }
];

public async Task Populate(ItemsDbContext context, CancellationToken cancellation)
{
context.Items.AddRange(Items);
await context.SaveChangesAsync(cancellation);
}
}

// Registration
builder.Services.AddInitialData<ItemsDbContext, SeedCoreItems>();
```

## Lambda seeders

For small amounts of seed data, authoring a class is overkill. Weasel 8.14+ exposes a lambda overload:

```csharp
builder.Services.AddInitialData<ItemsDbContext>(async (ctx, ct) =>
{
ctx.Items.Add(new Item { Name = "Gamma" });
await ctx.SaveChangesAsync(ct);
});
```

Class-based and lambda seeders can be freely mixed in the same application; they're all resolved as `IEnumerable<IInitialData<TContext>>` at reset time and run in registration order.

## Layered seeders

Because every registered `IInitialData<T>` runs on every reset, you can compose seed data by registering multiple seeders rather than stuffing everything into one class:

```csharp
// Baseline data needed by every test suite.
builder.Services.AddInitialData<ItemsDbContext, SeedCoreItems>();

// Only in Development, add some demo rows.
if (builder.Environment.IsDevelopment())
{
builder.Services.AddInitialData<ItemsDbContext>(async (ctx, ct) =>
{
ctx.Items.Add(new Item { Name = "Dev Demo" });
await ctx.SaveChangesAsync(ct);
});
}
```

This is easier to maintain than a single "giant seeder" and plays well with conditional registration (environment, feature flags, tenant).

## Interaction with `ResetAllDataAsync<T>`

`host.ResetAllDataAsync<ItemsDbContext>()` does two things, in order:

1. Delete every row from the tables mapped by `ItemsDbContext` in foreign-key-safe order.
2. Invoke every registered `IInitialData<ItemsDbContext>`.

So the contract of a seeder is: **after me, the database contains whatever I just wrote, plus whatever later seeders write.** If you need the row to exist after `ResetAllDataAsync<T>()`, put it in an `IInitialData<T>`.

## Idempotency

Seeders are expected to be idempotent across resets — the cleaner always deletes first, so you can use fixed primary keys without collision concerns:

```csharp
public async Task Populate(ItemsDbContext context, CancellationToken cancellation)
{
context.Items.Add(new Item
{
Id = Guid.Parse("10000000-0000-0000-0000-000000000001"),
Name = "Deterministic seed"
});
await context.SaveChangesAsync(cancellation);
}
```

This makes assertions trivial: look up the known Id, no need to keep a reference returned from setup.

## When not to use `IInitialData`

- **Production bootstrap data** — `IInitialData` runs on *reset*, not on every app start. For data that should exist on first deploy, use EF Core's [`UseSeeding`](https://learn.microsoft.com/ef/core/modeling/data-seeding) or an explicit setup command.
- **Per-test unique fixtures** — if each test needs fundamentally different baseline data, keep that inside the test (Arrange) rather than baking it into shared seeders. `IInitialData` is for the *common floor* across your suite.
2 changes: 1 addition & 1 deletion docs/guide/durability/efcore/migrations.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Database Migrations

Wolverine uses [Weasel](https://github.com/JasperFx/weasel) for schema management of EF Core `DbContext` types rather than EF Core's own migration system. This approach provides a consistent schema management experience across the entire "critter stack" (Wolverine + Marten) and avoids issues with EF Core's `Database.EnsureCreatedAsync()` bypassing migration history.
Wolverine uses [Weasel](https://weasel.jasperfx.net/) for schema management of EF Core `DbContext` types rather than EF Core's own migration system. This approach provides a consistent schema management experience across the entire "critter stack" (Wolverine + Marten) and avoids issues with EF Core's `Database.EnsureCreatedAsync()` bypassing migration history. See the [Weasel EF Core migration docs](https://weasel.jasperfx.net/efcore/migrations.html) for the underlying diff engine, provider-specific behavior, and opt-outs.

## How It Works

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/durability/efcore/query-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ You can also return a plan instance directly from a handler's `Load` /
plan type in the return and auto-executes it, passing the materialized
result to `Handle` / `Validate` / `After` parameters. When multiple
batch-capable plans target the same `DbContext` on one handler, they share
a single [Weasel `BatchedQuery`](https://github.com/JasperFx/weasel) —
a single [Weasel `BatchedQuery`](https://weasel.jasperfx.net/efcore/batch-queries.html) —
**one database round-trip for all plans**.

```csharp
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/durability/managing.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ The available commands are:
storage Administer the envelope storage
```

There's admittedly some duplication here with different options coming from [Oakton](https://jasperfx.github.io/oakton) itself, the [Weasel.CommandLine](https://github.com/JasperFx/weasel) library,
There's admittedly some duplication here with different options coming from [Oakton](https://jasperfx.github.io/oakton) itself, the [Weasel.CommandLine](https://weasel.jasperfx.net/) library,
and the `storage` command from Wolverine itself. To build out the schema objects for [message persistence](/guide/durability/), you
can use this command to apply any outstanding database changes necessary to bring the database schema to the Wolverine configuration:

Expand Down
Loading
Loading