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 + } +}