diff --git a/docs/examples/cqrs-dispatcher.md b/docs/examples/cqrs-dispatcher.md new file mode 100644 index 00000000..454fe68b --- /dev/null +++ b/docs/examples/cqrs-dispatcher.md @@ -0,0 +1,64 @@ +# CQRS Dispatcher + +The CQRS example shows how an application can keep write models and read models explicit while still using PatternKit through normal .NET dependency injection. + +The example has two supported paths: + +- a fluent `Mediator` path for small applications or runtime-composed modules +- a source-generated `ProductionDispatcher` path for larger applications that want compile-time dispatcher APIs + +## Register + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; + +var services = new ServiceCollection() + .AddCqrsDispatcherExample(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var example = provider.GetRequiredService(); +``` + +Applications that only want the generated CQRS dispatcher services can register that lower-level bolt-on directly: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.Messaging; + +var services = new ServiceCollection() + .AddSourceGeneratedCqrsServices(); +``` + +`AddSourceGeneratedCqrsServices` uses `TryAdd` for infrastructure defaults, so an application can provide its own logger or repositories before calling the extension. + +## Fluent Path + +```csharp +var fluent = await example.RunFluentAsync(CancellationToken.None); +``` + +The fluent path builds a `Mediator` with: + +- `CreateCqrsOrder` as the command/write operation +- `GetCqrsOrder` as the query/read operation +- `CqrsOrderCreated` as the domain event notification +- pre/post behaviors for observable pipeline execution + +## Source-Generated Path + +```csharp +var generated = await example.RunSourceGeneratedAsync(provider, CancellationToken.None); +``` + +The generated path uses `ProductionDispatcher`, generated from `[GenerateDispatcher]`, with command handlers, query handlers, notification handlers, repositories, and logging registered in `IServiceCollection`. + +## Production Shape + +The TinyBDD scenarios validate that: + +- commands mutate the write side and return created state +- queries read back the command result through the read side +- events fan out through notification handlers +- both fluent and generated paths are importable through `IServiceCollection` +- the example advertises dependency injection and source-generation integration surfaces diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 4b127a96..9a644ba1 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -55,6 +55,9 @@ - name: Enterprise Messaging Workflow Suite href: enterprise-messaging-workflows.md +- name: CQRS Dispatcher + href: cqrs-dispatcher.md + - name: Resilient Checkout and Collaborating Mailboxes href: resilient-checkout-and-mailboxes.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 3080aeba..8613ce08 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -57,7 +57,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Messaging Reliability | Outbox | `InMemoryOutbox` and dispatcher contracts | Tracked in [#213](https://github.com/JerrettDavis/PatternKit/issues/213) | | Enterprise Integration | Request-Reply | Messaging backplane facade example | Tracked in [#214](https://github.com/JerrettDavis/PatternKit/issues/214) | | Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Tracked in [#214](https://github.com/JerrettDavis/PatternKit/issues/214) | -| Application Architecture | CQRS | Mediator/dispatcher command-query split | First-class example tracked in [#212](https://github.com/JerrettDavis/PatternKit/issues/212) | +| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | ## Research Baselines diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 55f4d685..f46cd768 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -87,6 +87,7 @@ public sealed record MessageRouterVisitorExample(Func Run); public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); public sealed record SourceGeneratorApplicationSuiteExample(Func> BuildProductionAsync); public sealed record EnterpriseMessagingWorkflowSuiteExample(Func Run); +public sealed record CqrsDispatcherExample(Func> RunFluentAsync, Func> RunSourceGeneratedAsync); public sealed record ResilientCheckoutMailboxesExample(Func Run); public sealed record MessagingBackplaneFacadeExample(Func> RunAsync); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); @@ -125,6 +126,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddPatternsShowcaseExample() .AddSourceGeneratorApplicationSuiteExample() .AddEnterpriseMessagingWorkflowSuiteExample() + .AddCqrsDispatcherExample() .AddResilientCheckoutMailboxesExample() .AddMessagingBackplaneFacadeExample() .AddPrototypeGameCharacterFactoryExample() @@ -339,6 +341,13 @@ public static IServiceCollection AddEnterpriseMessagingWorkflowSuiteExample(this return services.RegisterExample("Enterprise Messaging Workflow Suite", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddCqrsDispatcherExample(this IServiceCollection services) + { + services.AddSourceGeneratedCqrsServices(); + services.AddSingleton(new CqrsDispatcherExample(CqrsPatternExample.RunFluentAsync, CqrsPatternExample.RunSourceGeneratedAsync)); + return services.RegisterExample("CQRS Dispatcher", ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddResilientCheckoutMailboxesExample(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/PatternKit.Examples/Messaging/CqrsPatternExample.cs b/src/PatternKit.Examples/Messaging/CqrsPatternExample.cs new file mode 100644 index 00000000..6a3915aa --- /dev/null +++ b/src/PatternKit.Examples/Messaging/CqrsPatternExample.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using PatternKit.Behavioral.Mediator; +using PatternKit.Examples.Messaging.SourceGenerated; +using SourceGenerated = PatternKit.Examples.Messaging.SourceGenerated; + +namespace PatternKit.Examples.Messaging; + +/// +/// Production-shaped CQRS example showing fluent and source-generated PatternKit paths side by side. +/// +public static class CqrsPatternExample +{ + public static async ValueTask RunFluentAsync(CancellationToken cancellationToken = default) + { + var log = new List(); + var orders = new Dictionary(); + var nextOrderId = 1000; + + var mediator = Mediator.Create() + .Pre((in request, _) => + { + log.Add($"pre:{request?.GetType().Name}"); + return ValueTask.CompletedTask; + }) + .Post((in request, response, _) => + { + log.Add($"post:{request?.GetType().Name}:{response?.GetType().Name ?? "void"}"); + return ValueTask.CompletedTask; + }) + .Command((in command, _) => + { + var order = new CqrsOrder(++nextOrderId, command.CustomerId, command.Lines, command.Lines.Sum(static line => line.Quantity * line.UnitPrice)); + orders[order.Id] = order; + return new ValueTask(order); + }) + .Command((in query, _) => + { + orders.TryGetValue(query.OrderId, out var order); + return new ValueTask(order); + }) + .Notification((in notification, _) => + { + log.Add($"event:order-created:{notification.OrderId}"); + return ValueTask.CompletedTask; + }) + .Build(); + + var created = await mediator.Send( + new CreateCqrsOrder("customer-1", [new CqrsLine("SKU-1", 2, 19.95m)]), + cancellationToken); + + await mediator.Publish(new CqrsOrderCreated(created!.Id), cancellationToken); + var readModel = await mediator.Send(new GetCqrsOrder(created.Id), cancellationToken); + + return new CqrsSummary( + "fluent", + created.Id, + readModel?.Id == created.Id, + created.Total, + log.ToArray()); + } + + public static async ValueTask RunSourceGeneratedAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + var dispatcher = services.GetRequiredService(); + var customers = services.GetRequiredService(); + var orders = services.GetRequiredService(); + var logger = services.GetRequiredService(); + + var customer = await dispatcher.Send( + new CreateCustomerCommand("Ada Lovelace", "ada@example.com", 5000m), + cancellationToken); + + customers.Add(customer); + await dispatcher.Publish(new CustomerCreatedEvent(customer.Id, customer.Name, customer.Email), cancellationToken); + + var order = await dispatcher.Send( + new PlaceOrderCommand(customer.Id, [new OrderItem(1, "Keyboard", 2, 50m)]), + cancellationToken); + + orders.Add(order); + await dispatcher.Publish(new OrderPlacedEvent(order.Id, order.CustomerId, order.Total), cancellationToken); + + var readModel = await dispatcher.Send>( + new GetOrdersByCustomerQuery(customer.Id), + cancellationToken); + + return new CqrsSummary( + "source-generated", + order.Id, + readModel.Count == 1 && readModel[0].Id == order.Id, + order.Total, + logger.GetLogs().ToArray()); + } + + public static IServiceCollection AddSourceGeneratedCqrsServices(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services + .AddSourceGeneratedMediator() + .AddHandlersFromAssembly(typeof(CreateCustomerHandler).Assembly); + } +} + +public sealed record CreateCqrsOrder(string CustomerId, IReadOnlyList Lines); + +public sealed record GetCqrsOrder(int OrderId); + +public sealed record CqrsOrderCreated(int OrderId); + +public sealed record CqrsLine(string Sku, int Quantity, decimal UnitPrice); + +public sealed record CqrsOrder(int Id, string CustomerId, IReadOnlyList Lines, decimal Total); + +public sealed record CqrsSummary(string Path, int OrderId, bool QueryMatchedCommand, decimal Total, IReadOnlyList Log); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 1928a8bc..a1b4c278 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -232,6 +232,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator, ["ContentRouter", "RecipientList", "Splitter", "Aggregator", "RoutingSlip", "Saga", "Mailbox"], ["idempotency", "inbox/outbox", "generated dispatcher"]), + Descriptor( + "CQRS Dispatcher", + "src/PatternKit.Examples/Messaging/CqrsPatternExample.cs", + "test/PatternKit.Examples.Tests/Messaging/CqrsPatternExampleTests.cs", + "docs/examples/cqrs-dispatcher.md", + ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.GenericHost, + ["Mediator", "Dispatcher", "CQRS"], + ["command/query separation", "source-generated dispatcher", "DI composition"]), Descriptor( "Resilient Checkout and Collaborating Mailboxes", "src/PatternKit.Examples/Messaging/ResilientCheckoutDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index f1efb458..2acd036c 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -539,11 +539,11 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/generators/dispatcher.md", "src/PatternKit.Generators/Messaging/DispatcherGenerator.cs", "test/PatternKit.Generators.Tests/DispatcherGeneratorTests.cs", - "https://github.com/JerrettDavis/PatternKit/issues/212", - "docs/generators/dispatcher.md", - "src/PatternKit.Examples/MediatorComprehensiveDemo/ComprehensiveDemo.cs", - "test/PatternKit.Examples.Tests/MediatorDemo/MediatorDemoTests.cs", - ["dispatcher command/query separation", "generated dispatcher", "first-class CQRS example tracked"]) + null, + "docs/examples/cqrs-dispatcher.md", + "src/PatternKit.Examples/Messaging/CqrsPatternExample.cs", + "test/PatternKit.Examples.Tests/Messaging/CqrsPatternExampleTests.cs", + ["fluent mediator command/query separation", "generated dispatcher", "DI-importable CQRS example"]) ]; public IReadOnlyList Patterns => Items; diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index ac8bcf27..69846563 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -81,6 +81,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var asyncTemplate = provider.GetRequiredService(); var routing = provider.GetRequiredService(); var envelope = provider.GetRequiredService(); + var cqrs = provider.GetRequiredService(); var checkout = provider.GetRequiredService(); auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary())); @@ -107,6 +108,8 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() .GetResult(); var state = asyncState.RunAsync(["connect", "ok"]).GetAwaiter().GetResult(); var asyncResult = asyncTemplate.Pipeline.ExecuteAsync(7, CancellationToken.None).GetAwaiter().GetResult(); + var cqrsFluent = cqrs.RunFluentAsync(CancellationToken.None).GetAwaiter().GetResult(); + var cqrsGenerated = cqrs.RunSourceGeneratedAsync(provider, CancellationToken.None).GetAwaiter().GetResult(); editor.Editor.Insert("hello"); return @@ -132,6 +135,8 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("async template method formats payloads", asyncResult == "PAYLOAD:7"), ("message router visitor aggregates totals", routing.Run().AggregatedTotal == 100m), ("message envelope example tracks first attempt", envelope.Run().Attempt == 1), + ("CQRS fluent path matches command writes to query reads", cqrsFluent.QueryMatchedCommand), + ("CQRS generated path matches command writes to query reads", cqrsGenerated.QueryMatchedCommand), ("resilient checkout succeeds", checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded) ]; } diff --git a/test/PatternKit.Examples.Tests/Messaging/CqrsPatternExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/CqrsPatternExampleTests.cs new file mode 100644 index 00000000..3fec8591 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/CqrsPatternExampleTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using PatternKit.Examples.Messaging.SourceGenerated; +using PatternKit.Examples.ProductionReadiness; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.Messaging; + +[Feature("CQRS dispatcher example")] +public sealed class CqrsPatternExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent mediator path separates command writes from query reads")] + [Fact] + public Task Fluent_Mediator_Path_Separates_Command_Writes_From_Query_Reads() + => Given>>("the fluent CQRS example", () => CqrsPatternExample.RunFluentAsync) + .When("running the command and query flow", run => run(CancellationToken.None)) + .Then("the query read model matches the command write", summary => + summary.Path == "fluent" && summary.QueryMatchedCommand) + .And("the write total is calculated by the command handler", summary => + ScenarioExpect.Equal(39.90m, summary.Total)) + .And("pipeline and event logs were captured", summary => + { + ScenarioExpect.Contains(summary.Log, entry => entry.StartsWith("pre:CreateCqrsOrder", StringComparison.Ordinal)); + ScenarioExpect.Contains(summary.Log, entry => entry.StartsWith("event:order-created:", StringComparison.Ordinal)); + ScenarioExpect.Contains(summary.Log, entry => entry.StartsWith("post:GetCqrsOrder", StringComparison.Ordinal)); + }) + .AssertPassed(); + + [Scenario("Source-generated dispatcher path separates command writes from query reads")] + [Fact] + public Task Source_Generated_Dispatcher_Path_Separates_Command_Writes_From_Query_Reads() + => Given("service provider configured for the source-generated CQRS example", () => + { + var services = new ServiceCollection(); + services.AddSourceGeneratedCqrsServices(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("running the generated command and query flow", async ValueTask (provider) => + { + using (provider) + return await CqrsPatternExample.RunSourceGeneratedAsync(provider, CancellationToken.None); + }) + .Then("the query read model matches the command write", summary => + summary.Path == "source-generated" && summary.QueryMatchedCommand) + .And("the write total is calculated by the generated command handler", summary => + ScenarioExpect.Equal(100m, summary.Total)) + .And("command and notification handlers logged operations", summary => + { + ScenarioExpect.Contains(summary.Log, entry => entry.Contains("Creating customer", StringComparison.Ordinal)); + ScenarioExpect.Contains(summary.Log, entry => entry.Contains("Placing order", StringComparison.Ordinal)); + ScenarioExpect.Contains(summary.Log, entry => entry.Contains("Sending order confirmation", StringComparison.Ordinal)); + }) + .AssertPassed(); + + [Scenario("CQRS example is importable through IServiceCollection")] + [Fact] + public Task Cqrs_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection using the PatternKit CQRS extension", () => + { + var services = new ServiceCollection(); + services.AddCqrsDispatcherExample(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running both CQRS paths", async ValueTask (provider) => + { + using (provider) + { + var example = provider.GetRequiredService(); + var fluent = await example.RunFluentAsync(CancellationToken.None); + var generated = await example.RunSourceGeneratedAsync(provider, CancellationToken.None); + var descriptor = provider.GetServices() + .Single(descriptor => descriptor.ExampleName == "CQRS Dispatcher"); + + return new CqrsExampleRun(fluent, generated, descriptor.Integration); + } + }) + .Then("both entry points produce matched command and query results", result => + result.Fluent.QueryMatchedCommand && result.Generated.QueryMatchedCommand) + .And("the service descriptor advertises DI and source generation", result => + result.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection) + && result.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator)) + .AssertPassed(); + + private sealed record CqrsExampleRun( + CqrsSummary Fluent, + CqrsSummary Generated, + ExampleIntegrationSurface Integration); +}