From d7ee9d0520c8439426807f14df68a0de0608de12 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 10 Feb 2026 15:26:48 -0600 Subject: [PATCH] Add TransactionMiddlewareMode support for EF Core transactional middleware. Closes GH-2086 Introduces Eager vs Lightweight mode to control whether EF Core transactional middleware opens an explicit database transaction or only relies on DbContext.SaveChangesAsync(). Eager remains the default for backwards compatibility. Co-Authored-By: Claude Opus 4.6 --- .../efcore/transactional-middleware.md | 49 ++++ .../transaction_middleware_mode_tests.cs | 244 ++++++++++++++++++ .../Codegen/EFCorePersistenceFrameProvider.cs | 57 ++-- .../WolverineEntityCoreExtensions.cs | 28 +- .../Attributes/TransactionalAttribute.cs | 27 +- .../Persistence/TransactionMiddlewareMode.cs | 17 ++ 6 files changed, 400 insertions(+), 22 deletions(-) create mode 100644 src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs create mode 100644 src/Wolverine/Persistence/TransactionMiddlewareMode.cs diff --git a/docs/guide/durability/efcore/transactional-middleware.md b/docs/guide/durability/efcore/transactional-middleware.md index a38417d50..ab30c22ea 100644 --- a/docs/guide/durability/efcore/transactional-middleware.md +++ b/docs/guide/durability/efcore/transactional-middleware.md @@ -101,3 +101,52 @@ await host.StartAsync(); With this option, you will no longer need to decorate handler methods with the `[Transactional]` attribute. +## Transaction Middleware Mode + +By default, the EF Core transactional middleware uses `TransactionMiddlewareMode.Eager`, which eagerly opens an +explicit database transaction via `Database.BeginTransactionAsync()` before the handler executes. This is appropriate +when you need explicit transaction control, such as when using EF Core bulk operations. + +If you prefer to rely solely on `DbContext.SaveChangesAsync()` as your transactional boundary without opening an +explicit database transaction, you can use `TransactionMiddlewareMode.Lightweight`: + +```cs +builder.Host.UseWolverine(opts => +{ + opts.PersistMessagesWithSqlServer(connectionString, "wolverine"); + + // Use Lightweight mode — no explicit transaction, relies on SaveChangesAsync() + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight); + + opts.Policies.UseDurableLocalQueues(); +}); +``` + +::: tip +`TransactionMiddlewareMode.Lightweight` is **not** supported or necessary for Marten or RavenDb, which have their own +unit of work implementations. +::: + +### Per-Handler Override + +You can override the global `TransactionMiddlewareMode` for individual handlers using the `[Transactional]` attribute's +`Mode` property: + +```cs +// This handler will use an explicit transaction even if the global mode is Lightweight +[Transactional(Mode = TransactionMiddlewareMode.Eager)] +public static ItemCreated Handle(CreateItemCommand command, ItemsDbContext db) +{ + var item = new Item { Name = command.Name }; + db.Items.Add(item); + return new ItemCreated { Id = item.Id }; +} + +// This handler skips the explicit transaction even if the global mode is Eager +[Transactional(Mode = TransactionMiddlewareMode.Lightweight)] +public static void Handle(UpdateItemCommand command, ItemsDbContext db) +{ + // Just uses SaveChangesAsync() without an explicit transaction +} +``` + diff --git a/src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs b/src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs new file mode 100644 index 000000000..acc9c0fed --- /dev/null +++ b/src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs @@ -0,0 +1,244 @@ +using IntegrationTests; +using JasperFx.CodeGeneration.Frames; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.EntityFrameworkCore; +using Wolverine.EntityFrameworkCore.Codegen; +using Wolverine.Persistence; +using Wolverine.SqlServer; +using Wolverine.Tracking; + +namespace EfCoreTests; + +[Collection("sqlserver")] +public class transaction_middleware_mode_tests +{ + [Fact] + public async Task eager_mode_should_add_transaction_frame() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(); + }).StartAsync(); + + var chain = host.GetRuntime().Handlers.ChainFor(); + + chain.Middleware.OfType().ShouldNotBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task lightweight_mode_should_not_add_transaction_frame() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(); + }).StartAsync(); + + var chain = host.GetRuntime().Handlers.ChainFor(); + + chain.Middleware.OfType().ShouldBeEmpty(); + chain.Middleware.OfType().ShouldBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task transactional_attribute_lightweight_overrides_eager_default() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType() + .IncludeType(); + }).StartAsync(); + + // Verify the auto-applied handler uses the Eager default + var eagerChain = host.GetRuntime().Handlers.ChainFor(); + eagerChain.Middleware.OfType().ShouldNotBeEmpty(); + + // Force compilation of the [Transactional] chain by triggering HandlerFor + host.GetRuntime().Handlers.HandlerFor(); + var chain = host.GetRuntime().Handlers.ChainFor(); + + // The attribute overrides to Lightweight, so no transaction frame + chain.IsTransactional.ShouldBeTrue(); + chain.Middleware.OfType().ShouldBeEmpty(); + chain.Middleware.OfType().ShouldBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task transactional_attribute_eager_overrides_lightweight_default() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType() + .IncludeType(); + }).StartAsync(); + + // Verify the auto-applied handler uses the Lightweight default + var lightChain = host.GetRuntime().Handlers.ChainFor(); + lightChain.Middleware.OfType().ShouldBeEmpty(); + + // Force compilation of the [Transactional] chain by triggering HandlerFor + host.GetRuntime().Handlers.HandlerFor(); + var chain = host.GetRuntime().Handlers.ChainFor(); + + // The attribute overrides to Eager, so transaction frame should be present + chain.IsTransactional.ShouldBeTrue(); + chain.Middleware.OfType().ShouldNotBeEmpty(); + + chain.Postprocessors.OfType() + .Any(x => x.Method.Name == nameof(DbContext.SaveChangesAsync)) + .ShouldBeTrue(); + } + + [Fact] + public async Task default_mode_is_eager() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode"); + opts.UseEntityFrameworkCoreTransactions(); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(); + }).StartAsync(); + + var chain = host.GetRuntime().Handlers.ChainFor(); + + // Default should be Eager + chain.Middleware.OfType().ShouldNotBeEmpty(); + } +} + +#region test message types and handlers + +public record EagerModeMessage; + +public class EagerModeHandler +{ + public static void Handle(EagerModeMessage message, CleanDbContext db) + { + } +} + +public record LightweightModeMessage; + +public class LightweightModeHandler +{ + public static void Handle(LightweightModeMessage message, CleanDbContext db) + { + } +} + +public record LightweightAttributeMessage; + +public class LightweightAttributeHandler +{ + [Transactional(Mode = TransactionMiddlewareMode.Lightweight)] + public static void Handle(LightweightAttributeMessage message, CleanDbContext db) + { + } +} + +public record EagerAttributeMessage; + +public class EagerAttributeHandler +{ + [Transactional(Mode = TransactionMiddlewareMode.Eager)] + public static void Handle(EagerAttributeMessage message, CleanDbContext db) + { + } +} + +public record DefaultModeMessage; + +public class DefaultModeHandler +{ + public static void Handle(DefaultModeMessage message, CleanDbContext db) + { + } +} + +public record EagerAutoApplyMessage; + +public class EagerAutoApplyHandler +{ + public static void Handle(EagerAutoApplyMessage message, CleanDbContext db) + { + } +} + +public record LightweightAutoApplyMessage; + +public class LightweightAutoApplyHandler +{ + public static void Handle(LightweightAutoApplyMessage message, CleanDbContext db) + { + } +} + +#endregion diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs index edb7ef3ec..c786b8bed 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs @@ -21,8 +21,11 @@ namespace Wolverine.EntityFrameworkCore.Codegen; internal class EFCorePersistenceFrameProvider : IPersistenceFrameProvider { public const string UsingEfCoreTransaction = "uses_efcore_transaction"; + public const string TransactionModeKey = "TransactionMiddlewareMode"; private ImHashMap _dbContextTypes = ImHashMap.Empty; + public TransactionMiddlewareMode DefaultMode { get; set; } = TransactionMiddlewareMode.Eager; + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) { var dbContextType = TryDetermineDbContextType(entityType, container); @@ -121,23 +124,32 @@ public void ApplyTransactionSupport(IChain chain, IServiceContainer container) chain.Tags.Add(UsingEfCoreTransaction, true); var dbContextType = DetermineDbContextType(chain, container); + + // Resolve effective mode: per-chain override from [Transactional] attribute, or default + var mode = chain.Tags.TryGetValue(TransactionModeKey, out var modeObj) + ? (TransactionMiddlewareMode)modeObj + : DefaultMode; + var runtime = container.Services.GetRequiredService(); if (runtime.Stores.HasAncillaryStoreFor(dbContextType)) { var frame = typeof(ApplyAncillaryStoreFrame<>).CloseAndBuildAs(dbContextType); chain.Middleware.Insert(0, frame); } - - if (isMultiTenanted(container, dbContextType)) - { - var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbContextType); - chain.Middleware.Insert(0, createContext); - chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbContextType, chain.Idempotency)); - } - else + if (mode == TransactionMiddlewareMode.Eager) { - chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbContextType, chain.Idempotency)); + if (isMultiTenanted(container, dbContextType)) + { + var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbContextType); + + chain.Middleware.Insert(0, createContext); + chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbContextType, chain.Idempotency)); + } + else + { + chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbContextType, chain.Idempotency)); + } } var saveChangesAsync = @@ -169,17 +181,26 @@ public void ApplyTransactionSupport(IChain chain, IServiceContainer container, T chain.Tags.Add(UsingEfCoreTransaction, true); var dbType = DetermineDbContextType(entityType, container); - if (isMultiTenanted(container, dbType)) - { - var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbType); - chain.Middleware.Insert(0, createContext); - chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbType, chain.Idempotency)); - } - else + + // Resolve effective mode: per-chain override from [Transactional] attribute, or default + var mode = chain.Tags.TryGetValue(TransactionModeKey, out var modeObj) + ? (TransactionMiddlewareMode)modeObj + : DefaultMode; + + if (mode == TransactionMiddlewareMode.Eager) { - chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbType, chain.Idempotency)); + if (isMultiTenanted(container, dbType)) + { + var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs(dbType); + chain.Middleware.Insert(0, createContext); + chain.Middleware.Insert(0, new StartDatabaseTransactionForDbContext(dbType, chain.Idempotency)); + } + else + { + chain.Middleware.Insert(0, new EnrollDbContextInTransaction(dbType, chain.Idempotency)); + } } - + var saveChangesAsync = dbType.GetMethod(nameof(DbContext.SaveChangesAsync), [typeof(CancellationToken)]); diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs index 1328fb3d0..c1f3025b8 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs @@ -8,9 +8,12 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Wolverine.EntityFrameworkCore.Codegen; using Wolverine.EntityFrameworkCore.Internals; using Wolverine.EntityFrameworkCore.Internals.Migrations; +using Wolverine.Persistence; using Wolverine.Persistence.Durability; +using Wolverine.Persistence.Sagas; using Wolverine.RDBMS; using Wolverine.Runtime; @@ -172,10 +175,24 @@ private static IServiceCollection addDbContextWithWolverineIntegration(IServi /// /// Uses Entity Framework Core for Saga persistence and transactional - /// middleware + /// middleware using mode by default. /// /// public static void UseEntityFrameworkCoreTransactions(this WolverineOptions options) + { + options.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager); + } + + /// + /// Uses Entity Framework Core for Saga persistence and transactional + /// middleware with the specified . + /// opens an explicit database transaction immediately. + /// only relies on DbContext.SaveChangesAsync() + /// without opening an explicit transaction. + /// + /// + /// The transaction middleware mode to use + public static void UseEntityFrameworkCoreTransactions(this WolverineOptions options, TransactionMiddlewareMode mode) { try { @@ -191,8 +208,15 @@ public static void UseEntityFrameworkCoreTransactions(this WolverineOptions opti throw; } } - + options.Include(); + + var providers = options.CodeGeneration.PersistenceProviders(); + var efProvider = providers.OfType().FirstOrDefault(); + if (efProvider != null) + { + efProvider.DefaultMode = mode; + } } /// diff --git a/src/Wolverine/Attributes/TransactionalAttribute.cs b/src/Wolverine/Attributes/TransactionalAttribute.cs index c4eecf2d0..bcb939aac 100644 --- a/src/Wolverine/Attributes/TransactionalAttribute.cs +++ b/src/Wolverine/Attributes/TransactionalAttribute.cs @@ -14,22 +14,45 @@ namespace Wolverine.Attributes; /// public class TransactionalAttribute : ModifyChainAttribute { + private bool _modeExplicitlySet; + public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container) { if (Idempotency.HasValue) { chain.Idempotency = Idempotency.Value; } - + + if (_modeExplicitlySet) + { + chain.Tags["TransactionMiddlewareMode"] = Mode; + } + chain.ApplyImpliedMiddlewareFromHandlers(rules); var transactionFrameProvider = rules.As().GetPersistenceProviders(chain, container); transactionFrameProvider.ApplyTransactionSupport(chain, container); chain.IsTransactional = true; } - + public IdempotencyStyle? Idempotency { get; set; } + /// + /// Optionally override the for just this handler chain. + /// When set, this takes precedence over the global mode configured in + /// UseEntityFrameworkCoreTransactions(). + /// + public TransactionMiddlewareMode Mode + { + get => _mode; + set + { + _mode = value; + _modeExplicitlySet = true; + } + } + private TransactionMiddlewareMode _mode; + public TransactionalAttribute() { } diff --git a/src/Wolverine/Persistence/TransactionMiddlewareMode.cs b/src/Wolverine/Persistence/TransactionMiddlewareMode.cs new file mode 100644 index 000000000..53d02db96 --- /dev/null +++ b/src/Wolverine/Persistence/TransactionMiddlewareMode.cs @@ -0,0 +1,17 @@ +namespace Wolverine.Persistence; + +public enum TransactionMiddlewareMode +{ + /// + /// Start a native database transaction immediately when starting the message handling or HTTP request handling. + /// Use this for tools like EF Core that may require an explicit transaction for bulk operations + /// + /// Not supported or necessary for Marten or RavenDb + /// + Eager, + + /// + /// Only rely on the underlying persistence tool's version of SaveChangesAsync() for transactional boundaries + /// + Lightweight +} \ No newline at end of file