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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/guide/durability/efcore/transactional-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

244 changes: 244 additions & 0 deletions src/Persistence/EfCoreTests/transaction_middleware_mode_tests.cs
Original file line number Diff line number Diff line change
@@ -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<CleanDbContext>(x =>
x.UseSqlServer(Servers.SqlServerConnectionString));

opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode");
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager);
opts.Policies.AutoApplyTransactions();

opts.Discovery.DisableConventionalDiscovery()
.IncludeType<EagerModeHandler>();
}).StartAsync();

var chain = host.GetRuntime().Handlers.ChainFor<EagerModeMessage>();

chain.Middleware.OfType<EnrollDbContextInTransaction>().ShouldNotBeEmpty();

chain.Postprocessors.OfType<MethodCall>()
.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<CleanDbContext>(x =>
x.UseSqlServer(Servers.SqlServerConnectionString));

opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode");
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight);
opts.Policies.AutoApplyTransactions();

opts.Discovery.DisableConventionalDiscovery()
.IncludeType<LightweightModeHandler>();
}).StartAsync();

var chain = host.GetRuntime().Handlers.ChainFor<LightweightModeMessage>();

chain.Middleware.OfType<EnrollDbContextInTransaction>().ShouldBeEmpty();
chain.Middleware.OfType<StartDatabaseTransactionForDbContext>().ShouldBeEmpty();

chain.Postprocessors.OfType<MethodCall>()
.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<CleanDbContext>(x =>
x.UseSqlServer(Servers.SqlServerConnectionString));

opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode");
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Eager);
opts.Policies.AutoApplyTransactions();

opts.Discovery.DisableConventionalDiscovery()
.IncludeType<LightweightAttributeHandler>()
.IncludeType<EagerAutoApplyHandler>();
}).StartAsync();

// Verify the auto-applied handler uses the Eager default
var eagerChain = host.GetRuntime().Handlers.ChainFor<EagerAutoApplyMessage>();
eagerChain.Middleware.OfType<EnrollDbContextInTransaction>().ShouldNotBeEmpty();

// Force compilation of the [Transactional] chain by triggering HandlerFor
host.GetRuntime().Handlers.HandlerFor<LightweightAttributeMessage>();
var chain = host.GetRuntime().Handlers.ChainFor<LightweightAttributeMessage>();

// The attribute overrides to Lightweight, so no transaction frame
chain.IsTransactional.ShouldBeTrue();
chain.Middleware.OfType<EnrollDbContextInTransaction>().ShouldBeEmpty();
chain.Middleware.OfType<StartDatabaseTransactionForDbContext>().ShouldBeEmpty();

chain.Postprocessors.OfType<MethodCall>()
.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<CleanDbContext>(x =>
x.UseSqlServer(Servers.SqlServerConnectionString));

opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode");
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight);
opts.Policies.AutoApplyTransactions();

opts.Discovery.DisableConventionalDiscovery()
.IncludeType<EagerAttributeHandler>()
.IncludeType<LightweightAutoApplyHandler>();
}).StartAsync();

// Verify the auto-applied handler uses the Lightweight default
var lightChain = host.GetRuntime().Handlers.ChainFor<LightweightAutoApplyMessage>();
lightChain.Middleware.OfType<EnrollDbContextInTransaction>().ShouldBeEmpty();

// Force compilation of the [Transactional] chain by triggering HandlerFor
host.GetRuntime().Handlers.HandlerFor<EagerAttributeMessage>();
var chain = host.GetRuntime().Handlers.ChainFor<EagerAttributeMessage>();

// The attribute overrides to Eager, so transaction frame should be present
chain.IsTransactional.ShouldBeTrue();
chain.Middleware.OfType<EnrollDbContextInTransaction>().ShouldNotBeEmpty();

chain.Postprocessors.OfType<MethodCall>()
.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<CleanDbContext>(x =>
x.UseSqlServer(Servers.SqlServerConnectionString));

opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "txmode");
opts.UseEntityFrameworkCoreTransactions();
opts.Policies.AutoApplyTransactions();

opts.Discovery.DisableConventionalDiscovery()
.IncludeType<DefaultModeHandler>();
}).StartAsync();

var chain = host.GetRuntime().Handlers.ChainFor<DefaultModeMessage>();

// Default should be Eager
chain.Middleware.OfType<EnrollDbContextInTransaction>().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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type, Type?> _dbContextTypes = ImHashMap<Type, Type?>.Empty;

public TransactionMiddlewareMode DefaultMode { get; set; } = TransactionMiddlewareMode.Eager;

public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
var dbContextType = TryDetermineDbContextType(entityType, container);
Expand Down Expand Up @@ -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<IWolverineRuntime>();
if (runtime.Stores.HasAncillaryStoreFor(dbContextType))
{
var frame = typeof(ApplyAncillaryStoreFrame<>).CloseAndBuildAs<Frame>(dbContextType);
chain.Middleware.Insert(0, frame);
}

if (isMultiTenanted(container, dbContextType))
{
var createContext = typeof(CreateTenantedDbContext<>).CloseAndBuildAs<Frame>(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<Frame>(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 =
Expand Down Expand Up @@ -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<Frame>(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<Frame>(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)]);

Expand Down
Loading
Loading