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
90 changes: 90 additions & 0 deletions docs/guide/durability/efcore/transactional-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -384,3 +390,87 @@ opts.UseEntityFrameworkCoreTransactions()
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Persistence/EfCoreTests/dbContext_abstraction_scenarios.cs#L62-L83' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_register_mixed_dbcontexts' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Selecting the Transactional DbContext <Badge type="tip" text="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:

```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<TAbstraction,
TDbContext>()`, so a Clean Architecture handler can name the abstraction it depends on rather than the
concrete EF Core type:

```csharp
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`:

```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.
218 changes: 218 additions & 0 deletions src/Persistence/EfCoreTests/storage_dbcontext_selection_tests.cs
Original file line number Diff line number Diff line change
@@ -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<InvoiceDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString));

// A second, read-only DbContext dependency that must never be enrolled.
opts.Services.AddDbContext<PricingDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString));

opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_invoice_storage");

opts.UseEntityFrameworkCoreTransactions();
opts.Policies.AutoApplyTransactions();

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

host.GetRuntime().Handlers.HandlerFor<CreateInvoice>();
var chain = host.GetRuntime().Handlers.ChainFor<CreateInvoice>();
chain.ShouldNotBeNull();

// [Storage(typeof(InvoiceDbContext))] set AncillaryStoreType; DetermineDbContextType honored it,
// so only InvoiceDbContext is enrolled — PricingDbContext stays an ordinary read dependency.
chain.Middleware.OfType<EnrollDbContextInTransaction>().ToArray().Length.ShouldBe(1);

var saveChanges = chain.Postprocessors.OfType<MethodCall>()
.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<InvoiceDbContext>();
(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<InvoiceDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString));
opts.Services.AddDbContext<PricingDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString));

opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_invoice_storage_err");

opts.UseEntityFrameworkCoreTransactions();
opts.Policies.AutoApplyTransactions();

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

host.GetRuntime().Handlers.HandlerFor<CreateInvoiceWithBadStorage>();
}

var ex = await Should.ThrowAsync<InvalidOperationException>(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<InvoiceDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString));
// A second Wolverine-enabled context so there is genuinely no single default.
opts.Services.AddDbContextWithWolverineIntegration<AuditingDbContext>(x =>
x.UseNpgsql(Servers.PostgresConnectionString));

opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_ambiguous");

opts.UseEntityFrameworkCoreTransactions();
opts.Policies.AutoApplyTransactions();

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

host.GetRuntime().Handlers.HandlerFor<AmbiguousCommand>();
}

// 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<InvalidOperationException>(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<InvoiceDbContext> options) : DbContext(options)
{
public DbSet<Invoice> Invoices => Set<Invoice>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("invoice_storage_schema");
modelBuilder.MapWolverineEnvelopeStorage("invoice_storage_schema");
modelBuilder.Entity<Invoice>().ToTable("invoices");
}
}

// Read-only lookup context: no overlapping entities, exists only to add a second DbContext dependency.
public class PricingDbContext(DbContextOptions<PricingDbContext> options) : DbContext(options);

// A second Wolverine-enabled write context used to force genuine ambiguity.
public class AuditingDbContext(DbContextOptions<AuditingDbContext> 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" });
}
}
Loading
Loading