Transactional Middleware
Support for using Wolverine transactional middleware requires an explicit registration on WolverineOptions shown below (it's an extension method):
builder.Host.UseWolverine(opts =>
{
// Setting up Sql Server-backed message storage
// This requires a reference to Wolverine.SqlServer
opts.PersistMessagesWithSqlServer(connectionString!, "wolverine");
// Set up Entity Framework Core as the support
// for Wolverine's transactional middleware
opts.UseEntityFrameworkCoreTransactions();
// Enrolling all local queues into the
// durable inbox/outbox processing
opts.Policies.UseDurableLocalQueues();
});TIP
When using the opt in Handlers.AutoApplyTransactions() option, Wolverine can detect that your handler method uses a DbContext if it's a method argument, a dependency of any service injected as a method argument, or a dependency of any service injected as a constructor argument of the handler class.
That will enroll EF Core as both a strategy for stateful saga support and for transactional middleware. With this option added, Wolverine will wrap transactional middleware around any message handler that has a dependency on any type of DbContext like this one:
[Transactional]
public static ItemCreated Handle(
// This would be the message
CreateItemCommand command,
// Any other arguments are assumed
// to be service dependencies
ItemsDbContext db)
{
// Create a new Item entity
var item = new Item
{
Name = command.Name
};
// Add the item to the current
// DbContext unit of work
db.Items.Add(item);
// This event being returned
// by the handler will be automatically sent
// out as a "cascading" message
return new ItemCreated
{
Id = item.Id
};
}When using the transactional middleware around a message handler, the DbContext is used to persist the outgoing messages as part of Wolverine's outbox support.
Opting Out with [NonTransactional]
When using AutoApplyTransactions(), you can opt specific handlers or HTTP endpoints out of transactional middleware by decorating them with the [NonTransactional] attribute:
using Wolverine.Attributes;
public static class MyHandler
{
// This handler will NOT have transactional middleware applied
// even when AutoApplyTransactions() is enabled
[NonTransactional]
public static void Handle(MyCommand command, MyDbContext db)
{
// You're managing the DbContext yourself here
}
}The [NonTransactional] attribute can be placed on individual handler methods or on the handler class to opt out all methods in that class.
Eager vs Lightweight Transactions 5.15
By default, the EF Core middleware will run in Eager mode meaning that Wolverine will call DbContext.Database.BeginTransactionAsync() before your message handler or HTTP endpoint handler. We do this so that set-based operations -- ExecuteUpdateAsync, ExecuteDeleteAsync, raw SQL, a stored procedure -- have a transaction to run inside of. If all you need to do is persist entities such that DbContext.SaveChangesAsync() gives you all the transactional integrity you need, you can opt into lightweight transaction code generation instead:
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");
// ONLY use SaveChangesAsync() for transaction boundaries
// Treat the DbContext as a unit of work, assume there are no
// bulk operations
opts.UseEntityFrameworkCoreTransactions(TransactionMiddlewareMode.Lightweight);
opts.Policies.AutoApplyTransactions();
opts.Discovery.DisableConventionalDiscovery()
.IncludeType<LightweightModeHandler>();
}).StartAsync(cancellationToken: TestContext.Current.CancellationToken);You can also selectively configure the transaction middleware mode on singular message handlers or HTTP endpoints with the [Transactional] attribute like this:
public class LightweightAttributeHandler
{
[Transactional(Mode = TransactionMiddlewareMode.Lightweight)]
public static void Handle(LightweightAttributeMessage message, CleanDbContext db)
{
}
}Lightweight Mode and the Outbox 6.41
Lightweight mode skips the explicit BeginTransactionAsync(), but it does not skip the outbox. Wolverine enrolls your DbContext in the outbox either way, so the rows for any message you cascade or publish out of a handler are written by the very same SaveChangesAsync() call that saves your entities. Either both land or neither does.
WARNING
Before 6.41 that was only true in Eager mode and for HTTP endpoints. A message handler in Lightweight mode buffered its cascades in memory and then wrote them in a second database call, after SaveChangesAsync() had already committed. Nothing was ever sent early, so you would not have seen a duplicate or an out-of-order message -- but a failure in between left you with the entity row and no message to go with it, which is exactly what the outbox exists to prevent.
Two more things follow from the enrollment, and both are worth knowing before you move a handler to Lightweight:
- Your domain event scrapers run. See Publishing Domain Events --
PublishDomainEventsFromEntityFrameworkCore()was inert inLightweightmode before 6.41, in all three of its forms. AutoApplyTransactions(IdempotencyStyle.Eager)really does check the inbox now. The insert behind that check works perfectly well inside the implicit transactionSaveChangesAsync()opens, so there was never a reason forLightweightto opt out of it -- but before 6.41 it silently did, and you got no deduplication at all.
TIP
The one thing Lightweight genuinely cannot give you is a rollback. There is no explicit transaction, so a handler that calls SaveChangesAsync() itself and then throws has already committed. What you get from Lightweight is atomicity between the entity write and the outgoing messages, not a handler-wide undo. If you want the undo, use Eager.
EnableRetryOnFailure and Eager mode 6.41
EF Core's retrying execution strategy will not let you open your own transaction, and Eager mode opens one on your behalf before your handler runs. Put the two together and every single message through that handler fails, with an EF Core exception that never mentions Wolverine.
Wolverine now catches that combination while it is building the handler, so you find out at startup instead of in production:
<handler> uses the EF Core transactional middleware in TransactionMiddlewareMode.Eager mode, but
MyDbContext is registered with EnableRetryOnFailure(). EF Core's retrying execution strategy does not
allow the explicit BeginTransactionAsync() that Eager mode emits, so this chain would throw on every
message. Either switch to TransactionMiddlewareMode.Lightweight -- UseEntityFrameworkCoreTransactions(
TransactionMiddlewareMode.Lightweight) for the whole application, or [Transactional(Mode =
TransactionMiddlewareMode.Lightweight)] on just this handler -- or drop EnableRetryOnFailure() from the
DbContext registration.Lightweight is usually the answer. It never opens a transaction of its own, which is the whole reason it is compatible with a retrying execution strategy.
Set-Based Operations and EfCoreOps 6.41
Sooner or later a handler has to archive ten thousand rows, call a stored procedure, or delete everything older than a cutoff date. ExecuteUpdateAsync, ExecuteDeleteAsync and Database.ExecuteSqlAsync each do that in a single statement, and not one of them goes anywhere near SaveChangesAsync. That is the whole difficulty. In Lightweight mode there is no transaction around the statement, so it commits by itself the moment it runs. If the rest of your handler then fails, the bulk write stays behind while the outbox rows that were supposed to travel with it roll back, and you are left with half the work done and no message to finish the other half.
Up until now the answer was "remember to ask for Eager on this handler". You can say it in the return type instead:
public static EfCoreOp Handle(ArchiveOldOrders command)
{
return EfCoreOps.ExecuteUpdate<Order>(x => x.PlacedAt < command.Before,
setters => setters.SetProperty(x => x.IsArchived, true));
}Wolverine sees the EfCoreOp coming back and puts that chain in Eager mode -- whatever the application default is, and whatever a [Transactional] attribute or a handler policy asked for. The statement runs inside the same transaction as the outbox rows, so either both land or neither does.
Here is the family:
| Operation | What you get |
|---|---|
EfCoreOps.ExecuteUpdate<T>(predicate, setters) | One UPDATE covering every matching row |
EfCoreOps.ExecuteDelete<T>(predicate) | One DELETE covering every matching row |
EfCoreOps.ExecuteSql(FormattableString) | Raw SQL, with the interpolated values as parameters |
EfCoreOps.ExecuteSqlRaw(sql, parameters) | Raw SQL with positional parameters |
EfCoreOps.InsertMany<T>(entities) | One AddRange |
InsertMany is the odd one out, on purpose. It writes through the same SaveChangesAsync as everything else the handler did, so it has no business overruling your Lightweight default, and it does not.
The setters read exactly like EF Core's own, including the computed form SetProperty(x => x.Tally, x => x.Tally + 1), but the type you are chaining calls on is Wolverine's EfCoreSetters<T> rather than EF's. That is what lets the same handler code compile against both EF Core 9 and EF Core 10, which spell their setter builders differently, and it is what lets Wolverine read back what an update is about to touch -- see the multi-tenancy rules below.
TIP
Notice that the handler above never takes a DbContext at all. When your application registers exactly one, Wolverine knows which one the operation belongs to. When it registers several, say which with [Storage(typeof(YourDbContext))] or [Transactional(typeof(YourDbContext))] on the handler, exactly as you would for any other multi-DbContext chain.
When you keep calling EF Core yourself
Returning an operation is not always what you want. A handler that already has the DbContext in hand for other reasons may as well call ExecuteUpdateAsync on it directly. Mark it, and you get the same guarantee:
[RequiresEagerTransaction]
public static async Task Handle(ArchiveOldOrders command, OrdersDbContext db, CancellationToken token)
{
await db.Orders.Where(x => x.PlacedAt < command.Before)
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.IsArchived, true), token);
}WARNING
An operation that forces Eager runs headlong into EnableRetryOnFailure(), for the reason described in EnableRetryOnFailure and Eager mode above. Wolverine catches the pairing here too, while it is building the handler, so a chain that returns an operation against a retrying DbContext fails at startup rather than on every message.
Set-based writes and conjoined multi-tenancy
A set-based statement is the one write path that conjoined multi-tenancy's tenant interceptor cannot see. That interceptor hooks SavingChanges, and there is no SaveChanges here to hook. So the operations carry the tenancy rules themselves: ExecuteUpdate and ExecuteDelete append the tenant predicate to whatever predicate you gave them, refuse a SetProperty(x => x.TenantId, ...) that would move rows into another tenant, refuse IgnoreQueryFilters() on an ITenanted entity, and check that the tenant has not been switched off. A refusal is a CrossTenantWriteException, the same exception you get from a cross-tenant write through change tracking.
WARNING
That protection belongs to the operations, not to ExecuteUpdateAsync itself. A raw db.Set<T>().IgnoreQueryFilters().ExecuteDeleteAsync() still reaches across tenants. In a conjoined multi-tenanted application, do your set-based writes through EfCoreOps.
Units of work in bulk
UnitOfWork<T> is not part of the EfCoreOps family, but it gets faster here for a related reason. EF Core has no upsert statement, so Store has to ask the database whether each row already exists -- and that used to be a FindAsync round trip per entity. A unit of work of a thousand entities meant a thousand queries. It is now a single WHERE key IN (...) for the whole unit of work:
public static UnitOfWork<Order> Handle(ImportOrders command)
{
var uow = new UnitOfWork<Order>();
foreach (var row in command.Rows)
{
uow.Store(ToOrder(row));
}
return uow;
}Nothing about the API changed, and a unit of work holding a single action still behaves exactly as it did. An entity with a composite primary key, or one whose key lives in a shadow property, falls back to the per-entity path because there is no single column to build an IN list from.
Auto Apply Transactional Middleware
You can opt into automatically applying the transactional middleware to any handler that depends on a DbContext type with the AutoApplyTransactions() option as shown below:
var builder = Host.CreateApplicationBuilder();
builder.UseWolverine(opts =>
{
var connectionString = builder.Configuration.GetConnectionString("database");
opts.Services.AddDbContextWithWolverineIntegration<SampleDbContext>(x =>
{
x.UseSqlServer(connectionString);
});
// Add the auto transaction middleware attachment policy
opts.Policies.AutoApplyTransactions();
});
using var host = builder.Build();
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 for how to designate it.
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, and it is required for any set-based operation -- see Set-Based Operations and EfCoreOps, which makes that requirement declarative instead of something you have to remember.
If you prefer to rely solely on DbContext.SaveChangesAsync() as your transactional boundary without opening an explicit database transaction, you can use TransactionMiddlewareMode.Lightweight:
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:
// 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
}DbContext Abstractions 6.2
Sometimes the application code wants to depend on an interface that's implemented by a DbContext rather than on the concrete DbContext itself — a DbContext that doubles as a custom IRepository, an IUnitOfWork, or a similar abstraction. Wolverine's EF Core transactional middleware can be taught to recognise those abstractions at handler-graph compile time so the auto-applied transaction/outbox still wraps the handler. Register the abstraction with WithDbContextAbstraction<TAbstraction, TDbContext>():
opts.Services.AddDbContextWithWolverineIntegration<OrdersDbContext>(x =>
x.UseNpgsql(connectionString));
// Forward the abstraction to the SAME scoped DbContext via a factory. This keeps
// `IOrderRepository` and `OrdersDbContext` pointing at one instance per scope, which is
// what `AddScoped<TAbs, TImpl>()` does NOT do (it would create a separate one per
// registered interface).
opts.Services.AddScoped<IOrderRepository>(sp => sp.GetRequiredService<OrdersDbContext>());
opts.PersistMessagesWithPostgresql(connectionString, "wolverine");
opts.UseEntityFrameworkCoreTransactions()
.WithDbContextAbstraction<IOrderRepository, OrdersDbContext>();
opts.Policies.AutoApplyTransactions();TIP
The generic constraint where TDbContext : DbContext, TAbstraction means the registration only covers abstractions that the DbContext implements directly. Wrappers around a DbContext are out of scope; declare the abstraction on the DbContext itself.
Handlers depend on the abstraction the same way they'd depend on any other service. Wolverine emits a runtime cast at the top of the handler chain so SaveChangesAsync and the EF Core outbox enrolment fire against the concrete DbContext underneath:
public class PlaceOrderViaAbstractionHandler
{
public static void Handle(PlaceOrderViaAbstraction cmd, IOrderRepository orders)
{
// The handler depends on the abstraction. Wolverine's transactional middleware
// recognises the chain as `DbContext`-backed via the registered abstraction and emits
// a runtime cast at the top of the chain so SaveChangesAsync + outbox enrolment fire
// against the concrete OrdersDbContext underneath.
orders.Orders.Add(new OrderEntity { Id = cmd.Id, Description = cmd.Description });
}
}Multiple abstractions for the same DbContext
A single DbContext can implement several abstractions, and a handler may depend on more than one of them. The contract Wolverine honours is: both parameters resolve to the same scoped DbContext instance, just viewed through different interfaces, so a single SaveChangesAsync commits all the writes the handler made through either parameter.
To make this work the abstractions must forward to the same scoped DbContext in DI — use a factory registration, not AddScoped<TAbstraction, TDbContext>() (the latter would create a separate DbContext per registered abstraction):
opts.Services.AddDbContextWithWolverineIntegration<StoreDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString,
b => b.MigrationsHistoryTable("__EFMigrationsHistory", "store_abs_schema")));
// Two abstractions forwarded to the SAME scoped DbContext instance via factory
// lambdas. `AddScoped<TAbs, TImpl>()` would create *separate* instances per
// registration; the factory form is the one users want when an abstraction is
// just a view over a DbContext that's already in the scope.
opts.Services.AddScoped<IItemRepository>(sp => sp.GetRequiredService<StoreDbContext>());
opts.Services.AddScoped<IOrderInsightRepository>(sp => sp.GetRequiredService<StoreDbContext>());
opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_abs");
opts.UseEntityFrameworkCoreTransactions()
.WithDbContextAbstraction<IItemRepository, StoreDbContext>()
.WithDbContextAbstraction<IOrderInsightRepository, StoreDbContext>();A handler can take both abstractions; the casts inside the chain land on the single shared DbContext and one transaction commits everything atomically:
public class CrossAbstractionAuditHandler
{
public static (bool SameInstance, Type ItemsType, Type OrdersType) LastSeen;
// The handler depends on TWO abstractions of the same `DbContext`. At runtime both
// parameters resolve to the same scoped `StoreDbContext`, just viewed through different
// interfaces — so a single `SaveChangesAsync` commits writes the handler made through
// either parameter atomically. The forwarding-factory DI registrations above are what
// make this work; without them you'd get two separate `DbContext` instances.
public static void Handle(CrossAbstractionAudit cmd, IItemRepository items, IOrderInsightRepository orders)
{
// Cast both back to the concrete DbContext - the cast must succeed (the constraint on
// WithDbContextAbstraction guarantees TDbContext : TAbstraction) and the resulting
// references must be the SAME instance. That's the contract Wolverine's
// CastDbContextFrame + the user's forwarding-factory DI registrations together provide:
// one DbContext in scope, viewed through different interfaces.
var itemsCtx = (StoreDbContext)items;
var ordersCtx = (StoreDbContext)orders;
LastSeen = (ReferenceEquals(itemsCtx, ordersCtx), itemsCtx.GetType(), ordersCtx.GetType());
// Both writes go through the single scoped DbContext - the EF Core middleware's
// SaveChangesAsync postprocessor commits them as one transaction.
items.Items.Add(new StoreItem { Id = cmd.ItemId, Name = "cross-abs" });
orders.StoreOrders.Add(new StoreOrder { Id = cmd.OrderId, Status = "audited" });
}
}Multi-DbContext, mixed abstraction
Each DbContext is independent — a host can mix abstracted and non-abstracted DbContexts freely. The middleware picks the right one for each handler based on its actual parameter dependencies:
// First DbContext: abstracted via IOrderRepository.
opts.Services.AddDbContextWithWolverineIntegration<OrdersDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString,
b => b.MigrationsHistoryTable("__EFMigrationsHistory", "orders_abs_schema")));
opts.Services.AddScoped<IOrderRepository>(sp => sp.GetRequiredService<OrdersDbContext>());
// Second DbContext: used directly, no abstraction.
opts.Services.AddDbContextWithWolverineIntegration<CustomersDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString,
b => b.MigrationsHistoryTable("__EFMigrationsHistory", "customers_abs_schema")));
opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_abs");
// Only OrdersDbContext is registered as having an abstraction — Wolverine's
// transactional middleware still wraps handlers that depend on
// CustomersDbContext directly.
opts.UseEntityFrameworkCoreTransactions()
.WithDbContextAbstraction<IOrderRepository, OrdersDbContext>();Selecting the Transactional DbContext 6.17
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 <handler>, 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:
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<TAbstraction, TDbContext>(), so a Clean Architecture handler can name the abstraction it depends on rather than the concrete EF Core type:
opts.UseEntityFrameworkCoreTransactions()
.WithDbContextAbstraction<IAppStore, AppDbContext>();
// ...
[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:
[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.

