diff --git a/docs/guide/durability/efcore/transactional-middleware.md b/docs/guide/durability/efcore/transactional-middleware.md
index 6f77b4341..d9301b8c8 100644
--- a/docs/guide/durability/efcore/transactional-middleware.md
+++ b/docs/guide/durability/efcore/transactional-middleware.md
@@ -174,6 +174,12 @@ await host.StartAsync();
With this option, you will no longer need to decorate handler methods with the `[Transactional]` attribute.
+::: tip
+If an auto-transaction handler depends on **more than one** `DbContext` type, Wolverine cannot infer
+which one owns the transaction and will fail fast at startup. See
+[Selecting the Transactional DbContext](#selecting-the-transactional-dbcontext) for how to designate it.
+:::
+
## Transaction Middleware Mode
By default, the EF Core transactional middleware uses `TransactionMiddlewareMode.Eager`, which eagerly opens an
@@ -384,3 +390,87 @@ opts.UseEntityFrameworkCoreTransactions()
```
snippet source | anchor
+
+## Selecting the Transactional DbContext
+
+A handler chain can only have **one** transactional `DbContext` — the one Wolverine enrolls in the
+transaction and uses for the outbox. But a handler is often legitimately given more than one
+`DbContext`-shaped dependency: one it writes through, plus one it only *reads* from — a shared
+read-only lookup database, or another module's context in a modular monolith.
+
+This applies uniformly to every kind of Wolverine chain — message handlers, HTTP endpoints, and gRPC
+endpoints — since they all resolve their transactional storage the same way. The attributes below can
+be placed on the handler method or the containing class (for HTTP, on the endpoint method).
+
+When a chain depends on more than one `DbContext`-shaped service, Wolverine will **not** guess which
+one is transactional. There is no automatic selection and no "magic" — you designate it explicitly, or
+Wolverine fails fast at startup:
+
+```
+Cannot determine the DbContext type for , multiple DbContext types detected:
+AppDbContext, LookupDbContext. Wolverine will not guess which one owns the transaction. Either
+remove the automatic transactional middleware from this handler (e.g. with [NonTransactional] or
+by not calling AutoApplyTransactions), or explicitly designate the transactional DbContext with
+[Transactional(typeof(YourDbContext))] or [Storage(typeof(YourDbContext))] on the handler.
+```
+
+You have three ways to resolve it.
+
+### 1. `[Transactional(typeof(TDbContext))]`
+
+Name the write context on the handler. The other `DbContext` is simply an ordinary injected read
+dependency:
+
+```csharp
+public class GrantAccessHandler
+{
+ [Transactional(typeof(AppDbContext))]
+ public static void Handle(GrantAccess message, AppDbContext users, LookupDbContext lookup)
+ {
+ // users.SaveChanges() is enrolled in the transaction + outbox.
+ // lookup is just an ordinary injected read-only dependency.
+ }
+}
+```
+
+`[Transactional]` lives in the core `Wolverine` assembly and only stores a `System.Type`, so neither the
+attribute nor your handler assembly needs to reference anything EF-Core-specific beyond `typeof`. The
+type may also be a **DbContext abstraction** registered via `WithDbContextAbstraction()`, so a Clean Architecture handler can name the abstraction it depends on rather than the
+concrete EF Core type:
+
+```csharp
+opts.UseEntityFrameworkCoreTransactions()
+ .WithDbContextAbstraction();
+
+// ...
+
+[Transactional(typeof(IAppStore))]
+public static void Handle(GrantAccess message, IAppStore users, LookupDbContext lookup) { }
+```
+
+### 2. `[Storage(typeof(TDbContext))]`
+
+The provider-agnostic `[Storage]` attribute — the same one used to route a handler to a Marten or
+Polecat ancillary store — can equally designate the transactional `DbContext`:
+
+```csharp
+[Storage(typeof(AppDbContext))]
+public static void Handle(GrantAccess message, AppDbContext users, LookupDbContext lookup) { }
+```
+
+This behaves identically to `[Transactional(typeof(AppDbContext))]` for the purpose of choosing the
+transactional context. Use whichever attribute reads better in your codebase.
+
+### 3. Opt the handler out
+
+If a multi-`DbContext` handler should not be transactional at all, mark it `[NonTransactional]` (or
+don't apply `AutoApplyTransactions`). Wolverine then leaves both contexts as plain injected
+dependencies.
+
+### No guessing
+
+A designation that names a type the handler does **not** actually depend on — directly or via a
+registered abstraction — fails loudly at startup and names the offending type, rather than silently
+falling back to a default. Single-`DbContext` handlers are unaffected by any of this: there is exactly
+one candidate, so no attribute is needed.
diff --git a/src/Persistence/EfCoreTests/storage_dbcontext_selection_tests.cs b/src/Persistence/EfCoreTests/storage_dbcontext_selection_tests.cs
new file mode 100644
index 000000000..fb6e971e9
--- /dev/null
+++ b/src/Persistence/EfCoreTests/storage_dbcontext_selection_tests.cs
@@ -0,0 +1,218 @@
+using IntegrationTests;
+using JasperFx.CodeGeneration.Frames;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Npgsql;
+using Shouldly;
+using Weasel.Postgresql;
+using Wolverine;
+using Wolverine.Attributes;
+using Wolverine.EntityFrameworkCore;
+using Wolverine.EntityFrameworkCore.Codegen;
+using Wolverine.Persistence;
+using Wolverine.Postgresql;
+using Wolverine.Tracking;
+
+namespace EfCoreTests;
+
+// Companion to transactional_dbcontext_selection_tests.cs. That file proves the [Transactional(typeof(X))]
+// path; this one proves the alternative the architecture review asked for: reusing the provider-agnostic
+// [Storage(typeof(X))] attribute to designate which DbContext owns the transaction on a handler that
+// depends on more than one. It also pins the "no magic" contract: an ambiguous chain with no explicit
+// designation fails fast with a message that names both escape hatches.
+public class storage_dbcontext_selection_tests
+{
+ [Fact]
+ public async Task storage_attribute_disambiguates_and_only_enrolls_that_context()
+ {
+ await using (var conn = new NpgsqlConnection(Servers.PostgresConnectionString))
+ {
+ await conn.OpenAsync();
+ await conn.DropSchemaAsync("invoice_storage_schema");
+ await conn.CreateCommand(
+ """
+ CREATE SCHEMA "invoice_storage_schema";
+ CREATE TABLE invoice_storage_schema.invoices (
+ "Id" uuid PRIMARY KEY,
+ "Memo" text NOT NULL
+ );
+ """)
+ .ExecuteNonQueryAsync();
+ }
+
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ // A second, read-only DbContext dependency that must never be enrolled.
+ opts.Services.AddDbContext(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_invoice_storage");
+
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType();
+ }).StartAsync();
+
+ host.GetRuntime().Handlers.HandlerFor();
+ var chain = host.GetRuntime().Handlers.ChainFor();
+ chain.ShouldNotBeNull();
+
+ // [Storage(typeof(InvoiceDbContext))] set AncillaryStoreType; DetermineDbContextType honored it,
+ // so only InvoiceDbContext is enrolled — PricingDbContext stays an ordinary read dependency.
+ chain.Middleware.OfType().ToArray().Length.ShouldBe(1);
+
+ var saveChanges = chain.Postprocessors.OfType()
+ .Single(x => x.Method.Name == nameof(DbContext.SaveChangesAsync));
+ saveChanges.HandlerType.ShouldBe(typeof(InvoiceDbContext));
+
+ var invoiceId = Guid.NewGuid();
+ await host.InvokeMessageAndWaitAsync(new CreateInvoice(invoiceId, "opening balance"));
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Invoices.AnyAsync(i => i.Id == invoiceId)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task storage_attribute_that_is_not_a_dependency_throws_clear_error()
+ {
+ async Task startBadHost()
+ {
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+ opts.Services.AddDbContext(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_invoice_storage_err");
+
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType();
+ }).StartAsync();
+
+ host.GetRuntime().Handlers.HandlerFor();
+ }
+
+ var ex = await Should.ThrowAsync(startBadHost);
+ ex.Message.ShouldContain("is not one of this chain's dependencies");
+ }
+
+ [Fact]
+ public async Task ambiguous_multi_dbcontext_without_designation_throws_helpful_error()
+ {
+ async Task startBadHost()
+ {
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+ // A second Wolverine-enabled context so there is genuinely no single default.
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_ambiguous");
+
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType();
+ }).StartAsync();
+
+ host.GetRuntime().Handlers.HandlerFor();
+ }
+
+ // No [Transactional]/[Storage] designation and two candidates => no magic, fail fast, and the
+ // message must point the user at both escape hatches.
+ var ex = await Should.ThrowAsync(startBadHost);
+ ex.Message.ShouldContain("multiple DbContext types detected");
+ ex.Message.ShouldContain("[Transactional(typeof(YourDbContext))]");
+ ex.Message.ShouldContain("[Storage(typeof(YourDbContext))]");
+ }
+}
+
+public class Invoice
+{
+ public Guid Id { get; set; }
+ public string Memo { get; set; } = string.Empty;
+}
+
+public class InvoiceDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Invoices => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("invoice_storage_schema");
+ modelBuilder.MapWolverineEnvelopeStorage("invoice_storage_schema");
+ modelBuilder.Entity().ToTable("invoices");
+ }
+}
+
+// Read-only lookup context: no overlapping entities, exists only to add a second DbContext dependency.
+public class PricingDbContext(DbContextOptions options) : DbContext(options);
+
+// A second Wolverine-enabled write context used to force genuine ambiguity.
+public class AuditingDbContext(DbContextOptions options) : DbContext(options)
+{
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("invoice_storage_schema");
+ modelBuilder.MapWolverineEnvelopeStorage("invoice_storage_schema");
+ }
+}
+
+public record CreateInvoice(Guid Id, string Memo);
+
+[WolverineIgnore]
+public class CreateInvoiceHandler
+{
+ [Storage(typeof(InvoiceDbContext))]
+ public static void Handle(CreateInvoice cmd, InvoiceDbContext invoices, PricingDbContext pricing)
+ {
+ invoices.Invoices.Add(new Invoice { Id = cmd.Id, Memo = cmd.Memo });
+ }
+}
+
+public record CreateInvoiceWithBadStorage(Guid Id);
+
+[WolverineIgnore]
+public class CreateInvoiceWithBadStorageHandler
+{
+ // Names PricingDbContext, which this handler does not depend on.
+ [Storage(typeof(PricingDbContext))]
+ public static void Handle(CreateInvoiceWithBadStorage cmd, InvoiceDbContext invoices)
+ {
+ invoices.Invoices.Add(new Invoice { Id = cmd.Id, Memo = "n/a" });
+ }
+}
+
+public record AmbiguousCommand(Guid Id);
+
+[WolverineIgnore]
+public class AmbiguousHandler
+{
+ public static void Handle(AmbiguousCommand cmd, InvoiceDbContext invoices, AuditingDbContext audit)
+ {
+ invoices.Invoices.Add(new Invoice { Id = cmd.Id, Memo = "ambiguous" });
+ }
+}
diff --git a/src/Persistence/EfCoreTests/transactional_dbcontext_selection_tests.cs b/src/Persistence/EfCoreTests/transactional_dbcontext_selection_tests.cs
new file mode 100644
index 000000000..ff998a30c
--- /dev/null
+++ b/src/Persistence/EfCoreTests/transactional_dbcontext_selection_tests.cs
@@ -0,0 +1,267 @@
+using IntegrationTests;
+using JasperFx;
+using JasperFx.CodeGeneration.Frames;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Npgsql;
+using Shouldly;
+using Weasel.Postgresql;
+using Wolverine;
+using Wolverine.Attributes;
+using Wolverine.EntityFrameworkCore;
+using Wolverine.EntityFrameworkCore.Codegen;
+using Wolverine.Postgresql;
+using Wolverine.Tracking;
+
+namespace EfCoreTests;
+
+// Reproduces the scenario from the "per-handler transactional DbContext" architecture review:
+// a single handler depends on two different DbContext types - one that owns the write/aggregate
+// and must be enrolled in the transaction + outbox, and one that is only used for read-only
+// lookups and must never be wrapped in a transaction. Today `EFCorePersistenceFrameProvider.
+// DetermineDbContextType(IChain, ...)` throws `InvalidOperationException` the moment it sees more
+// than one DbContext-shaped dependency (EFCorePersistenceFrameProvider.cs:497-501) - it has no way
+// to know which one is "the" transactional context for this chain. `[Transactional(typeof(TDbContext))]`
+// disambiguates that per-chain.
+public class transactional_dbcontext_selection_tests
+{
+ [Fact]
+ public async Task explicit_dbcontext_type_disambiguates_and_only_enrolls_that_context()
+ {
+ await using (var conn = new NpgsqlConnection(Servers.PostgresConnectionString))
+ {
+ await conn.OpenAsync();
+ await conn.DropSchemaAsync("widget_selection_schema");
+ await conn.CreateCommand(
+ """
+ CREATE SCHEMA "widget_selection_schema";
+ CREATE TABLE widget_selection_schema.widgets (
+ "Id" uuid PRIMARY KEY,
+ "Name" text NOT NULL
+ );
+ """)
+ .ExecuteNonQueryAsync();
+ }
+
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ // A second, unrelated DbContext used only for read-only lookups. It must never
+ // participate in the transaction/outbox even though it's also DbContext-shaped.
+ opts.Services.AddDbContext(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_widget_sel");
+
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType();
+ }).StartAsync();
+
+ // [Transactional]-attributed chains apply their attribute lazily on first compile, so force
+ // it the same way transaction_middleware_mode_tests.cs does before inspecting Middleware.
+ host.GetRuntime().Handlers.HandlerFor();
+ var chain = host.GetRuntime().Handlers.ChainFor();
+ chain.ShouldNotBeNull();
+
+ // Only the tagged DbContext should be enrolled in the transaction.
+ var enrollFrames = chain.Middleware.OfType().ToArray();
+ enrollFrames.Length.ShouldBe(1);
+
+ var saveChanges = chain.Postprocessors.OfType()
+ .Single(x => x.Method.Name == nameof(DbContext.SaveChangesAsync));
+ saveChanges.HandlerType.ShouldBe(typeof(WidgetDbContext));
+
+ var widgetId = Guid.NewGuid();
+ await host.InvokeMessageAndWaitAsync(new CreateWidget(widgetId, "gizmo"));
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Widgets.AnyAsync(w => w.Id == widgetId)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task explicit_dbcontext_type_that_is_not_a_dependency_throws_clear_error()
+ {
+ async Task startBadHost()
+ {
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ opts.Services.AddDbContext(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_widget_sel_err");
+
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType();
+ }).StartAsync();
+
+ // [Transactional] is applied lazily on first compile - force it.
+ host.GetRuntime().Handlers.HandlerFor();
+ }
+
+ // The attribute references LookupDbContext, but this handler doesn't depend on it - only
+ // on WidgetDbContext. That mismatch must fail loudly and name the offending type, rather
+ // than silently falling back to some default DbContext.
+ var ex = await Should.ThrowAsync(startBadHost);
+ ex.Message.ShouldContain("is not one of this chain's dependencies");
+ }
+
+ // Clean Architecture handlers can't reference the concrete DbContext type at all (it lives in
+ // Infrastructure). They depend on a repository abstraction registered via
+ // `WithDbContextAbstraction()` instead, so [Transactional] must accept
+ // that abstraction type and resolve it to the concrete DbContext internally.
+ [Fact]
+ public async Task explicit_dbcontext_type_can_be_a_registered_abstraction()
+ {
+ await using (var conn = new NpgsqlConnection(Servers.PostgresConnectionString))
+ {
+ await conn.OpenAsync();
+ await conn.DropSchemaAsync("gadget_selection_schema");
+ await conn.CreateCommand(
+ """
+ CREATE SCHEMA "gadget_selection_schema";
+ CREATE TABLE gadget_selection_schema.gadgets (
+ "Id" uuid PRIMARY KEY,
+ "Name" text NOT NULL
+ );
+ """)
+ .ExecuteNonQueryAsync();
+ }
+
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+ opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+ opts.Services.AddDbContext(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString));
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_gadget_sel");
+
+ opts.UseEntityFrameworkCoreTransactions()
+ .WithDbContextAbstraction();
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType();
+ }).StartAsync();
+
+ host.GetRuntime().Handlers.HandlerFor();
+ var chain = host.GetRuntime().Handlers.ChainFor();
+ chain.ShouldNotBeNull();
+
+ chain.Middleware.OfType().ToArray().Length.ShouldBe(1);
+
+ var saveChanges = chain.Postprocessors.OfType()
+ .Single(x => x.Method.Name == nameof(DbContext.SaveChangesAsync));
+ saveChanges.HandlerType.ShouldBe(typeof(GadgetDbContext));
+
+ var gadgetId = Guid.NewGuid();
+ await host.InvokeMessageAndWaitAsync(new CreateGadget(gadgetId, "sprocket"));
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Gadgets.AnyAsync(g => g.Id == gadgetId)).ShouldBeTrue();
+ }
+}
+
+public class Widget
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+}
+
+public class WidgetDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Widgets => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("widget_selection_schema");
+ modelBuilder.MapWolverineEnvelopeStorage("widget_selection_schema");
+ modelBuilder.Entity().ToTable("widgets");
+ }
+}
+
+// Deliberately has no entities mapped that overlap with WidgetDbContext - it exists purely to
+// prove a second, unrelated DbContext dependency doesn't have to participate in the transaction.
+public class LookupDbContext(DbContextOptions options) : DbContext(options);
+
+public record CreateWidget(Guid Id, string Name);
+
+public class CreateWidgetHandler
+{
+ [Transactional(typeof(WidgetDbContext))]
+ public static void Handle(CreateWidget cmd, WidgetDbContext widgets, LookupDbContext lookup)
+ {
+ widgets.Widgets.Add(new Widget { Id = cmd.Id, Name = cmd.Name });
+ }
+}
+
+public record CreateWidgetWithBadTag(Guid Id);
+
+public class CreateWidgetWithBadTagHandler
+{
+ [Transactional(typeof(LookupDbContext))]
+ public static void Handle(CreateWidgetWithBadTag cmd, WidgetDbContext widgets)
+ {
+ widgets.Widgets.Add(new Widget { Id = cmd.Id, Name = "n/a" });
+ }
+}
+
+public class GadgetEntity
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+}
+
+// The Application-layer-facing abstraction - the only thing the handler is allowed to depend on.
+public interface IGadgetRepository
+{
+ DbSet Gadgets { get; }
+}
+
+public class GadgetDbContext(DbContextOptions options) : DbContext(options), IGadgetRepository
+{
+ public DbSet Gadgets => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("gadget_selection_schema");
+ modelBuilder.MapWolverineEnvelopeStorage("gadget_selection_schema");
+ modelBuilder.Entity().ToTable("gadgets");
+ }
+}
+
+public record CreateGadget(Guid Id, string Name);
+
+public class CreateGadgetHandler
+{
+ [Transactional(typeof(IGadgetRepository))]
+ public static void Handle(CreateGadget cmd, IGadgetRepository gadgets, LookupDbContext lookup)
+ {
+ gadgets.Gadgets.Add(new GadgetEntity { Id = cmd.Id, Name = cmd.Name });
+ }
+}
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCoreDbContextStorageFrameProvider.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCoreDbContextStorageFrameProvider.cs
new file mode 100644
index 000000000..e58b997fd
--- /dev/null
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCoreDbContextStorageFrameProvider.cs
@@ -0,0 +1,37 @@
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.Core.Reflection;
+using Microsoft.EntityFrameworkCore;
+using Wolverine.Persistence;
+
+namespace Wolverine.EntityFrameworkCore.Codegen;
+
+///
+/// Teaches the provider-agnostic ([Storage(typeof(MyDbContext))])
+/// how to designate the transactional DbContext for an EF Core handler. This is the EF Core sibling of
+/// — its only job is to claim
+/// DbContext-shaped store types so [Storage] resolves instead of throwing "no integration owns
+/// this store".
+///
+/// The actual selection is made by EFCorePersistenceFrameProvider.DetermineDbContextType(IChain, ...),
+/// which reads chain.AncillaryStoreType (set by [Storage]) and enrolls that DbContext
+/// through the ordinary EF Core transaction middleware. There is therefore no separate ancillary
+/// outbox session to build — unlike Marten/Polecat, a designated DbContext is resolved from the
+/// container the same way as any other — so the inserted frame is intentionally a no-op marker.
+///
+///
+internal class EFCoreDbContextStorageFrameProvider : IAncillaryStoreFrameProvider
+{
+ private readonly EFCorePersistenceFrameProvider _provider;
+
+ public EFCoreDbContextStorageFrameProvider(EFCorePersistenceFrameProvider provider)
+ {
+ _provider = provider;
+ }
+
+ public bool Matches(Type storeType)
+ => storeType.CanBeCastTo() || _provider.IsRegisteredAbstraction(storeType);
+
+ public Frame BuildOutboxFactoryFrame(Type storeType)
+ => new CommentFrame(
+ $"[Storage(typeof({storeType.FullNameInCode()}))] designates the transactional DbContext; enrollment is handled by the EF Core transaction middleware.");
+}
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs
index 0b7edd9e3..cd7870c32 100644
--- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs
@@ -52,6 +52,14 @@ public void RegisterAbstraction(Type abstractionType, Type dbContextType)
_abstractions = _abstractions.AddOrUpdate(abstractionType, dbContextType);
}
+ ///
+ /// True when is a DbContext abstraction registered via
+ /// WithDbContextAbstraction<TAbstraction, TDbContext>(). Lets the EF Core
+ /// accept a [Storage(typeof(abstraction))]
+ /// designation without depending on this provider's private abstraction map.
+ ///
+ internal bool IsRegisteredAbstraction(Type type) => _abstractions.Contains(type);
+
public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
var dbContextType = TryDetermineDbContextType(entityType, container);
@@ -478,6 +486,17 @@ IEnumerable FindDbContextTypes()
var contextTypes = FindDbContextTypes().ToArray();
+ // Explicit, no-magic disambiguation. When a chain depends on more than one DbContext-shaped
+ // service, the developer designates the transactional one with either [Transactional(typeof(X))]
+ // (carried as a chain tag) or [Storage(typeof(X))] (carried as chain.AncillaryStoreType). X may
+ // be a concrete DbContext or a registered DbContext abstraction. A designation that names a type
+ // the chain does not actually depend on fails loudly rather than silently picking a default.
+ var designated = resolveDesignatedDbContext(chain, contextTypes);
+ if (designated != null)
+ {
+ return designated;
+ }
+
if (contextTypes.Length == 0)
{
var sagaType = chain.HandlerCalls().SelectMany(x => x.Creates)
@@ -489,7 +508,7 @@ IEnumerable FindDbContextTypes()
{
return DetermineDbContextType(sagaType, container);
}
-
+
throw new InvalidOperationException(
$"Cannot determine the {nameof(DbContext)} type for {chain.Description}");
}
@@ -497,12 +516,86 @@ IEnumerable FindDbContextTypes()
if (contextTypes.Length > 1)
{
throw new InvalidOperationException(
- $"Cannot determine the {nameof(DbContext)} type for {chain.Description}, multiple {nameof(DbContext)} types detected: {contextTypes.Select(x => x.Name).Join(", ")}");
+ $"Cannot determine the {nameof(DbContext)} type for {chain.Description}, multiple {nameof(DbContext)} types detected: {contextTypes.Select(x => x.Name).Join(", ")}. " +
+ $"Wolverine will not guess which one owns the transaction. Either remove the automatic transactional middleware from this handler (e.g. with [NonTransactional] or by not calling AutoApplyTransactions), " +
+ $"or explicitly designate the transactional {nameof(DbContext)} with [Transactional(typeof(YourDbContext))] or [Storage(typeof(YourDbContext))] on the handler.");
}
return contextTypes.Single();
}
+ ///
+ /// Resolve an explicit transactional-DbContext designation for this chain, or null when there is
+ /// none. Two equivalent, EF-Core-agnostic markers are honored:
+ ///
+ /// - [Transactional(typeof(X))] — carried as the chain tag.
+ /// - [Storage(typeof(X))] — carried as .
+ ///
+ /// X may be a concrete DbContext or a registered DbContext abstraction. A designation that names a
+ /// type the chain does not depend on throws; a that names a
+ /// non-DbContext ancillary store (a genuine Marten/Polecat secondary store) is ignored here.
+ ///
+ private Type? resolveDesignatedDbContext(IChain chain, Type[] contextTypes)
+ {
+ if (chain.Tags.TryGetValue(TransactionalAttribute.TransactionalDbContextTypeKey, out var tagged)
+ && tagged is Type taggedType)
+ {
+ return validateDesignation(taggedType, chain, contextTypes, "[Transactional]");
+ }
+
+ // [Storage(typeof(X))] designation. We read the attribute directly off the handler rather than
+ // relying on chain.AncillaryStoreType, because DetermineDbContextType runs from
+ // AutoApplyTransactions before the StorageAttribute's eager policy / Modify has populated
+ // AncillaryStoreType. Only honored when X resolves to one of this chain's DbContext candidates;
+ // a [Storage] that names a genuine Marten/Polecat ancillary store on some other chain is ignored
+ // here and left to that integration.
+ var storageType = findStorageAttributeType(chain) ?? chain.AncillaryStoreType;
+ if (storageType != null)
+ {
+ var resolved = _abstractions.TryFind(storageType, out var concrete) ? concrete : storageType;
+
+ // If it names a DbContext-shaped type (or registered abstraction) but isn't actually a
+ // dependency, fail loudly the same way the [Transactional] tag does — a typo shouldn't
+ // silently fall through to the ambiguity error.
+ if (resolved.CanBeCastTo() || _abstractions.Contains(storageType))
+ {
+ return validateDesignation(storageType, chain, contextTypes, "[Storage]");
+ }
+ }
+
+ return null;
+ }
+
+ private static Type? findStorageAttributeType(IChain chain)
+ {
+ foreach (var call in chain.HandlerCalls())
+ {
+ var att = call.Method.GetCustomAttribute(inherit: true)
+ ?? call.HandlerType.GetCustomAttribute(inherit: true);
+
+ if (att != null)
+ {
+ return att.StoreType;
+ }
+ }
+
+ return null;
+ }
+
+ private Type validateDesignation(Type designated, IChain chain, Type[] contextTypes, string source)
+ {
+ var resolved = _abstractions.TryFind(designated, out var concrete) ? concrete : designated;
+
+ if (!contextTypes.Contains(resolved))
+ {
+ throw new InvalidOperationException(
+ $"The {source} DbContextType {designated.FullNameInCode()} on {chain.Description} is not one of this chain's dependencies (directly or via a registered DbContext abstraction). " +
+ $"Detected {nameof(DbContext)} types: {(contextTypes.Length == 0 ? "none" : contextTypes.Select(x => x.Name).Join(", "))}");
+ }
+
+ return resolved;
+ }
+
public class CastDbContextFrame : SyncFrame
{
private readonly Type _abstractionType;
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs
index a8c926f3b..30bdc9d45 100644
--- a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs
@@ -325,6 +325,16 @@ public static EFCoreTransactionConfiguration UseEntityFrameworkCoreTransactions(
throw new Exception($"Unable to find any ${typeof(EFCorePersistenceFrameProvider)}");
}
efProvider.DefaultMode = mode;
+
+ // Let [Storage(typeof(MyDbContext))] designate the transactional DbContext on a multi-DbContext
+ // handler, the same way [Transactional(typeof(MyDbContext))] does. Registered once; harmless if
+ // no handler uses [Storage]. See EFCoreDbContextStorageFrameProvider.
+ if (!options.Services.Any(x => x.ServiceType == typeof(IAncillaryStoreFrameProvider)
+ && x.ImplementationInstance is EFCoreDbContextStorageFrameProvider))
+ {
+ options.Services.AddSingleton(new EFCoreDbContextStorageFrameProvider(efProvider));
+ }
+
return new EFCoreTransactionConfiguration(options, efProvider);
}
diff --git a/src/Wolverine/Attributes/TransactionalAttribute.cs b/src/Wolverine/Attributes/TransactionalAttribute.cs
index 0f9f153f4..06dcd749f 100644
--- a/src/Wolverine/Attributes/TransactionalAttribute.cs
+++ b/src/Wolverine/Attributes/TransactionalAttribute.cs
@@ -14,6 +14,13 @@ namespace Wolverine.Attributes;
///
public class TransactionalAttribute : ModifyChainAttribute
{
+ ///
+ /// Chain tag key carrying the persistence storage type (e.g. a specific EF Core DbContext)
+ /// that a designates as the transactional one. Read by
+ /// persistence providers to disambiguate multi-storage handler chains.
+ ///
+ public const string TransactionalDbContextTypeKey = "TransactionalDbContextType";
+
private bool _modeExplicitlySet;
public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container)
@@ -28,6 +35,15 @@ public override void Modify(IChain chain, GenerationRules rules, IServiceContain
chain.Tags["TransactionMiddlewareMode"] = Mode;
}
+ if (DbContextType != null)
+ {
+ // Consumed by the persistence provider (e.g. EF Core's
+ // EFCorePersistenceFrameProvider) to disambiguate which storage type owns the
+ // transaction when a chain depends on more than one. Kept as a chain tag so this
+ // attribute — and the core Wolverine assembly — never reference EF Core.
+ chain.Tags[TransactionalDbContextTypeKey] = DbContextType;
+ }
+
chain.ApplyImpliedMiddlewareFromHandlers(rules);
var transactionFrameProvider = rules.As().GetPersistenceProviders(chain, container);
transactionFrameProvider.ApplyTransactionSupport(chain, container);
@@ -60,6 +76,18 @@ public TransactionMiddlewareMode Mode
///
public bool IsModeExplicitlySet => _modeExplicitlySet;
+ ///
+ /// Optionally designate which persistence storage type owns the transaction for this handler
+ /// when its chain depends on more than one candidate (for example two EF Core DbContext types —
+ /// one written through, one read-only). This may be the concrete type or an abstraction the
+ /// provider has been taught to resolve (e.g. a DbContext abstraction registered via
+ /// WithDbContextAbstraction<TAbstraction, TDbContext>()). It is only consulted when a
+ /// chain is otherwise ambiguous; single-candidate handlers ignore it. The value is carried as a
+ /// plain , so neither this attribute nor the core Wolverine assembly reference
+ /// any specific persistence technology.
+ ///
+ public Type? DbContextType { get; set; }
+
public TransactionalAttribute()
{
}
@@ -68,4 +96,14 @@ public TransactionalAttribute(IdempotencyStyle idempotency)
{
Idempotency = idempotency;
}
+
+ ///
+ /// Designate which persistence storage type (e.g. a specific EF Core DbContext, or a registered
+ /// DbContext abstraction) owns the transaction for this handler, for chains that depend on more
+ /// than one candidate. See .
+ ///
+ public TransactionalAttribute(Type dbContextType)
+ {
+ DbContextType = dbContextType;
+ }
}
\ No newline at end of file