From 0a0da9c29572d405863a5d4a521359f7e5aa6990 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 5 Jul 2026 12:29:57 -0500 Subject: [PATCH] feat(efcore): explicit transactional DbContext selection for multi-DbContext handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handler/HTTP/gRPC chain can legitimately depend on more than one DbContext — one written through, one read-only. Previously EFCorePersistenceFrameProvider threw the moment it saw a second DbContext-shaped dependency with no way to disambiguate. Now the transactional DbContext can be designated explicitly — no "magic", no automatic/registration-side inference: * [Transactional(typeof(MyDbContext))] — carried as a chain tag; the attribute lives in core Wolverine and only stores a System.Type, so it never references EF Core. * [Storage(typeof(MyDbContext))] — the provider-agnostic storage attribute, now taught to designate an EF Core DbContext via a new EFCoreDbContextStorageFrameProvider (IAncillaryStoreFrameProvider). Read directly off the handler so it is honored even under AutoApplyTransactions, whose policy runs before AncillaryStoreType is populated. * Either may name a concrete DbContext or a registered DbContext abstraction (WithDbContextAbstraction()). When a chain has more than one DbContext dependency and no designation, Wolverine fails fast at startup with a message telling the user to remove the automatic transactional middleware or designate the context with [Transactional]/[Storage]. A designation that names a non-dependency also fails loudly. Supersedes #3284: keeps that PR's [Transactional]-attribute tests and drops its registration-side auto-selection (WithTransactionalDbContext / Wolverine-enabled inference) per the "explicit only" design. Adds [Storage] + ambiguity-validation tests and docs. Co-authored-by: KhaledZaabat Co-Authored-By: Claude Opus 4.8 (1M context) --- .../efcore/transactional-middleware.md | 90 ++++++ .../storage_dbcontext_selection_tests.cs | 218 ++++++++++++++ ...transactional_dbcontext_selection_tests.cs | 267 ++++++++++++++++++ .../EFCoreDbContextStorageFrameProvider.cs | 37 +++ .../Codegen/EFCorePersistenceFrameProvider.cs | 97 ++++++- .../WolverineEntityCoreExtensions.cs | 10 + .../Attributes/TransactionalAttribute.cs | 38 +++ 7 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 src/Persistence/EfCoreTests/storage_dbcontext_selection_tests.cs create mode 100644 src/Persistence/EfCoreTests/transactional_dbcontext_selection_tests.cs create mode 100644 src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCoreDbContextStorageFrameProvider.cs 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