diff --git a/docs/guide/durability/efcore/transactional-middleware.md b/docs/guide/durability/efcore/transactional-middleware.md
index bed1bc445..6f77b4341 100644
--- a/docs/guide/durability/efcore/transactional-middleware.md
+++ b/docs/guide/durability/efcore/transactional-middleware.md
@@ -223,3 +223,164 @@ public static void Handle(UpdateItemCommand command, ItemsDbContext db)
}
```
+
+## DbContext Abstractions
+
+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()`:
+
+
+
+```cs
+opts.Services.AddDbContextWithWolverineIntegration(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()` does NOT do (it would create a separate one per
+// registered interface).
+opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+opts.PersistMessagesWithPostgresql(connectionString, "wolverine");
+
+opts.UseEntityFrameworkCoreTransactions()
+ .WithDbContextAbstraction();
+
+opts.Policies.AutoApplyTransactions();
+```
+snippet source | anchor
+
+
+::: 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:
+
+
+
+```cs
+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 });
+ }
+}
+```
+snippet source | anchor
+
+
+### 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()` (the latter would create a
+separate `DbContext` per registered abstraction):
+
+
+
+```cs
+opts.Services.AddDbContextWithWolverineIntegration(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()` 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(sp => sp.GetRequiredService());
+opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_abs");
+
+opts.UseEntityFrameworkCoreTransactions()
+ .WithDbContextAbstraction()
+ .WithDbContextAbstraction();
+```
+snippet source | anchor
+
+
+A handler can take both abstractions; the casts inside the chain land on the single shared
+`DbContext` and one transaction commits everything atomically:
+
+
+
+```cs
+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" });
+ }
+}
+```
+snippet source | anchor
+
+
+### Multi-DbContext, mixed abstraction
+
+Each `DbContext` is independent — a host can mix abstracted and non-abstracted `DbContext`s
+freely. The middleware picks the right one for each handler based on its actual parameter
+dependencies:
+
+
+
+```cs
+// First DbContext: abstracted via IOrderRepository.
+opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString,
+ b => b.MigrationsHistoryTable("__EFMigrationsHistory", "orders_abs_schema")));
+opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+// Second DbContext: used directly, no abstraction.
+opts.Services.AddDbContextWithWolverineIntegration(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();
+```
+snippet source | anchor
+
diff --git a/docs/guide/durability/marten/ancillary-stores.md b/docs/guide/durability/marten/ancillary-stores.md
index 119a9ad9f..b899c6bd7 100644
--- a/docs/guide/durability/marten/ancillary-stores.md
+++ b/docs/guide/durability/marten/ancillary-stores.md
@@ -26,7 +26,7 @@ public interface IPlayerStore : IDocumentStore;
public interface IThingStore : IDocumentStore;
```
-snippet source | anchor
+snippet source | anchor
We can add Wolverine integration to both through a similar call to `IntegrateWithWolverine()` as normal as shown below:
@@ -74,11 +74,12 @@ theHost = await Host.CreateDefaultBuilder()
{
x.MainConnectionString = Servers.PostgresConnectionString;
});
-
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType(typeof(PlayerMessageHandler));
opts.Services.AddResourceSetupOnStartup();
}).StartAsync();
```
-snippet source | anchor
+snippet source | anchor
Let's specifically zoom in on this code from within the big sample above:
@@ -118,7 +119,7 @@ public static class PlayerMessageHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
::: info
diff --git a/docs/guide/durability/marten/distribution.md b/docs/guide/durability/marten/distribution.md
index 130a392df..6bc1381a5 100644
--- a/docs/guide/durability/marten/distribution.md
+++ b/docs/guide/durability/marten/distribution.md
@@ -26,7 +26,7 @@ opts.Services.AddMarten(m =>
m.UseWolverineManagedEventSubscriptionDistribution = true;
});
```
-snippet source | anchor
+snippet source | anchor
::: tip
@@ -113,11 +113,11 @@ var host = await Host.CreateDefaultBuilder()
{
opts.Durability.HealthCheckPollingTime = 1.Seconds();
opts.Durability.CheckAssignmentPeriod = 1.Seconds();
-
+
opts.UseMessagePackSerialization();
-
+
opts.UseSharedMemoryQueueing();
-
+
opts.Services.AddMarten(m =>
{
m.DisableNpgsqlLogging = true;
@@ -135,8 +135,8 @@ var host = await Host.CreateDefaultBuilder()
// cluster
m.UseWolverineManagedEventSubscriptionDistribution = true;
});
-
- opts.Services.AddSingleton(new OutputLoggerProvider(_output));
+
+ opts.Services.AddSingleton(new OutputLoggerProvider(output));
opts.Services.AddMartenStore(m =>
{
@@ -149,9 +149,8 @@ var host = await Host.CreateDefaultBuilder()
m.Projections.Add(ProjectionLifecycle.Async);
}).IntegrateWithWolverine();
- opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto;
}).StartAsync();
```
-snippet source | anchor
+snippet source | anchor
diff --git a/docs/guide/durability/marten/event-forwarding.md b/docs/guide/durability/marten/event-forwarding.md
index 14efdf997..a3c10feb9 100644
--- a/docs/guide/durability/marten/event-forwarding.md
+++ b/docs/guide/durability/marten/event-forwarding.md
@@ -135,7 +135,7 @@ To be used in your tests such as this:
[Fact]
public async Task execution_of_forwarded_events_can_be_awaited_from_tests()
{
- var host = await Host.CreateDefaultBuilder()
+ using var host = await Host.CreateDefaultBuilder()
.UseWolverine()
.ConfigureServices(services =>
{
@@ -162,7 +162,7 @@ public async Task execution_of_forwarded_events_can_be_awaited_from_tests()
events[1].Data.ShouldBeOfType();
}
```
-snippet source | anchor
+snippet source | anchor
Where the result contains `FourthEvent` because `SecondEvent` was forwarded as `SecondMessage` and that persisted `FourthEvent` in a handler such as:
@@ -177,7 +177,7 @@ public static Task HandleAsync(SecondMessage message, IDocumentSession session)
return session.SaveChangesAsync();
}
```
-snippet source | anchor
+snippet source | anchor
## Overriding Side-Effect Message Metadata
diff --git a/docs/guide/durability/marten/event-sourcing.md b/docs/guide/durability/marten/event-sourcing.md
index e26a40c6b..482c4df3e 100644
--- a/docs/guide/durability/marten/event-sourcing.md
+++ b/docs/guide/durability/marten/event-sourcing.md
@@ -437,7 +437,7 @@ public static void Handle(OrderEventSourcingSample.MarkItemReady command, IEvent
}
}
```
-snippet source | anchor
+snippet source | anchor
Just as in other Wolverine [message handlers](/guide/handlers/), you can use
@@ -535,7 +535,7 @@ public class MarkItemReady
public string ItemName { get; init; } = null!;
}
```
-snippet source | anchor
+snippet source | anchor
## Validation
@@ -644,7 +644,7 @@ public static (
return (new UpdatedAggregate(), events);
}
```
-snippet source | anchor
+snippet source | anchor
Note the usage of the `Wolverine.Marten.UpdatedAggregate` response in the handler. That type by itself is just a directive
@@ -662,7 +662,7 @@ public static Task update_and_get_latest(IMessageBus bus, MarkItemReady c
return bus.InvokeAsync(command);
}
```
-snippet source | anchor
+snippet source | anchor
Likewise, you can use `UpdatedAggregate` as the response body of an HTTP endpoint with Wolverine.HTTP [as shown here](/guide/http/marten.html#responding-with-the-updated-aggregate~~~~).
@@ -697,7 +697,7 @@ public static class RaiseIfValidatedHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
## Archiving Streams
@@ -1146,7 +1146,7 @@ public class StrongLetterAggregate
public void Apply(DEvent _) => DCount++;
}
```
-snippet source | anchor
+snippet source | anchor
And now let's use that identifier type in message handlers:
@@ -1209,7 +1209,7 @@ public static class StrongLetterHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
And also in some of the equivalent Wolverine.HTTP endpoints:
@@ -1279,7 +1279,7 @@ public record NkHandlerOrderCreated(NkHandlerOrderNumber OrderNumber, string Cus
public record NkHandlerItemAdded(string ItemName, decimal Price);
public record NkHandlerOrderCompleted;
```
-snippet source | anchor
+snippet source | anchor
### Using Natural Keys in Command Handlers
@@ -1293,7 +1293,7 @@ public record AddNkOrderItem(NkHandlerOrderNumber OrderNum, string ItemName, dec
public record AddNkOrderItems(NkHandlerOrderNumber OrderNum, (string Name, decimal Price)[] Items);
public record CompleteNkOrder(NkHandlerOrderNumber OrderNum);
```
-snippet source | anchor
+snippet source | anchor
Wolverine uses the natural key type on the command property to call `FetchForWriting()` under the covers, resolving the stream by the natural key in a single database round-trip.
@@ -1329,7 +1329,7 @@ public static class NkOrderHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
For more details on how natural keys work at the Marten level, see the [Marten natural keys documentation](https://martendb.io/events/natural-keys).
@@ -1437,8 +1437,14 @@ namespace MartenTests.Dcb.University;
/// Ported from the Axon SubscribeStudentToCourseCommandHandler.State which uses
/// EventCriteria.either() to load events matching CourseId OR StudentId.
///
-public class SubscriptionState
+public partial class SubscriptionState
{
+ // Required so the aggregate can be registered as a single-stream projection
+ // (LiveStreamAggregation), which is what makes the JasperFx.Events source generator
+ // emit the dispatcher that FetchForWritingByTags resolves. For the
+ // boundary (tag-query) path this Id is not stream-bound — it just satisfies the
+ // single-stream projection shape, the same way Marten's own DCB aggregates carry one.
+ public string Id { get; set; } = null!;
public CourseId? CourseId { get; private set; }
public int CourseCapacity { get; private set; }
public int StudentsSubscribedToCourse { get; private set; }
@@ -1484,7 +1490,7 @@ public class SubscriptionState
}
}
```
-snippet source | anchor
+snippet source | anchor
### Identity-less Boundary Aggregates with `[BoundaryAggregate]`
diff --git a/docs/guide/durability/marten/multi-tenancy.md b/docs/guide/durability/marten/multi-tenancy.md
index 42eee8fce..aa405c1e1 100644
--- a/docs/guide/durability/marten/multi-tenancy.md
+++ b/docs/guide/durability/marten/multi-tenancy.md
@@ -151,7 +151,7 @@ public static class CreateTenantDocumentHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
For completeness, here's the Wolverine and Marten bootstrapping:
@@ -162,6 +162,8 @@ For completeness, here's the Wolverine and Marten bootstrapping:
_host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(CreateTenantDocumentHandler));
+ opts.Durability.Mode = DurabilityMode.Solo;
opts.Services.AddMarten(Servers.PostgresConnectionString)
.IntegrateWithWolverine()
.UseLightweightSessions();
@@ -170,7 +172,7 @@ _host = await Host.CreateDefaultBuilder()
}).StartAsync();
```
-snippet source | anchor
+snippet source | anchor
and after that, the calls to [InvokeForTenantAsync()]() "just work" as you can see if you squint hard enough reading this test:
@@ -216,7 +218,7 @@ public async Task execute_with_tenancy()
}
}
```
-snippet source | anchor
+snippet source | anchor
diff --git a/docs/guide/durability/marten/operations.md b/docs/guide/durability/marten/operations.md
index e5245062c..f5da758d3 100644
--- a/docs/guide/durability/marten/operations.md
+++ b/docs/guide/durability/marten/operations.md
@@ -39,7 +39,7 @@ public static IMartenOp Pay([Document] Invoice invoice)
return MartenOps.Store(invoice);
}
```
-snippet source | anchor
+snippet source | anchor
There are existing Marten ops for storing, inserting, updating, and deleting a document.
@@ -169,7 +169,7 @@ public static IEnumerable Handle(AppendManyNamedDocuments command)
}
}
```
-snippet source | anchor
+snippet source | anchor
Wolverine will pick up on any return type that can be cast to `IEnumerable`, so for example:
diff --git a/docs/guide/durability/marten/transactional-middleware.md b/docs/guide/durability/marten/transactional-middleware.md
index 3ade4e697..3000205f1 100644
--- a/docs/guide/durability/marten/transactional-middleware.md
+++ b/docs/guide/durability/marten/transactional-middleware.md
@@ -19,6 +19,8 @@ It is no longer necessary to mark a handler method with `[Transactional]` if you
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
+ opts.Discovery.DisableConventionalDiscovery();
+ opts.Durability.Mode = DurabilityMode.Solo;
opts.Services.AddMarten("some connection string")
.IntegrateWithWolverine();
@@ -26,7 +28,7 @@ using var host = await Host.CreateDefaultBuilder()
opts.Policies.AutoApplyTransactions();
}).StartAsync();
```
-snippet source | anchor
+snippet source | anchor
With this enabled, Wolverine will automatically use the Marten
@@ -182,7 +184,7 @@ public class CommandsAreTransactional : IHandlerPolicy
}
}
```
-snippet source | anchor
+snippet source | anchor
Then add the policy to your application like this:
@@ -193,11 +195,13 @@ Then add the policy to your application like this:
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(CreateDocCommand2Handler));
+ opts.Durability.Mode = DurabilityMode.Solo;
// And actually use the policy
opts.Policies.Add();
}).StartAsync();
```
-snippet source | anchor
+snippet source | anchor
## Using IDocumentOperations
@@ -229,6 +233,6 @@ public class CreateDocCommand2Handler
}
}
```
-snippet source | anchor
+snippet source | anchor
diff --git a/docs/guide/durability/polecat/event-sourcing.md b/docs/guide/durability/polecat/event-sourcing.md
index 8eb665e17..96812dfa9 100644
--- a/docs/guide/durability/polecat/event-sourcing.md
+++ b/docs/guide/durability/polecat/event-sourcing.md
@@ -711,8 +711,14 @@ namespace MartenTests.Dcb.University;
/// Ported from the Axon SubscribeStudentToCourseCommandHandler.State which uses
/// EventCriteria.either() to load events matching CourseId OR StudentId.
///
-public class SubscriptionState
+public partial class SubscriptionState
{
+ // Required so the aggregate can be registered as a single-stream projection
+ // (LiveStreamAggregation), which is what makes the JasperFx.Events source generator
+ // emit the dispatcher that FetchForWritingByTags resolves. For the
+ // boundary (tag-query) path this Id is not stream-bound — it just satisfies the
+ // single-stream projection shape, the same way Marten's own DCB aggregates carry one.
+ public string Id { get; set; } = null!;
public CourseId? CourseId { get; private set; }
public int CourseCapacity { get; private set; }
public int StudentsSubscribedToCourse { get; private set; }
@@ -758,7 +764,7 @@ public class SubscriptionState
}
}
```
-snippet source | anchor
+snippet source | anchor
### Identity-less Boundary Aggregates with `[BoundaryAggregate]`
diff --git a/docs/guide/durability/sagas.md b/docs/guide/durability/sagas.md
index 7dcbbd89d..4ae0aea3c 100644
--- a/docs/guide/durability/sagas.md
+++ b/docs/guide/durability/sagas.md
@@ -695,7 +695,7 @@ public class RevisionedSaga : Wolverine.Saga
chain.SuccessLogLevel = LogLevel.None;
}
```
-snippet source | anchor
+snippet source | anchor
Or if you wanted to just do it globally, something like this approach:
diff --git a/docs/guide/extensions.md b/docs/guide/extensions.md
index 1f3e84633..4380bdb0f 100644
--- a/docs/guide/extensions.md
+++ b/docs/guide/extensions.md
@@ -13,7 +13,7 @@ Wolverine supports the concept of extensions for modularizing Wolverine configur
///
/// Use to create loadable extensions to Wolverine applications
///
-public interface IWolverineExtension
+public interface IWolverineExtension : IJasperFxExtension
{
///
/// Make any alterations to the WolverineOptions for the application
@@ -22,7 +22,7 @@ public interface IWolverineExtension
void Configure(WolverineOptions options);
}
```
-snippet source | anchor
+snippet source | anchor
Here's a sample:
@@ -108,7 +108,7 @@ internal class DisableExternalTransports : IWolverineExtension
}
}
```
-snippet source | anchor
+snippet source | anchor
And that extension is just added to the application's IoC container at test bootstrapping time like this:
@@ -122,7 +122,7 @@ public static IServiceCollection DisableAllExternalWolverineTransports(this ISer
return services;
}
```
-snippet source | anchor
+snippet source | anchor
In usage, the `IWolverineExtension` objects added to the IoC container are applied *after* the inner configuration
@@ -312,9 +312,14 @@ using var host = await Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.DisableConventionalDiscovery();
+
+ // With ExtensionDiscovery.ManualOnly, Wolverine does not auto-load the
+ // WolverineFx.RuntimeCompilation module, so a TypeLoadMode.Dynamic app must
+ // opt into runtime Roslyn compilation explicitly (or pre-generate with Static).
+ opts.UseRuntimeCompilation();
}, ExtensionDiscovery.ManualOnly)
.StartAsync();
```
-snippet source | anchor
+snippet source | anchor
diff --git a/docs/guide/http/index.md b/docs/guide/http/index.md
index 0ea9e6f81..42358cacb 100644
--- a/docs/guide/http/index.md
+++ b/docs/guide/http/index.md
@@ -260,7 +260,7 @@ app.MapWolverineEndpoints(opts =>
return await app.RunJasperFxCommands(args);
```
-snippet source | anchor
+snippet source | anchor
Notice the call to `SourceServiceFromHttpContext()`. That directs Wolverine.HTTP to always pull the service
diff --git a/docs/guide/http/marten.md b/docs/guide/http/marten.md
index 4888951b1..1c6504544 100644
--- a/docs/guide/http/marten.md
+++ b/docs/guide/http/marten.md
@@ -42,7 +42,7 @@ look like this:
return Results.Ok(invoice);
}
```
-snippet source | anchor
+snippet source | anchor
Pretty straightforward, but it's a little annoying to have to scatter in all the attributes for OpenAPI and there's definitely
@@ -58,7 +58,7 @@ public static Invoice Get([Document] Invoice invoice)
return invoice;
}
```
-snippet source | anchor
+snippet source | anchor
Notice that the `[Document]` attribute was able to use the "id" route parameter. By default, Wolverine is looking first
@@ -75,7 +75,7 @@ public static IMartenOp Approve([Document("number")] Invoice invoice)
return MartenOps.Store(invoice);
}
```
-snippet source | anchor
+snippet source | anchor
In the code above, if the `Invoice` document does not exist, the route will stop and return a status code 404 for Not Found.
@@ -101,7 +101,7 @@ public static Invoice GetSoftDeleted([Document(Required = true, MaybeSoftDeleted
return invoice;
}
```
-snippet source | anchor
+snippet source | anchor
@@ -378,7 +378,7 @@ public static class MakePurchaseHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
::: info
@@ -548,7 +548,7 @@ public static ApprovedInvoicedCompiledQuery GetApproved()
return new ApprovedInvoicedCompiledQuery();
}
```
-snippet source | anchor
+snippet source | anchor
@@ -562,7 +562,7 @@ public class ApprovedInvoicedCompiledQuery : ICompiledListQuery
}
}
```
-snippet source | anchor
+snippet source | anchor
## Streaming JSON Responses
diff --git a/docs/guide/http/streaming.md b/docs/guide/http/streaming.md
index 99072df3a..e57d4a6ee 100644
--- a/docs/guide/http/streaming.md
+++ b/docs/guide/http/streaming.md
@@ -14,7 +14,7 @@ public static IResult GetSseEvents()
{
return Results.Stream(async stream =>
{
- var writer = new StreamWriter(stream);
+ await using var writer = new StreamWriter(stream);
for (var i = 0; i < 3; i++)
{
await writer.WriteAsync($"data: Event {i}\n\n");
@@ -40,7 +40,7 @@ public static IResult GetStreamData()
{
return Results.Stream(async stream =>
{
- var writer = new StreamWriter(stream);
+ await using var writer = new StreamWriter(stream);
for (var i = 0; i < 5; i++)
{
await writer.WriteLineAsync($"line {i}");
diff --git a/docs/guide/messaging/message-bus.md b/docs/guide/messaging/message-bus.md
index c8149fb11..0cac11594 100644
--- a/docs/guide/messaging/message-bus.md
+++ b/docs/guide/messaging/message-bus.md
@@ -190,7 +190,7 @@ public class CreateItemCommandHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
## Global Timeout Default for Request/Reply
diff --git a/docs/guide/messaging/partitioning.md b/docs/guide/messaging/partitioning.md
index f5352acee..ec5685408 100644
--- a/docs/guide/messaging/partitioning.md
+++ b/docs/guide/messaging/partitioning.md
@@ -164,7 +164,7 @@ opts.MessagePartitioning
});
});
```
-snippet source | anchor
+snippet source | anchor
The built in rules *at this point* include:
diff --git a/docs/guide/messaging/subscriptions.md b/docs/guide/messaging/subscriptions.md
index 97217e525..98dd20914 100644
--- a/docs/guide/messaging/subscriptions.md
+++ b/docs/guide/messaging/subscriptions.md
@@ -365,9 +365,22 @@ public interface IMessageRoutingConvention
void PreregisterSenders(IReadOnlyList handledMessageTypes, IWolverineRuntime runtime)
{
}
+
+ ///
+ /// Diagnostic description of this routing convention for routing explanations, the
+ /// describe-routing CLI, and service capabilities. The default implementation reports the
+ /// type name with no description; built-in and extension conventions should override to
+ /// supply a meaningful, AI-readable explanation (and, for broker conventions, the parent
+ /// transport's scheme/name/description).
+ ///
+ RoutingConventionDescriptor Describe(IWolverineRuntime runtime) => new()
+ {
+ Name = GetType().Name,
+ Description = string.Empty
+ };
}
```
-snippet source | anchor
+snippet source | anchor
As a concrete example, the Wolverine team received [this request](https://github.com/JasperFx/wolverine/issues/1130) to conventionally route messages based on
diff --git a/docs/guide/migration.md b/docs/guide/migration.md
index 64bb8ea0c..f6ff077e3 100644
--- a/docs/guide/migration.md
+++ b/docs/guide/migration.md
@@ -498,5 +498,5 @@ public class CreateItemCommandHandler
}
}
```
-snippet source | anchor
+snippet source | anchor
diff --git a/docs/tutorials/leader-election.md b/docs/tutorials/leader-election.md
index afcf6d33e..01dda0f64 100644
--- a/docs/tutorials/leader-election.md
+++ b/docs/tutorials/leader-election.md
@@ -491,12 +491,14 @@ public class SimpleSingularAgent : SingularAgent
// This template method should be used to cleanly stop up your background service
protected override Task stopAsync(CancellationToken cancellationToken)
{
+ _cancellation.Cancel();
+ _cancellation.Dispose();
_timer.SafeDispose();
return Task.CompletedTask;
}
}
```
-snippet source | anchor
+snippet source | anchor
To add that to your Wolverine system, we've added this convenience method:
diff --git a/docs/tutorials/modular-monolith.md b/docs/tutorials/modular-monolith.md
index e7d2a5a07..f317e2037 100644
--- a/docs/tutorials/modular-monolith.md
+++ b/docs/tutorials/modular-monolith.md
@@ -352,7 +352,7 @@ public static async Task run_end_to_end(IHost host)
// command handler
}
```
-snippet source | anchor
+snippet source | anchor
In the code sample above, the `InvokeAndMessageAndWaitAsync()` method puts the Wolverine runtime into a "tracked" mode
@@ -394,7 +394,7 @@ public static async Task run_end_to_end_with_external_transports(IHost host)
// command handler
}
```
-snippet source | anchor
+snippet source | anchor
And to test the invocation of an event message to a specific handler, we can still do that by sending the message to a specific local queue:
@@ -410,7 +410,7 @@ public static async Task test_specific_handler(IHost host)
c => c.EndpointFor("local queue name").SendAsync(new OrderPlaced("111")).AsTask());
}
```
-snippet source | anchor
+snippet source | anchor
## With EF Core
diff --git a/src/Persistence/EfCoreTests/dbContext_abstraction_scenarios.cs b/src/Persistence/EfCoreTests/dbContext_abstraction_scenarios.cs
new file mode 100644
index 000000000..0d3fcc136
--- /dev/null
+++ b/src/Persistence/EfCoreTests/dbContext_abstraction_scenarios.cs
@@ -0,0 +1,452 @@
+using IntegrationTests;
+using JasperFx;
+using JasperFx.Resources;
+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.Persistence;
+using Wolverine.Postgresql;
+using Wolverine.Tracking;
+
+// Sub-namespace so this file's `OrderEntity` / `IOrderRepository` / etc. don't collide with the
+// unrelated identically-named fixtures in `Bug_252_codegen_issue.cs` under the parent namespace.
+namespace EfCoreTests.DbContextAbstractionScenarios;
+
+// Follow-up coverage for PR #2919 (`WithDbContextAbstraction()`). The
+// scenarios in this file prove three contracts that aren't exercised by the original PR's tests:
+//
+// 1. Multi-DbContext, mixed abstraction — one DbContext is registered through an abstraction
+// and another DbContext is used directly. Both handlers transact through Wolverine's
+// EF Core middleware in the same host.
+//
+// 2. Multiple abstractions for the SAME DbContext — `StoreDbContext` implements both
+// `IItemRepository` and `IOrderRepository`. Two separate handlers, each depending on a
+// different abstraction, both successfully commit through the same physical DbContext.
+//
+// 3. Multiple abstractions used IN THE SAME handler — the handler depends on both
+// `IItemRepository` and `IOrderRepository`. At runtime BOTH parameters must resolve to the
+// *same scoped* `StoreDbContext` instance (just cast to the requested interface), so a
+// single Save/transaction commits all writes atomically. This is the contract the
+// DI-registration pattern (forwarding factories) is designed to honour, and the cast frame
+// that the merged PR emits depends on.
+
+public class dbContext_abstraction_scenarios
+{
+ // --- Scenario 1: multi-DbContext, mixed abstraction ---------------------------------------
+
+ [Fact]
+ public async Task multiple_dbcontext_types_one_abstracted_one_direct()
+ {
+ await CreateSchemaAsync("""
+ CREATE TABLE orders_abs_schema.orders (
+ "Id" uuid PRIMARY KEY,
+ "Description" text NOT NULL
+ );
+ CREATE TABLE customers_abs_schema.customers (
+ "Id" uuid PRIMARY KEY,
+ "Name" text NOT NULL
+ );
+ """, "orders_abs_schema", "customers_abs_schema");
+
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ #region sample_register_mixed_dbcontexts
+
+ // First DbContext: abstracted via IOrderRepository.
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString,
+ b => b.MigrationsHistoryTable("__EFMigrationsHistory", "orders_abs_schema")));
+ opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+ // Second DbContext: used directly, no abstraction.
+ opts.Services.AddDbContextWithWolverineIntegration(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();
+
+ #endregion
+
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType()
+ .IncludeType();
+
+ opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
+ }).StartAsync();
+
+ var orderId = Guid.NewGuid();
+ var customerId = Guid.NewGuid();
+
+ await host.InvokeMessageAndWaitAsync(new PlaceOrderViaAbstraction(orderId, "abstracted"));
+ await host.InvokeMessageAndWaitAsync(new RegisterCustomerDirect(customerId, "direct"));
+
+ await using var scope = host.Services.CreateAsyncScope();
+ (await scope.ServiceProvider.GetRequiredService()
+ .Orders.AnyAsync(o => o.Id == orderId))
+ .ShouldBeTrue("abstracted handler must commit through the IOrderRepository transaction");
+ (await scope.ServiceProvider.GetRequiredService()
+ .Customers.AnyAsync(c => c.Id == customerId))
+ .ShouldBeTrue("direct handler must commit through the CustomersDbContext transaction");
+ }
+
+ // --- Scenario 2: multiple abstractions for the same DbContext ------------------------------
+
+ [Fact]
+ public async Task multiple_abstractions_for_same_dbcontext_each_used_independently()
+ {
+ await CreateSchemaAsync("""
+ CREATE TABLE store_abs_schema.items (
+ "Id" uuid PRIMARY KEY,
+ "Name" text NOT NULL
+ );
+ CREATE TABLE store_abs_schema.orders (
+ "Id" uuid PRIMARY KEY,
+ "Status" text NOT NULL
+ );
+ """, "store_abs_schema");
+
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ #region sample_register_multiple_dbcontext_abstractions
+
+ opts.Services.AddDbContextWithWolverineIntegration(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()` 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(sp => sp.GetRequiredService());
+ opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_abs");
+
+ opts.UseEntityFrameworkCoreTransactions()
+ .WithDbContextAbstraction()
+ .WithDbContextAbstraction();
+
+ #endregion
+
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType()
+ .IncludeType();
+
+ opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
+ }).StartAsync();
+
+ var itemId = Guid.NewGuid();
+ var orderId = Guid.NewGuid();
+
+ await host.InvokeMessageAndWaitAsync(new CreateStoreItem(itemId, "phone"));
+ await host.InvokeMessageAndWaitAsync(new RecordStoreOrder(orderId, "shipped"));
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Items.AnyAsync(i => i.Id == itemId)).ShouldBeTrue();
+ (await db.StoreOrders.AnyAsync(o => o.Id == orderId)).ShouldBeTrue();
+ }
+
+ // --- Scenario 3: same handler uses both abstractions; assert SAME DbContext instance -------
+
+ [Fact]
+ public async Task handler_using_two_abstractions_to_same_dbcontext_uses_one_scoped_instance()
+ {
+ await CreateSchemaAsync("""
+ CREATE TABLE store_abs_schema.items (
+ "Id" uuid PRIMARY KEY,
+ "Name" text NOT NULL
+ );
+ CREATE TABLE store_abs_schema.orders (
+ "Id" uuid PRIMARY KEY,
+ "Status" text NOT NULL
+ );
+ """, "store_abs_schema");
+
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddDbContextWithWolverineIntegration(x =>
+ x.UseNpgsql(Servers.PostgresConnectionString,
+ b => b.MigrationsHistoryTable("__EFMigrationsHistory", "store_abs_schema")));
+
+ // Critical for the contract under test: both abstractions are factory-resolved
+ // from the same scoped StoreDbContext. Any other registration shape would defeat
+ // the "one DbContext in scope, viewed through different interfaces" guarantee.
+ opts.Services.AddScoped(sp => sp.GetRequiredService());
+ opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+ opts.PersistMessagesWithPostgresql(Servers.PostgresConnectionString, "wolverine_abs");
+
+ opts.UseEntityFrameworkCoreTransactions()
+ .WithDbContextAbstraction()
+ .WithDbContextAbstraction();
+
+ opts.Policies.AutoApplyTransactions();
+
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType();
+
+ opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
+ }).StartAsync();
+
+ var itemId = Guid.NewGuid();
+ var orderId = Guid.NewGuid();
+
+ // Reset between scenarios so we observe only this command's writes.
+ CrossAbstractionAuditHandler.LastSeen = default;
+
+ await host.InvokeMessageAndWaitAsync(new CrossAbstractionAudit(itemId, orderId));
+
+ // The handler tested reference-equality between the two abstraction parameters cast back
+ // to the concrete DbContext; this assertion is the test's actual proof point.
+ CrossAbstractionAuditHandler.LastSeen.SameInstance
+ .ShouldBeTrue("both abstractions must resolve to the same scoped StoreDbContext");
+
+ // And both writes must have landed via that single context's single SaveChanges.
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Items.AnyAsync(i => i.Id == itemId)).ShouldBeTrue();
+ (await db.StoreOrders.AnyAsync(o => o.Id == orderId)).ShouldBeTrue();
+ }
+
+ // EF Core's EnsureCreatedAsync is a no-op when the database already exists — and the shared
+ // Wolverine integration-tests Postgres always does. So we issue raw DDL for the user-defined
+ // tables, mirroring the workaround documented in `Bugs/Bug_DurableLocalQueue_ancillary_store_routing.cs`
+ // (see GH-2618). Schemas are dropped first so re-runs of these scenarios start clean.
+ private static async Task CreateSchemaAsync(string sql, params string[] schemas)
+ {
+ await using var conn = new NpgsqlConnection(Servers.PostgresConnectionString);
+ await conn.OpenAsync();
+ foreach (var schema in schemas)
+ {
+ await conn.DropSchemaAsync(schema);
+ await conn.CreateCommand($"CREATE SCHEMA \"{schema}\"").ExecuteNonQueryAsync();
+ }
+
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = sql;
+ await cmd.ExecuteNonQueryAsync();
+ }
+}
+
+// === Entities + DbContexts + abstractions ====================================================
+
+public class OrderEntity
+{
+ public Guid Id { get; set; }
+ public string Description { get; set; } = string.Empty;
+}
+
+public class CustomerEntity
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+}
+
+public class StoreItem
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+}
+
+public class StoreOrder
+{
+ public Guid Id { get; set; }
+ public string Status { get; set; } = string.Empty;
+}
+
+public interface IOrderRepository
+{
+ DbSet Orders { get; }
+}
+
+public interface IItemRepository
+{
+ DbSet Items { get; }
+}
+
+public interface IOrderInsightRepository
+{
+ DbSet StoreOrders { get; }
+}
+
+public class OrdersDbContext(DbContextOptions options) : DbContext(options), IOrderRepository
+{
+ public DbSet Orders => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("orders_abs_schema");
+ modelBuilder.MapWolverineEnvelopeStorage("orders_abs_schema");
+ modelBuilder.Entity().ToTable("orders");
+ }
+}
+
+public class CustomersDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Customers => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("customers_abs_schema");
+ modelBuilder.MapWolverineEnvelopeStorage("customers_abs_schema");
+ modelBuilder.Entity().ToTable("customers");
+ }
+}
+
+public class StoreDbContext(DbContextOptions options) : DbContext(options),
+ IItemRepository, IOrderInsightRepository
+{
+ public DbSet Items => Set();
+ public DbSet StoreOrders => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("store_abs_schema");
+ modelBuilder.MapWolverineEnvelopeStorage("store_abs_schema");
+ modelBuilder.Entity().ToTable("items");
+ modelBuilder.Entity().ToTable("orders");
+ }
+}
+
+// === Messages ================================================================================
+
+public record PlaceOrderViaAbstraction(Guid Id, string Description);
+
+public record RegisterCustomerDirect(Guid Id, string Name);
+
+public record CreateStoreItem(Guid Id, string Name);
+
+public record RecordStoreOrder(Guid Id, string Status);
+
+public record CrossAbstractionAudit(Guid ItemId, Guid OrderId);
+
+// === Handlers ================================================================================
+
+#region sample_handler_using_dbcontext_abstraction
+
+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 });
+ }
+}
+
+#endregion
+
+public class RegisterCustomerDirectHandler
+{
+ public static void Handle(RegisterCustomerDirect cmd, CustomersDbContext db)
+ {
+ db.Customers.Add(new CustomerEntity { Id = cmd.Id, Name = cmd.Name });
+ }
+}
+
+public class CreateStoreItemHandler
+{
+ public static void Handle(CreateStoreItem cmd, IItemRepository items)
+ {
+ items.Items.Add(new StoreItem { Id = cmd.Id, Name = cmd.Name });
+ }
+}
+
+public class RecordStoreOrderHandler
+{
+ public static void Handle(RecordStoreOrder cmd, IOrderInsightRepository orders)
+ {
+ orders.StoreOrders.Add(new StoreOrder { Id = cmd.Id, Status = cmd.Status });
+ }
+}
+
+#region sample_handler_using_multiple_abstractions
+
+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" });
+ }
+}
+
+#endregion
+
+// === Doc-only sample: bare single-abstraction registration ====================================
+//
+// Existence-of-this-class proves the snippet below compiles; the dedicated tests above exercise
+// the actual runtime behaviour.
+
+public static class SingleAbstractionRegistrationSample
+{
+ public static void Configure(WolverineOptions opts, string connectionString)
+ {
+ #region sample_register_dbcontext_abstraction
+
+ opts.Services.AddDbContextWithWolverineIntegration(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()` does NOT do (it would create a separate one per
+ // registered interface).
+ opts.Services.AddScoped(sp => sp.GetRequiredService());
+
+ opts.PersistMessagesWithPostgresql(connectionString, "wolverine");
+
+ opts.UseEntityFrameworkCoreTransactions()
+ .WithDbContextAbstraction();
+
+ opts.Policies.AutoApplyTransactions();
+
+ #endregion
+ }
+}