diff --git a/src/Persistence/EfCoreTests/batch_query_tests.cs b/src/Persistence/EfCoreTests/batch_query_tests.cs
new file mode 100644
index 000000000..6bc4b59c5
--- /dev/null
+++ b/src/Persistence/EfCoreTests/batch_query_tests.cs
@@ -0,0 +1,169 @@
+using IntegrationTests;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Resources;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharedPersistenceModels.Items;
+using Shouldly;
+using Weasel.EntityFrameworkCore.Batching;
+using Wolverine;
+using Wolverine.EntityFrameworkCore;
+using Wolverine.EntityFrameworkCore.Codegen;
+using Wolverine.SqlServer;
+
+namespace EfCoreTests;
+
+///
+/// Integration tests demonstrating Weasel's BatchedQuery API for combining multiple
+/// EF Core SELECT queries into a single database round trip. See also the
+/// BatchedLoadEntityFrame / CreateBatchQueryFrame / ExecuteBatchQueryFrame codegen
+/// infrastructure in Wolverine.EntityFrameworkCore.Codegen for generated-code equivalents.
+///
+[Collection("sqlserver")]
+public class batch_query_tests : IClassFixture
+{
+ private readonly IHost _host;
+
+ public batch_query_tests(EFCorePersistenceContext ctx)
+ {
+ _host = ctx.theHost;
+ }
+
+ private async Task InsertItemAsync(string name)
+ {
+ var id = Guid.NewGuid();
+ using var scope = _host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Items.Add(new Item { Id = id, Name = name });
+ await db.SaveChangesAsync();
+ return id;
+ }
+
+ [Fact]
+ public async Task load_two_entities_in_single_round_trip()
+ {
+ var id1 = await InsertItemAsync("Batch Load A");
+ var id2 = await InsertItemAsync("Batch Load B");
+
+ using var scope = _host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // All three calls to batch.Query*/Scalar return Tasks that resolve
+ // only after batch.ExecuteAsync() issues one DbBatch to the server.
+ var batch = db.CreateBatchQuery();
+ var item1Task = batch.QuerySingle(db.Items.Where(x => x.Id == id1));
+ var item2Task = batch.QuerySingle(db.Items.Where(x => x.Id == id2));
+ await batch.ExecuteAsync();
+
+ var item1 = await item1Task;
+ var item2 = await item2Task;
+
+ item1.ShouldNotBeNull();
+ item1.Name.ShouldBe("Batch Load A");
+ item2.ShouldNotBeNull();
+ item2.Name.ShouldBe("Batch Load B");
+ }
+
+ [Fact]
+ public async Task load_list_of_entities_via_batch()
+ {
+ var prefix = Guid.NewGuid().ToString("N")[..8];
+ using (var scope = _host.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Items.AddRange(
+ new Item { Id = Guid.NewGuid(), Name = $"{prefix}_list_0" },
+ new Item { Id = Guid.NewGuid(), Name = $"{prefix}_list_1" },
+ new Item { Id = Guid.NewGuid(), Name = $"{prefix}_list_2" });
+ await db.SaveChangesAsync();
+ }
+
+ using (var scope = _host.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var batch = db.CreateBatchQuery();
+ var listTask = batch.Query(db.Items.Where(x => x.Name.StartsWith(prefix)));
+ await batch.ExecuteAsync();
+
+ var items = await listTask;
+ items.Count.ShouldBe(3);
+ items.All(x => x.Name.StartsWith(prefix)).ShouldBeTrue();
+ }
+ }
+
+ [Fact]
+ public async Task mix_single_and_list_queries_in_same_batch()
+ {
+ var id1 = await InsertItemAsync("Mixed Single");
+ var prefix = Guid.NewGuid().ToString("N")[..8];
+
+ using (var scope = _host.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Items.AddRange(
+ new Item { Id = Guid.NewGuid(), Name = $"{prefix}_a" },
+ new Item { Id = Guid.NewGuid(), Name = $"{prefix}_b" });
+ await db.SaveChangesAsync();
+ }
+
+ using (var scope = _host.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var batch = db.CreateBatchQuery();
+ var singleTask = batch.QuerySingle(db.Items.Where(x => x.Id == id1));
+ var listTask = batch.Query(db.Items.Where(x => x.Name.StartsWith(prefix)));
+ await batch.ExecuteAsync();
+
+ var single = await singleTask;
+ var list = await listTask;
+
+ single.ShouldNotBeNull();
+ single.Id.ShouldBe(id1);
+ list.Count.ShouldBe(2);
+ }
+ }
+}
+
+///
+/// Unit tests for the BatchedLoadEntityFrame / CreateBatchQueryFrame /
+/// ExecuteBatchQueryFrame codegen infrastructure introduced in #2478.
+///
+public class batched_load_entity_frame_codegen_tests
+{
+ [Fact]
+ public void batched_load_frame_creates_saga_and_task_variables()
+ {
+ var sagaIdVar = Variable.For();
+ var frame = new BatchedLoadEntityFrame(
+ typeof(ItemsDbContext),
+ typeof(Item),
+ sagaIdVar,
+ "Id");
+
+ frame.Saga.VariableType.ShouldBe(typeof(Item));
+ frame.SagaTask.VariableType.ShouldBe(typeof(Task- ));
+ }
+
+ [Fact]
+ public void create_batch_query_frame_creates_batch_variable()
+ {
+ var frame = new CreateBatchQueryFrame(typeof(ItemsDbContext));
+ frame.BatchQuery.VariableType.ShouldBe(typeof(BatchedQuery));
+ frame.BatchQuery.Usage.ShouldBe("batchQuery");
+ }
+
+ [Fact]
+ public void execute_batch_query_frame_accepts_multiple_load_frames()
+ {
+ var batchVar = new CreateBatchQueryFrame(typeof(ItemsDbContext)).BatchQuery;
+ var idVar = Variable.For();
+
+ var load1 = new BatchedLoadEntityFrame(typeof(ItemsDbContext), typeof(Item), idVar, "Id");
+ var load2 = new BatchedLoadEntityFrame(typeof(ItemsDbContext), typeof(Item), idVar, "Id");
+
+ var execFrame = new ExecuteBatchQueryFrame(batchVar, [load1, load2]);
+ execFrame.ShouldNotBeNull();
+ }
+}
diff --git a/src/Persistence/EfCoreTests/database_cleaner_tests.cs b/src/Persistence/EfCoreTests/database_cleaner_tests.cs
new file mode 100644
index 000000000..be444e1a9
--- /dev/null
+++ b/src/Persistence/EfCoreTests/database_cleaner_tests.cs
@@ -0,0 +1,151 @@
+using IntegrationTests;
+using JasperFx.Resources;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharedPersistenceModels.Items;
+using Shouldly;
+using Weasel.EntityFrameworkCore;
+using Wolverine;
+using Wolverine.EntityFrameworkCore;
+using Wolverine.SqlServer;
+
+namespace EfCoreTests;
+
+///
+/// Demonstrates IInitialData<TContext> for FK-safe seed data applied after
+/// IDatabaseCleaner<TContext>.ResetAllDataAsync().
+///
+public class SeedItemsForTests : IInitialData
+{
+ public static readonly Item[] Items =
+ [
+ new Item { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "Alpha Item" },
+ new Item { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Name = "Beta Item" }
+ ];
+
+ public async Task Populate(ItemsDbContext context, CancellationToken cancellation)
+ {
+ context.Items.AddRange(Items);
+ await context.SaveChangesAsync(cancellation);
+ }
+}
+
+///
+/// Test fixture that registers IDatabaseCleaner<ItemsDbContext> and IInitialData<ItemsDbContext>
+/// alongside Wolverine's normal setup.
+///
+public class DatabaseCleanerContext : IAsyncLifetime
+{
+ public IHost Host { get; private set; } = null!;
+
+ public async Task InitializeAsync()
+ {
+ Host = await Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
+ .ConfigureServices((_, services) =>
+ {
+ services.AddDbContext(x => x.UseSqlServer(Servers.SqlServerConnectionString));
+
+ // Registers IDatabaseCleaner as a singleton.
+ // It discovers tables from DbContext.Model, topologically sorts them by
+ // FK dependencies, and generates provider-specific deletion SQL
+ // (TRUNCATE CASCADE for PostgreSQL, DELETE FROM for SQL Server).
+ services.AddDatabaseCleaner();
+
+ // Registers seed data applied after ResetAllDataAsync().
+ // Multiple IInitialData registrations execute in order.
+ services.AddInitialData();
+ })
+ .UseWolverine(opts =>
+ {
+ opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString);
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
+ opts.UseEntityFrameworkCoreWolverineManagedMigrations();
+ opts.Policies.AutoApplyTransactions();
+ })
+ .StartAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await Host.StopAsync();
+ Host.Dispose();
+ }
+}
+
+///
+/// Integration tests demonstrating Weasel's IDatabaseCleaner<TContext> as the
+/// recommended pattern for FK-aware database reset in EF Core integration tests.
+/// Replaces manual DELETE FROM / TRUNCATE / schema-drop cleanup code.
+///
+[Collection("sqlserver")]
+public class database_cleaner_usage_tests : IClassFixture
+{
+ private readonly DatabaseCleanerContext _ctx;
+
+ public database_cleaner_usage_tests(DatabaseCleanerContext ctx)
+ {
+ _ctx = ctx;
+ }
+
+ private IDatabaseCleaner Cleaner =>
+ _ctx.Host.Services.GetRequiredService>();
+
+ private async Task CountItemsAsync()
+ {
+ using var scope = _ctx.Host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.Items.CountAsync();
+ }
+
+ [Fact]
+ public async Task delete_all_data_removes_every_row()
+ {
+ // Arrange: insert some extra items so the table is non-empty
+ using (var scope = _ctx.Host.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Items.Add(new Item { Id = Guid.NewGuid(), Name = "Temp Item" });
+ await db.SaveChangesAsync();
+ }
+
+ // Act: FK-safe bulk delete (no seeding)
+ await Cleaner.DeleteAllDataAsync();
+
+ // Assert
+ (await CountItemsAsync()).ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task reset_all_data_clears_then_applies_seed_data()
+ {
+ // Arrange: insert extra data that should be removed
+ using (var scope = _ctx.Host.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Items.Add(new Item { Id = Guid.NewGuid(), Name = "Noise Item" });
+ await db.SaveChangesAsync();
+ }
+
+ // Act: delete all + run IInitialData seeders
+ await Cleaner.ResetAllDataAsync();
+
+ // Assert: exactly the seed rows remain
+ using var checkScope = _ctx.Host.Services.CreateScope();
+ var checkDb = checkScope.ServiceProvider.GetRequiredService();
+ var items = await checkDb.Items.OrderBy(x => x.Name).ToListAsync();
+
+ items.Count.ShouldBe(SeedItemsForTests.Items.Length);
+ items.Select(x => x.Name).ShouldBe(
+ SeedItemsForTests.Items.Select(x => x.Name).OrderBy(n => n));
+ }
+
+ [Fact]
+ public async Task database_cleaner_is_registered_as_singleton()
+ {
+ var cleaner1 = _ctx.Host.Services.GetRequiredService>();
+ var cleaner2 = _ctx.Host.Services.GetRequiredService>();
+ cleaner1.ShouldBeSameAs(cleaner2);
+ }
+}
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/BatchedLoadEntityFrame.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/BatchedLoadEntityFrame.cs
new file mode 100644
index 000000000..c4618bad1
--- /dev/null
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/BatchedLoadEntityFrame.cs
@@ -0,0 +1,166 @@
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+using Microsoft.EntityFrameworkCore;
+using Weasel.EntityFrameworkCore.Batching;
+
+namespace Wolverine.EntityFrameworkCore.Codegen;
+
+///
+/// Code generation frame that loads an entity using Weasel's
+/// API instead of DbContext.FindAsync. This enables multiple entity loads within
+/// the same handler to be combined into a single database round trip by sharing a
+/// instance.
+///
+/// Usage: replace with this frame when you want batch
+/// query participation. A must appear earlier in
+/// the middleware chain to supply the BatchedQuery variable, and an
+/// must appear after all batch loads.
+///
+internal class BatchedLoadEntityFrame : SyncFrame
+{
+ private readonly Type _dbContextType;
+ private readonly Variable _sagaId;
+ private readonly string _pkPropertyName;
+ private Variable? _context;
+ private Variable? _batchQuery;
+
+ public BatchedLoadEntityFrame(Type dbContextType, Type sagaType, Variable sagaId, string pkPropertyName)
+ {
+ _dbContextType = dbContextType;
+ _sagaId = sagaId;
+ _pkPropertyName = pkPropertyName;
+
+ Saga = new Variable(sagaType, this);
+ // The task that will be awaited by ExecuteBatchQueryFrame
+ SagaTask = new Variable(typeof(Task<>).MakeGenericType(sagaType), sagaType.Name.ToLowerInvariant() + "Task", this);
+ }
+
+ public Variable Saga { get; }
+
+ ///
+ /// The pending returned by batch.QuerySingle().
+ /// Must be awaited by the consumer (e.g. )
+ /// after batch.ExecuteAsync().
+ ///
+ public Variable SagaTask { get; }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ _context = chain.FindVariable(_dbContextType);
+ yield return _context;
+
+ var batch = chain.TryFindVariable(typeof(BatchedQuery), VariableSource.All);
+ if (batch != null)
+ {
+ _batchQuery = batch;
+ yield return _batchQuery;
+ }
+ }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteLine("");
+ writer.WriteComment("Queue entity load into a BatchedQuery for a single-round-trip multi-entity fetch");
+
+ if (_batchQuery != null)
+ {
+ writer.WriteLine(
+ $"var {SagaTask.Usage} = {_batchQuery.Usage}.{nameof(BatchedQuery.QuerySingle)}(" +
+ $"{_context!.Usage}.{nameof(DbContext.Set)}<{Saga.VariableType.FullNameInCode()}>().Where(x => x.{_pkPropertyName} == {_sagaId.Usage}));");
+ }
+ else
+ {
+ // Fallback: create a local single-use batch (no sharing benefit, but still correct)
+ writer.WriteLine(
+ $"var {Saga.VariableType.Name.ToLowerInvariant()}Batch = {_context!.Usage}.{nameof(BatchQueryExtensions.CreateBatchQuery)}();");
+ writer.WriteLine(
+ $"var {SagaTask.Usage} = {Saga.VariableType.Name.ToLowerInvariant()}Batch.{nameof(BatchedQuery.QuerySingle)}(" +
+ $"{_context!.Usage}.{nameof(DbContext.Set)}<{Saga.VariableType.FullNameInCode()}>().Where(x => x.{_pkPropertyName} == {_sagaId.Usage}));");
+ }
+
+ Next?.GenerateCode(method, writer);
+ }
+}
+
+///
+/// Frame that creates a shared instance to be shared
+/// by multiple instances in the same handler chain.
+/// Insert this frame before any frames.
+///
+internal class CreateBatchQueryFrame : SyncFrame
+{
+ private Variable? _context;
+ private readonly Type _dbContextType;
+
+ public CreateBatchQueryFrame(Type dbContextType)
+ {
+ _dbContextType = dbContextType;
+ BatchQuery = new Variable(typeof(BatchedQuery), "batchQuery", this);
+ }
+
+ public Variable BatchQuery { get; }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ _context = chain.FindVariable(_dbContextType);
+ yield return _context;
+ }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteLine("");
+ writer.WriteComment("Create a shared BatchedQuery so multiple entity loads share one round trip");
+ writer.WriteLine(
+ $"var {BatchQuery.Usage} = {_context!.Usage}.{nameof(BatchQueryExtensions.CreateBatchQuery)}();");
+ Next?.GenerateCode(method, writer);
+ }
+}
+
+///
+/// Frame that executes a shared and then awaits all
+/// pending entity-load tasks queued by instances.
+/// Insert this frame after all frames.
+///
+internal class ExecuteBatchQueryFrame : AsyncFrame
+{
+ private readonly Variable _batchQuery;
+ private readonly IReadOnlyList _loadFrames;
+ private Variable? _cancellation;
+
+ public ExecuteBatchQueryFrame(Variable batchQuery, IReadOnlyList loadFrames)
+ {
+ _batchQuery = batchQuery;
+ _loadFrames = loadFrames;
+ }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ yield return _batchQuery;
+
+ _cancellation = chain.FindVariable(typeof(CancellationToken));
+ yield return _cancellation;
+
+ foreach (var frame in _loadFrames)
+ {
+ yield return frame.SagaTask;
+ }
+ }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteLine("");
+ writer.WriteComment("Execute all queued batch queries in a single database round trip");
+ writer.WriteLine(
+ $"await {_batchQuery.Usage}.{nameof(BatchedQuery.ExecuteAsync)}({_cancellation!.Usage}).ConfigureAwait(false);");
+
+ foreach (var frame in _loadFrames)
+ {
+ writer.WriteLine(
+ $"var {frame.Saga.Usage} = await {frame.SagaTask.Usage}.ConfigureAwait(false);");
+ }
+
+ Next?.GenerateCode(method, writer);
+ }
+}