Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/examples/cqrs-dispatcher.md
Original file line number Diff line number Diff line change
@@ -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<CqrsDispatcherExample>();
```

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
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Messaging Reliability | Outbox | `InMemoryOutbox<TPayload>` 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public sealed record MessageRouterVisitorExample(Func<RoutingSummary> Run);
public sealed record PatternsShowcaseExample(ShowcaseFacade Facade);
public sealed record SourceGeneratorApplicationSuiteExample(Func<ValueTask<CorporateApp>> BuildProductionAsync);
public sealed record EnterpriseMessagingWorkflowSuiteExample(Func<Summary> Run);
public sealed record CqrsDispatcherExample(Func<CancellationToken, ValueTask<CqrsSummary>> RunFluentAsync, Func<IServiceProvider, CancellationToken, ValueTask<CqrsSummary>> RunSourceGeneratedAsync);
public sealed record ResilientCheckoutMailboxesExample(Func<CheckoutRequest, CheckoutServices, CheckoutResult> Run);
public sealed record MessagingBackplaneFacadeExample(Func<CancellationToken, ValueTask<BackplaneDemoSummary>> RunAsync);
public sealed record PrototypeGameCharacterFactoryExample(Prototype<string, PrototypeDemo.PrototypeDemo.GameCharacter> Factory);
Expand Down Expand Up @@ -125,6 +126,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddPatternsShowcaseExample()
.AddSourceGeneratorApplicationSuiteExample()
.AddEnterpriseMessagingWorkflowSuiteExample()
.AddCqrsDispatcherExample()
.AddResilientCheckoutMailboxesExample()
.AddMessagingBackplaneFacadeExample()
.AddPrototypeGameCharacterFactoryExample()
Expand Down Expand Up @@ -339,6 +341,13 @@ public static IServiceCollection AddEnterpriseMessagingWorkflowSuiteExample(this
return services.RegisterExample<EnterpriseMessagingWorkflowSuiteExample>("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<CqrsDispatcherExample>("CQRS Dispatcher", ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddResilientCheckoutMailboxesExample(this IServiceCollection services)
{
services.AddSingleton<CheckoutServices>();
Expand Down
122 changes: 122 additions & 0 deletions src/PatternKit.Examples/Messaging/CqrsPatternExample.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Production-shaped CQRS example showing fluent and source-generated PatternKit paths side by side.
/// </summary>
public static class CqrsPatternExample
{
public static async ValueTask<CqrsSummary> RunFluentAsync(CancellationToken cancellationToken = default)
{
var log = new List<string>();
var orders = new Dictionary<int, CqrsOrder>();
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<CreateCqrsOrder, CqrsOrder>((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<CqrsOrder>(order);
})
.Command<GetCqrsOrder, CqrsOrder?>((in query, _) =>
{
orders.TryGetValue(query.OrderId, out var order);
return new ValueTask<CqrsOrder?>(order);
})
.Notification<CqrsOrderCreated>((in notification, _) =>
{
log.Add($"event:order-created:{notification.OrderId}");
return ValueTask.CompletedTask;
})
.Build();

var created = await mediator.Send<CreateCqrsOrder, CqrsOrder>(
new CreateCqrsOrder("customer-1", [new CqrsLine("SKU-1", 2, 19.95m)]),
cancellationToken);

await mediator.Publish(new CqrsOrderCreated(created!.Id), cancellationToken);
Comment on lines +51 to +53
var readModel = await mediator.Send<GetCqrsOrder, CqrsOrder?>(new GetCqrsOrder(created.Id), cancellationToken);

return new CqrsSummary(
"fluent",
created.Id,
readModel?.Id == created.Id,
created.Total,
log.ToArray());
}

public static async ValueTask<CqrsSummary> RunSourceGeneratedAsync(
IServiceProvider services,
CancellationToken cancellationToken = default)
{
var dispatcher = services.GetRequiredService<ProductionDispatcher>();
var customers = services.GetRequiredService<ICustomerRepository>();
var orders = services.GetRequiredService<IOrderRepository>();
var logger = services.GetRequiredService<SourceGenerated.ILogger>();

var customer = await dispatcher.Send<CreateCustomerCommand, Customer>(
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<PlaceOrderCommand, Order>(
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<GetOrdersByCustomerQuery, List<Order>>(
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<SourceGenerated.ILogger, InMemoryLogger>();
services.TryAddSingleton<ICustomerRepository, InMemoryCustomerRepository>();
services.TryAddSingleton<IOrderRepository, InMemoryOrderRepository>();
services.TryAddSingleton<IProductRepository, InMemoryProductRepository>();

return services
.AddSourceGeneratedMediator()
.AddHandlersFromAssembly(typeof(CreateCustomerHandler).Assembly);
}
}

public sealed record CreateCqrsOrder(string CustomerId, IReadOnlyList<CqrsLine> 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<CqrsLine> Lines, decimal Total);

public sealed record CqrsSummary(string Path, int OrderId, bool QueryMatchedCommand, decimal Total, IReadOnlyList<string> Log);
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PatternCoverageDescriptor> Patterns => Items;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications()
var asyncTemplate = provider.GetRequiredService<TemplateMethodAsyncExample>();
var routing = provider.GetRequiredService<MessageRouterVisitorExample>();
var envelope = provider.GetRequiredService<EnterpriseMessagingWorkflowSuiteExample>();
var cqrs = provider.GetRequiredService<CqrsDispatcherExample>();
var checkout = provider.GetRequiredService<ResilientCheckoutMailboxesExample>();

auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary<string, string>()));
Expand All @@ -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
Expand All @@ -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)
];
}
Expand Down
Loading
Loading