From e81fead48271cf93bc4367018540b51502a3ce63 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 28 May 2026 13:23:53 -0500 Subject: [PATCH] feat: add aggregate root pattern slice --- README.md | 6 +- .../Application/AggregateRootBenchmarks.cs | 35 +++ docs/examples/order-aggregate-root-pattern.md | 30 +++ docs/examples/toc.yml | 3 + docs/generators/aggregate-root.md | 56 +++++ docs/generators/toc.yml | 3 + docs/guides/benchmark-results.md | 10 +- docs/guides/benchmarks.md | 2 + docs/guides/pattern-coverage.md | 1 + docs/index.md | 4 +- docs/patterns/application/aggregate-root.md | 49 ++++ docs/patterns/toc.yml | 2 + .../Application/Aggregates/AggregateRoot.cs | 107 +++++++++ .../OrderAggregateRootDemo.cs | 123 ++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 + .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../Aggregates/AggregateAttributes.cs | 24 ++ .../AggregateCommandHandlerGenerator.cs | 212 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 5 + .../OrderAggregateRootDemoTests.cs | 55 +++++ .../PatternKitBenchmarkCoverageTests.cs | 2 +- .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 26 +++ .../AggregateCommandHandlerGeneratorTests.cs | 198 ++++++++++++++++ .../Aggregates/AggregateRootTests.cs | 164 ++++++++++++++ 26 files changed, 1142 insertions(+), 9 deletions(-) create mode 100644 benchmarks/PatternKit.Benchmarks/Application/AggregateRootBenchmarks.cs create mode 100644 docs/examples/order-aggregate-root-pattern.md create mode 100644 docs/generators/aggregate-root.md create mode 100644 docs/patterns/application/aggregate-root.md create mode 100644 src/PatternKit.Core/Application/Aggregates/AggregateRoot.cs create mode 100644 src/PatternKit.Examples/AggregateRootDemo/OrderAggregateRootDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Aggregates/AggregateAttributes.cs create mode 100644 src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/AggregateRootDemo/OrderAggregateRootDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/AggregateCommandHandlerGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/Aggregates/AggregateRootTests.cs diff --git a/README.md b/README.md index a226525f..3c76b0c1 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 102 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 103 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 17 | Activity Tracker, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 18 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | @@ -497,6 +497,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Aggregate Root | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregator | Construction | 14.562 ns | 168 B | 15.235 ns | 168 B | Same allocation; fluent was slightly faster in this microbenchmark. | | Aggregator | Execution | 188.000 ns | 1,088 B | 200.564 ns | 1,088 B | Same allocation; fluent was faster for order line aggregation. | | Ambassador | Construction | 55.42 ns | 448 B | 48.03 ns | 360 B | Generated reduced construction time and allocation in this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/AggregateRootBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/AggregateRootBenchmarks.cs new file mode 100644 index 00000000..9aa38480 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/AggregateRootBenchmarks.cs @@ -0,0 +1,35 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.Aggregates; +using PatternKit.Examples.AggregateRootDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "AggregateRoot")] +public class AggregateRootBenchmarks +{ + private readonly AggregateCommandHandler _fluent = + OrderAggregateRootDemo.CreateFluentHandler(); + + private readonly AggregateCommandHandler _generated = + OrderAggregateRootDemo.CreateGeneratedHandler(); + + [Benchmark(Baseline = true, Description = "Fluent: create aggregate command handler")] + [BenchmarkCategory("Fluent", "Construction")] + public AggregateCommandHandler Fluent_CreateHandler() + => OrderAggregateRootDemo.CreateFluentHandler(); + + [Benchmark(Description = "Generated: create aggregate command handler")] + [BenchmarkCategory("Generated", "Construction")] + public AggregateCommandHandler Generated_CreateHandler() + => OrderAggregateRootDemo.CreateGeneratedHandler(); + + [Benchmark(Description = "Fluent: execute aggregate workflow")] + [BenchmarkCategory("Fluent", "Execution")] + public OrderAggregateRootDemo.OrderSummary Fluent_ExecuteWorkflow() + => OrderAggregateRootDemo.ExecuteOrder(_fluent); + + [Benchmark(Description = "Generated: execute aggregate workflow")] + [BenchmarkCategory("Generated", "Execution")] + public OrderAggregateRootDemo.OrderSummary Generated_ExecuteWorkflow() + => OrderAggregateRootDemo.ExecuteOrder(_generated); +} diff --git a/docs/examples/order-aggregate-root-pattern.md b/docs/examples/order-aggregate-root-pattern.md new file mode 100644 index 00000000..6472dbba --- /dev/null +++ b/docs/examples/order-aggregate-root-pattern.md @@ -0,0 +1,30 @@ +# Order Aggregate Root Pattern + +This example demonstrates a production-style Aggregate Root for order placement and payment. It includes a fluent command handler, a source-generated command handler, TinyBDD tests, and an `IServiceCollection` extension. + +## Import + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.AggregateRootDemo; + +var services = new ServiceCollection(); +services.AddOrderAggregateRootDemo(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var service = provider.GetRequiredService(); +``` + +## Run + +```csharp +var summary = service.Run(); +``` + +The service uses the generated `AggregateCommandHandler`. The fluent route uses the same command decision and event application functions, so teams can compare runtime composition with generated factories without changing domain behavior. + +## Production Notes + +- Keep command decision pure: inspect aggregate state and return events. +- Keep event application deterministic: event data should be enough to mutate aggregate state. +- Persist and publish `UncommittedEvents` after the unit of work commits, then call `MarkCommitted()`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 93073d54..9e6fb115 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -166,6 +166,9 @@ - name: Loan Approval Specifications href: loan-approval-specifications.md +- name: Order Aggregate Root Pattern + href: order-aggregate-root-pattern.md + - name: Order Value Object Pattern href: order-value-object-pattern.md diff --git a/docs/generators/aggregate-root.md b/docs/generators/aggregate-root.md new file mode 100644 index 00000000..179b641a --- /dev/null +++ b/docs/generators/aggregate-root.md @@ -0,0 +1,56 @@ +# Aggregate Root Generator + +The Aggregate Root generator turns a decision method and event applier into a typed aggregate command handler factory. + +## Usage + +```csharp +using PatternKit.Generators.Aggregates; + +[GenerateAggregateCommandHandler( + typeof(OrderAggregate), + typeof(OrderCommand), + typeof(IOrderEvent), + HandlerName = "orders")] +public static partial class OrderHandlers +{ + [AggregateDecision] + private static IEnumerable Decide(OrderAggregate aggregate, OrderCommand command) + => OrderDecisions.Decide(aggregate, command); + + [AggregateEventApplier] + private static void Apply(OrderAggregate aggregate, IOrderEvent domainEvent) + => aggregate.Record(domainEvent); +} +``` + +Generated output: + +```csharp +var handler = OrderHandlers.Create(); +var result = handler.Execute(order, command); +``` + +## Method Shape + +Decision methods must be static and return `IEnumerable`: + +```csharp +static IEnumerable Decide(TAggregate aggregate, TCommand command) +``` + +Event appliers must be static void methods: + +```csharp +static void Apply(TAggregate aggregate, TEvent domainEvent) +``` + +## Diagnostics + +| ID | Meaning | +|---|---| +| `PKAGG001` | Host type must be `partial`. | +| `PKAGG002` | Host type must declare exactly one `[AggregateDecision]` method. | +| `PKAGG003` | Host type must declare exactly one `[AggregateEventApplier]` method. | +| `PKAGG004` | Decision method signature is invalid. | +| `PKAGG005` | Event applier method signature is invalid. | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index a2484719..cd87570d 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -13,6 +13,9 @@ - name: Activity Tracker href: activity-tracker.md +- name: Aggregate Root + href: aggregate-root.md + - name: Audit Log href: audit-log.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 05abe951..04a974f7 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -17,6 +17,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Aggregate Root | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregator | Construction | 14.562 ns | 168 B | 15.235 ns | 168 B | Same allocation; fluent was slightly faster in this microbenchmark. | | Aggregator | Execution | 188.000 ns | 1,088 B | 200.564 ns | 1,088 B | Same allocation; fluent was faster for order line aggregation. | | Ambassador | Construction | 55.42 ns | 448 B | 48.03 ns | 360 B | Generated reduced construction time and allocation in this microbenchmark. | @@ -222,11 +224,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 102 catalog patterns and 408 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 103 catalog patterns and 412 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 17 | 68 | +| Application Architecture | 18 | 72 | | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | @@ -234,7 +236,7 @@ The coverage matrix currently publishes 102 catalog patterns and 408 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 97 generator source route results. +The generator matrix currently publishes 98 generator source route results. ## Hosting Integration Matrix Results @@ -255,6 +257,7 @@ The generator matrix currently publishes 97 generator source route results. | Category | Pattern | Fluent construction | Fluent execution | Generated construction | Generated execution | | --- | --- | --- | --- | --- | --- | | Application Architecture | Activity Tracker | Covered | Covered | Covered | Covered | +| Application Architecture | Aggregate Root | Covered | Covered | Covered | Covered | | Application Architecture | Anti-Corruption Layer | Covered | Covered | Covered | Covered | | Application Architecture | Audit Log | Covered | Covered | Covered | Covered | | Application Architecture | CQRS | Covered | Covered | Covered | Covered | @@ -361,6 +364,7 @@ The generator matrix currently publishes 97 generator source route results. | Generator | Source | Matrix result | | --- | --- | --- | | ActivityTrackerGenerator | `src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs` | Covered | +| AggregateCommandHandlerGenerator | `src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs` | Covered | | AdapterGenerator | `src/PatternKit.Generators/Adapter/AdapterGenerator.cs` | Covered | | AmbassadorGenerator | `src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs` | Covered | | AntiCorruptionLayerGenerator | `src/PatternKit.Generators/AntiCorruption/AntiCorruptionLayerGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index 45593627..d21f3f12 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -34,6 +34,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Adapter | Execution | 59.084 ns | 416 B | 20.479 ns | 80 B | Generated adapter execution was faster and allocated less for shipment adaptation. | | Activity Tracker | Construction | 13.09 ns | 152 B | 12.98 ns | 152 B | Same allocation; generated was slightly faster in this microbenchmark. | | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | +| Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Aggregate Root | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregator | Construction | 14.562 ns | 168 B | 15.235 ns | 168 B | Same allocation; fluent was slightly faster in this microbenchmark. | | Aggregator | Execution | 188.000 ns | 1,088 B | 200.564 ns | 1,088 B | Same allocation; fluent was faster for order line aggregation. | | Ambassador | Construction | 55.42 ns | 448 B | 48.03 ns | 360 B | Generated reduced construction time and allocation in this microbenchmark. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 8741fa7e..1b99f117 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -105,6 +105,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Leader Election | `LeaderElection` | Leader Election generator | | Cloud Architecture | Scheduler Agent Supervisor | `SchedulerAgentSupervisor` | Scheduler Agent Supervisor generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | +| Application Architecture | Aggregate Root | `AggregateRoot` and command handlers | Aggregate Root generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | | Application Architecture | Value Object | `ValueObject` and `ValueObjectFactory` | Value Object generator | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | diff --git a/docs/index.md b/docs/index.md index 7eba0e2b..91742b68 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 102 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 103 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 17 | Activity Tracker, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 18 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | diff --git a/docs/patterns/application/aggregate-root.md b/docs/patterns/application/aggregate-root.md new file mode 100644 index 00000000..207ddd50 --- /dev/null +++ b/docs/patterns/application/aggregate-root.md @@ -0,0 +1,49 @@ +# Aggregate Root + +Aggregate Root protects a consistency boundary by deciding which domain events a command may produce and applying those events to the aggregate state. + +Use it when updates must enforce invariants across a cluster of entities, such as order placement and payment, account lifecycle changes, or inventory reservations. + +## Fluent Path + +```csharp +using PatternKit.Application.Aggregates; + +var handler = AggregateCommandHandler.Create( + "order-aggregate", + Decide, + (aggregate, domainEvent) => aggregate.Record(domainEvent)); + +var result = handler.Execute(order, new PayOrder(order.Id)); +``` + +`AggregateRoot` tracks identity, version, and uncommitted events. `AggregateCommandHandler` keeps command decision and event application explicit and testable. + +## Generated Path + +```csharp +using PatternKit.Generators.Aggregates; + +[GenerateAggregateCommandHandler(typeof(OrderAggregate), typeof(OrderCommand), typeof(IOrderEvent))] +public static partial class OrderHandlers +{ + [AggregateDecision] + private static IEnumerable Decide(OrderAggregate aggregate, OrderCommand command) + => OrderDecisions.Decide(aggregate, command); + + [AggregateEventApplier] + private static void Apply(OrderAggregate aggregate, IOrderEvent domainEvent) + => aggregate.Record(domainEvent); +} +``` + +The generator emits a factory returning `AggregateCommandHandler` so application services can inject the generated command path. + +## IoC Usage + +```csharp +services.AddOrderAggregateRootDemo(); +services.AddSingleton(); +``` + +The example in `docs/examples/order-aggregate-root-pattern.md` shows fluent and generated aggregate command handling through standard `IServiceCollection`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 6d51121f..df24db3a 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -411,6 +411,8 @@ href: cloud/scheduler-agent-supervisor.md - name: Application Architecture items: + - name: Aggregate Root + href: application/aggregate-root.md - name: Anti-Corruption Layer href: application/anti-corruption-layer.md - name: Activity Tracker diff --git a/src/PatternKit.Core/Application/Aggregates/AggregateRoot.cs b/src/PatternKit.Core/Application/Aggregates/AggregateRoot.cs new file mode 100644 index 00000000..9d615563 --- /dev/null +++ b/src/PatternKit.Core/Application/Aggregates/AggregateRoot.cs @@ -0,0 +1,107 @@ +namespace PatternKit.Application.Aggregates; + +/// +/// Base class for aggregate roots that protect invariants through command decisions and domain events. +/// +public abstract class AggregateRoot + where TId : notnull +{ + private readonly List _uncommittedEvents = []; + + protected AggregateRoot(TId id) + { + Id = id; + } + + public TId Id { get; } + + public long Version { get; private set; } + + public IReadOnlyList UncommittedEvents => _uncommittedEvents; + + public IReadOnlyList DequeueUncommittedEvents() + { + var events = _uncommittedEvents.ToArray(); + _uncommittedEvents.Clear(); + return events; + } + + public void MarkCommitted() => _uncommittedEvents.Clear(); + + protected void Raise(TEvent domainEvent, Action apply) + { + if (apply is null) + throw new ArgumentNullException(nameof(apply)); + + apply(domainEvent); + _uncommittedEvents.Add(domainEvent); + Version++; + } + + protected void Replay(TEvent domainEvent, Action apply) + { + if (apply is null) + throw new ArgumentNullException(nameof(apply)); + + apply(domainEvent); + Version++; + } +} + +/// +/// Fluent command handler for deciding aggregate events and applying them atomically. +/// +public sealed class AggregateCommandHandler +{ + private readonly Func> _decide; + private readonly Action _apply; + + private AggregateCommandHandler( + string name, + Func> decide, + Action apply) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Handler name is required.", nameof(name)) + : name; + _decide = decide ?? throw new ArgumentNullException(nameof(decide)); + _apply = apply ?? throw new ArgumentNullException(nameof(apply)); + } + + public string Name { get; } + + public static AggregateCommandHandler Create( + string name, + Func> decide, + Action apply) + => new(name, decide, apply); + + public AggregateCommandResult Execute(TAggregate aggregate, TCommand command) + { + if (aggregate is null) + throw new ArgumentNullException(nameof(aggregate)); + if (command is null) + throw new ArgumentNullException(nameof(command)); + + var events = _decide(aggregate, command)?.ToArray() ?? Array.Empty(); + foreach (var domainEvent in events) + _apply(aggregate, domainEvent); + + return new AggregateCommandResult(Name, events); + } +} + +public sealed class AggregateCommandResult +{ + public AggregateCommandResult(string handler, IReadOnlyList events) + { + Handler = handler; + Events = events; + } + + public string Handler { get; } + + public IReadOnlyList Events { get; } + + public bool HasChanges => Events.Count > 0; +} diff --git a/src/PatternKit.Examples/AggregateRootDemo/OrderAggregateRootDemo.cs b/src/PatternKit.Examples/AggregateRootDemo/OrderAggregateRootDemo.cs new file mode 100644 index 00000000..6307df38 --- /dev/null +++ b/src/PatternKit.Examples/AggregateRootDemo/OrderAggregateRootDemo.cs @@ -0,0 +1,123 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.Aggregates; +using PatternKit.Generators.Aggregates; + +namespace PatternKit.Examples.AggregateRootDemo; + +/// +/// Production-style order aggregate root with fluent and source-generated command handlers. +/// +public static class OrderAggregateRootDemo +{ + public abstract record OrderCommand(string OrderId); + + public sealed record PlaceOrder(string OrderId, decimal Total) : OrderCommand(OrderId); + + public sealed record PayOrder(string OrderId) : OrderCommand(OrderId); + + public interface IOrderEvent + { + string OrderId { get; } + } + + public sealed record OrderPlaced(string OrderId, decimal Total) : IOrderEvent; + + public sealed record OrderPaid(string OrderId) : IOrderEvent; + + public sealed class OrderAggregate(string id) : AggregateRoot(id) + { + public decimal Total { get; private set; } + + public bool IsPlaced { get; private set; } + + public bool IsPaid { get; private set; } + + public void Record(IOrderEvent domainEvent) => Raise(domainEvent, Apply); + + public void Apply(IOrderEvent domainEvent) + { + switch (domainEvent) + { + case OrderPlaced placed: + IsPlaced = true; + Total = placed.Total; + break; + case OrderPaid: + IsPaid = true; + break; + } + } + } + + public sealed record OrderSummary(string OrderId, bool IsPlaced, bool IsPaid, decimal Total, long Version, IReadOnlyList Changes); + + public static AggregateCommandHandler CreateFluentHandler() + => AggregateCommandHandler.Create( + "order-aggregate-fluent", + Decide, + static (aggregate, domainEvent) => aggregate.Record(domainEvent)); + + public static AggregateCommandHandler CreateGeneratedHandler() + => GeneratedOrderAggregateHandlers.Create(); + + public static OrderSummary ExecuteOrder(AggregateCommandHandler handler) + { + var aggregate = new OrderAggregate("ORD-100"); + handler.Execute(aggregate, new PlaceOrder("ORD-100", 125m)); + handler.Execute(aggregate, new PayOrder("ORD-100")); + + return new OrderSummary( + aggregate.Id, + aggregate.IsPlaced, + aggregate.IsPaid, + aggregate.Total, + aggregate.Version, + aggregate.UncommittedEvents.ToArray()); + } + + public static IEnumerable Decide(OrderAggregate aggregate, OrderCommand command) + { + switch (command) + { + case PlaceOrder place when !aggregate.IsPlaced && place.Total > 0m: + return [new OrderPlaced(place.OrderId, place.Total)]; + case PayOrder pay when aggregate.IsPlaced && !aggregate.IsPaid: + return [new OrderPaid(pay.OrderId)]; + default: + return []; + } + } + + public static IServiceCollection AddOrderAggregateRootDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => CreateGeneratedHandler()); + services.AddSingleton(); + return services; + } +} + +public sealed class OrderAggregateRootService(AggregateCommandHandler handler) +{ + public OrderAggregateRootDemo.OrderSummary Run() + => OrderAggregateRootDemo.ExecuteOrder(handler); +} + +[GenerateAggregateCommandHandler( + typeof(OrderAggregateRootDemo.OrderAggregate), + typeof(OrderAggregateRootDemo.OrderCommand), + typeof(OrderAggregateRootDemo.IOrderEvent), + HandlerName = "order-aggregate-generated")] +public static partial class GeneratedOrderAggregateHandlers +{ + [AggregateDecision] + private static IEnumerable Decide( + OrderAggregateRootDemo.OrderAggregate aggregate, + OrderAggregateRootDemo.OrderCommand command) + => OrderAggregateRootDemo.Decide(aggregate, command); + + [AggregateEventApplier] + private static void Apply( + OrderAggregateRootDemo.OrderAggregate aggregate, + OrderAggregateRootDemo.IOrderEvent domainEvent) + => aggregate.Record(domainEvent); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 6013cd75..8cf2bd60 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ using PatternKit.Creational.Prototype; using PatternKit.Creational.Singleton; using PatternKit.Examples.ActivityTrackingDemo; +using PatternKit.Examples.AggregateRootDemo; using PatternKit.Examples.AmbassadorDemo; using PatternKit.Examples.AntiCorruptionDemo; using PatternKit.Examples.ApiGateway; @@ -193,6 +194,7 @@ public sealed record GeneratedReliabilityPipelineExample(ReliabilityExampleRunne public sealed record ResilientCheckoutMailboxesExample(Func Run); public sealed record MessagingBackplaneFacadeExample(Func> RunAsync); public sealed record GeneratedInterpreterRulesExample(Interpreter Pricing, Interpreter Eligibility); +public sealed record OrderAggregateRootPatternExample(OrderAggregateRootService Service); public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry Registry, LoanApprovalService Service); public sealed record OrderValueObjectPatternExample(OrderValueObjectService Service); public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow); @@ -303,6 +305,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddResilientCheckoutMailboxesExample() .AddMessagingBackplaneFacadeExample() .AddGeneratedInterpreterRulesExample() + .AddOrderAggregateRootPatternExample() .AddLoanApprovalSpecificationsExample() .AddOrderValueObjectPatternExample() .AddOrderRepositoryPatternExample() @@ -908,6 +911,13 @@ public static IServiceCollection AddGeneratedInterpreterRulesExample(this IServi return services.RegisterExample("Generated Interpreter Rules", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddOrderAggregateRootPatternExample(this IServiceCollection services) + { + services.AddOrderAggregateRootDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Order Aggregate Root Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddLoanApprovalSpecificationsExample(this IServiceCollection services) { services.AddLoanApprovalSpecifications(); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 79af35c8..aa887731 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -520,6 +520,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Specification"], ["composable business rules", "source-generated registry", "DI composition"]), + Descriptor( + "Order Aggregate Root Pattern", + "src/PatternKit.Examples/AggregateRootDemo/OrderAggregateRootDemo.cs", + "test/PatternKit.Examples.Tests/AggregateRootDemo/OrderAggregateRootDemoTests.cs", + "docs/examples/order-aggregate-root-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Aggregate Root"], + ["invariant boundary", "source-generated command handler", "DI composition"]), Descriptor( "Order Value Object Pattern", "src/PatternKit.Examples/ValueObjectDemo/OrderValueObjectDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 88a3cecc..cb175f19 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1181,6 +1181,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs", ["fluent specification composition", "generated specification registry", "DI-importable loan approval example"]), + Pattern("Aggregate Root", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/aggregate-root.md", + "src/PatternKit.Core/Application/Aggregates/AggregateRoot.cs", + "test/PatternKit.Tests/Application/Aggregates/AggregateRootTests.cs", + "docs/generators/aggregate-root.md", + "src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs", + "test/PatternKit.Generators.Tests/AggregateCommandHandlerGeneratorTests.cs", + null, + "docs/examples/order-aggregate-root-pattern.md", + "src/PatternKit.Examples/AggregateRootDemo/OrderAggregateRootDemo.cs", + "test/PatternKit.Examples.Tests/AggregateRootDemo/OrderAggregateRootDemoTests.cs", + ["fluent command decision and event application", "generated aggregate command handler", "DI-importable order aggregate example"]), + Pattern("Value Object", PatternFamily.ApplicationArchitecture, "docs/patterns/application/value-object.md", "src/PatternKit.Core/Application/ValueObjects/ValueObject.cs", diff --git a/src/PatternKit.Generators.Abstractions/Aggregates/AggregateAttributes.cs b/src/PatternKit.Generators.Abstractions/Aggregates/AggregateAttributes.cs new file mode 100644 index 00000000..c2ee5a2c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Aggregates/AggregateAttributes.cs @@ -0,0 +1,24 @@ +namespace PatternKit.Generators.Aggregates; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateAggregateCommandHandlerAttribute( + Type aggregateType, + Type commandType, + Type eventType) : Attribute +{ + public Type AggregateType { get; } = aggregateType ?? throw new ArgumentNullException(nameof(aggregateType)); + + public Type CommandType { get; } = commandType ?? throw new ArgumentNullException(nameof(commandType)); + + public Type EventType { get; } = eventType ?? throw new ArgumentNullException(nameof(eventType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string HandlerName { get; set; } = "generated-aggregate-handler"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AggregateDecisionAttribute : Attribute; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AggregateEventApplierAttribute : Attribute; diff --git a/src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs b/src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs new file mode 100644 index 00000000..35467acb --- /dev/null +++ b/src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs @@ -0,0 +1,212 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.Aggregates; + +[Generator] +public sealed class AggregateCommandHandlerGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.Aggregates.GenerateAggregateCommandHandlerAttribute"; + private const string DecisionAttributeName = "PatternKit.Generators.Aggregates.AggregateDecisionAttribute"; + private const string ApplierAttributeName = "PatternKit.Generators.Aggregates.AggregateEventApplierAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKAGG001", + "Aggregate handler host must be partial", + "Type '{0}' is marked with [GenerateAggregateCommandHandler] but is not declared as partial", + "PatternKit.Generators.Aggregates", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingDecision = new( + "PKAGG002", + "Aggregate handler must declare one decision method", + "Type '{0}' must declare exactly one [AggregateDecision] method", + "PatternKit.Generators.Aggregates", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingApplier = new( + "PKAGG003", + "Aggregate handler must declare one event applier", + "Type '{0}' must declare exactly one [AggregateEventApplier] method", + "PatternKit.Generators.Aggregates", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidDecision = new( + "PKAGG004", + "Aggregate decision signature is invalid", + "Decision method '{0}' must be static, return IEnumerable, and accept TAggregate and TCommand parameters", + "PatternKit.Generators.Aggregates", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidApplier = new( + "PKAGG005", + "Aggregate event applier signature is invalid", + "Event applier method '{0}' must be static, return void, and accept TAggregate and TEvent parameters", + "PatternKit.Generators.Aggregates", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => + a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var aggregateType = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + var commandType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol; + var eventType = attribute.ConstructorArguments[2].Value as INamedTypeSymbol; + if (aggregateType is null || commandType is null || eventType is null) + return; + + var decisions = GetMethods(type, DecisionAttributeName); + var appliers = GetMethods(type, ApplierAttributeName); + if (decisions.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingDecision, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (appliers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingApplier, node.Identifier.GetLocation(), type.Name)); + return; + } + + var decision = decisions[0]; + if (!IsDecision(decision, aggregateType, commandType, eventType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidDecision, decision.Locations.FirstOrDefault(), decision.Name)); + return; + } + + var applier = appliers[0]; + if (!IsApplier(applier, aggregateType, eventType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidApplier, applier.Locations.FirstOrDefault(), applier.Name)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var handlerName = GetNamedString(attribute, "HandlerName") ?? "generated-aggregate-handler"; + context.AddSource($"{type.Name}.AggregateCommandHandler.g.cs", SourceText.From( + GenerateSource(type, aggregateType, commandType, eventType, decision, applier, factoryMethodName, handlerName), + Encoding.UTF8)); + } + + private static ImmutableArray GetMethods(INamedTypeSymbol type, string attributeName) + => type.GetMembers() + .OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToImmutableArray(); + + private static bool IsDecision(IMethodSymbol method, INamedTypeSymbol aggregateType, INamedTypeSymbol commandType, INamedTypeSymbol eventType) + { + if (!method.IsStatic || method.Parameters.Length != 2) + return false; + + if (!SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, aggregateType) + || !SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, commandType)) + return false; + + return IsEnumerableOf(method.ReturnType, eventType); + } + + private static bool IsApplier(IMethodSymbol method, INamedTypeSymbol aggregateType, INamedTypeSymbol eventType) + => method.IsStatic + && method.ReturnsVoid + && method.Parameters.Length == 2 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, aggregateType) + && SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, eventType); + + private static bool IsEnumerableOf(ITypeSymbol type, INamedTypeSymbol eventType) + { + if (type is IArrayTypeSymbol array) + return SymbolEqualityComparer.Default.Equals(array.ElementType, eventType); + + if (type is INamedTypeSymbol named + && named.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T + && SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], eventType)) + return true; + + return type.AllInterfaces.Any(candidate => + candidate.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T + && SymbolEqualityComparer.Default.Equals(candidate.TypeArguments[0], eventType)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol aggregateType, + INamedTypeSymbol commandType, + INamedTypeSymbol eventType, + IMethodSymbol decision, + IMethodSymbol applier, + string factoryMethodName, + string handlerName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.Aggregates.AggregateCommandHandler<") + .Append(aggregateType.ToDisplayString(TypeFormat)).Append(", ") + .Append(commandType.ToDisplayString(TypeFormat)).Append(", ") + .Append(eventType.ToDisplayString(TypeFormat)).Append("> ") + .Append(factoryMethodName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.Aggregates.AggregateCommandHandler<") + .Append(aggregateType.ToDisplayString(TypeFormat)).Append(", ") + .Append(commandType.ToDisplayString(TypeFormat)).Append(", ") + .Append(eventType.ToDisplayString(TypeFormat)).Append(">.Create(\"") + .Append(Escape(handlerName)).Append("\", ") + .Append(decision.Name).Append(", ") + .Append(applier.Name).AppendLine(");"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 6f5ec5b1..8ec3bf9e 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -410,6 +410,11 @@ PKGWY003 | PatternKit.Generators.Messaging | Error | Messaging Gateway handler s PKSVA001 | PatternKit.Generators.Messaging | Error | Service Activator host type must be partial. PKSVA002 | PatternKit.Generators.Messaging | Error | Service Activator must declare exactly one handler. PKSVA003 | PatternKit.Generators.Messaging | Error | Service Activator handler signature is invalid. +PKAGG001 | PatternKit.Generators.Aggregates | Error | Aggregate handler host must be partial. +PKAGG002 | PatternKit.Generators.Aggregates | Error | Aggregate handler must declare one decision method. +PKAGG003 | PatternKit.Generators.Aggregates | Error | Aggregate handler must declare one event applier. +PKAGG004 | PatternKit.Generators.Aggregates | Error | Aggregate decision signature is invalid. +PKAGG005 | PatternKit.Generators.Aggregates | Error | Aggregate event applier signature is invalid. PKVO001 | PatternKit.Generators.ValueObjects | Error | Value Object host must be partial. PKVO002 | PatternKit.Generators.ValueObjects | Error | Value Object host must be a class. PKVO003 | PatternKit.Generators.ValueObjects | Error | Value Object must declare at least one component. diff --git a/test/PatternKit.Examples.Tests/AggregateRootDemo/OrderAggregateRootDemoTests.cs b/test/PatternKit.Examples.Tests/AggregateRootDemo/OrderAggregateRootDemoTests.cs new file mode 100644 index 00000000..0e676971 --- /dev/null +++ b/test/PatternKit.Examples.Tests/AggregateRootDemo/OrderAggregateRootDemoTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.AggregateRootDemo; +using PatternKit.Examples.DependencyInjection; +using TinyBDD; +using static PatternKit.Examples.AggregateRootDemo.OrderAggregateRootDemo; + +namespace PatternKit.Examples.Tests.AggregateRootDemo; + +public sealed class OrderAggregateRootDemoTests +{ + [Scenario("Fluent and generated aggregate handlers produce the same order state")] + [Fact] + public void Fluent_And_Generated_Aggregate_Handlers_Produce_The_Same_Order_State() + { + var fluent = ExecuteOrder(CreateFluentHandler()); + var generated = ExecuteOrder(CreateGeneratedHandler()); + + ScenarioExpect.True(fluent.IsPlaced); + ScenarioExpect.True(fluent.IsPaid); + ScenarioExpect.Equal(fluent.OrderId, generated.OrderId); + ScenarioExpect.Equal(fluent.Total, generated.Total); + ScenarioExpect.Equal(fluent.Version, generated.Version); + ScenarioExpect.Equal(fluent.Changes.Select(static change => change.GetType()).ToArray(), generated.Changes.Select(static change => change.GetType()).ToArray()); + } + + [Scenario("Aggregate root demo integrates with IServiceCollection")] + [Fact] + public void Aggregate_Root_Demo_Integrates_With_IServiceCollection() + { + var services = new ServiceCollection(); + services.AddOrderAggregateRootDemo(); + using var provider = services.BuildServiceProvider(validateScopes: true); + + var service = provider.GetRequiredService(); + var summary = service.Run(); + + ScenarioExpect.True(summary.IsPaid); + ScenarioExpect.Equal(2L, summary.Version); + } + + [Scenario("Aggregate root demo is importable through AddPatternKitExamples")] + [Fact] + public void Aggregate_Root_Demo_Is_Importable_Through_AddPatternKitExamples() + { + using var provider = new ServiceCollection() + .AddPatternKitExamples() + .BuildServiceProvider(validateScopes: true); + + var example = provider.GetRequiredService(); + var summary = example.Service.Run(); + + ScenarioExpect.True(summary.IsPlaced); + ScenarioExpect.True(summary.IsPaid); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index be14f602..7454f34f 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs @@ -107,7 +107,7 @@ public Task Published_Benchmark_Results_Include_Every_Catalog_Pattern() .Then("every catalog pattern appears in the benchmark results matrix", ctx => ScenarioExpect.Empty(ctx.MissingPatterns)) .And("the guide publishes the route result total", ctx => - ScenarioExpect.Contains("408 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("412 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 9e9f9525..a1f0e439 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -100,6 +100,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Leader Election", "Scheduler Agent Supervisor", "CQRS", + "Aggregate Root", "Specification", "Value Object", "Repository", @@ -159,7 +160,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(41, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(18, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 8249405c..1e4d3e7d 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -1,6 +1,7 @@ using PatternKit.Generators; using PatternKit.Generators.ActivityTracking; using PatternKit.Generators.Adapter; +using PatternKit.Generators.Aggregates; using PatternKit.Generators.Ambassador; using PatternKit.Generators.AntiCorruption; using PatternKit.Generators.AuditLog; @@ -79,6 +80,9 @@ private enum TestTrigger { typeof(AntiCorruptionExternalRuleAttribute), AttributeTargets.Method, true, false }, { typeof(AntiCorruptionDomainRuleAttribute), AttributeTargets.Method, true, false }, { typeof(GenerateActivityTrackerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(GenerateAggregateCommandHandlerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(AggregateDecisionAttribute), AttributeTargets.Method, false, false }, + { typeof(AggregateEventApplierAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateAuditLogAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(AuditLogKeySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateAdapterAttribute), AttributeTargets.Class, true, false }, @@ -890,6 +894,28 @@ public void Value_Object_Attributes_Expose_Defaults() ScenarioExpect.IsType(new ValueObjectComponentAttribute()); } + [Scenario("Aggregate Attributes Expose Defaults And Validation")] + [Fact] + public void Aggregate_Attributes_Expose_Defaults_And_Validation() + { + var generator = new GenerateAggregateCommandHandlerAttribute(typeof(string), typeof(int), typeof(Guid)) + { + FactoryMethodName = "Build", + HandlerName = "orders" + }; + + ScenarioExpect.Equal(typeof(string), generator.AggregateType); + ScenarioExpect.Equal(typeof(int), generator.CommandType); + ScenarioExpect.Equal(typeof(Guid), generator.EventType); + ScenarioExpect.Equal("Build", generator.FactoryMethodName); + ScenarioExpect.Equal("orders", generator.HandlerName); + ScenarioExpect.IsType(new AggregateDecisionAttribute()); + ScenarioExpect.IsType(new AggregateEventApplierAttribute()); + ScenarioExpect.Throws(() => new GenerateAggregateCommandHandlerAttribute(null!, typeof(int), typeof(Guid))); + ScenarioExpect.Throws(() => new GenerateAggregateCommandHandlerAttribute(typeof(string), null!, typeof(Guid))); + ScenarioExpect.Throws(() => new GenerateAggregateCommandHandlerAttribute(typeof(string), typeof(int), null!)); + } + [Scenario("Retry Attributes Expose Defaults And Configuration")] [Fact] public void Retry_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/AggregateCommandHandlerGeneratorTests.cs b/test/PatternKit.Generators.Tests/AggregateCommandHandlerGeneratorTests.cs new file mode 100644 index 00000000..023656f8 --- /dev/null +++ b/test/PatternKit.Generators.Tests/AggregateCommandHandlerGeneratorTests.cs @@ -0,0 +1,198 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Aggregates; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class AggregateCommandHandlerGeneratorTests +{ + [Scenario("Generates aggregate command handler from decision and applier")] + [Fact] + public void Generates_Aggregate_Command_Handler_From_Decision_And_Applier() + { + var source = """ + using System.Collections.Generic; + using PatternKit.Generators.Aggregates; + + namespace Demo; + + public sealed class OrderAggregate; + public sealed class PayOrder; + public interface IOrderEvent; + + [GenerateAggregateCommandHandler(typeof(OrderAggregate), typeof(PayOrder), typeof(IOrderEvent), FactoryMethodName = "Build", HandlerName = "pay-order")] + public static partial class OrderHandlers + { + [AggregateDecision] + private static IEnumerable Decide(OrderAggregate aggregate, PayOrder command) => []; + + [AggregateEventApplier] + private static void Apply(OrderAggregate aggregate, IOrderEvent domainEvent) { } + } + """; + + var comp = CreateCompilation(source, nameof(Generates_Aggregate_Command_Handler_From_Decision_And_Applier)); + var gen = new AggregateCommandHandlerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(static result => result.GeneratedSources)); + ScenarioExpect.Equal("OrderHandlers.AggregateCommandHandler.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("AggregateCommandHandler", text); + ScenarioExpect.Contains("\"pay-order\", Decide, Apply", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Generates aggregate command handler from array decision and global struct host")] + [Fact] + public void Generates_Aggregate_Command_Handler_From_Array_Decision_And_Global_Struct_Host() + { + var source = """ + using PatternKit.Generators.Aggregates; + + public sealed class OrderAggregate; + public sealed class PayOrder; + public interface IOrderEvent; + + [GenerateAggregateCommandHandler(typeof(OrderAggregate), typeof(PayOrder), typeof(IOrderEvent))] + public partial struct OrderHandlers + { + [AggregateDecision] + private static IOrderEvent[] Decide(OrderAggregate aggregate, PayOrder command) => []; + + [AggregateEventApplier] + private static void Apply(OrderAggregate aggregate, IOrderEvent domainEvent) { } + } + """; + + var comp = CreateCompilation(source, nameof(Generates_Aggregate_Command_Handler_From_Array_Decision_And_Global_Struct_Host)); + var gen = new AggregateCommandHandlerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(static result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("partial struct OrderHandlers", text); + ScenarioExpect.DoesNotContain("namespace ", text); + ScenarioExpect.Contains("\"generated-aggregate-handler\", Decide, Apply", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Theory] + [InlineData(""" + using PatternKit.Generators.Aggregates; + [GenerateAggregateCommandHandler(typeof(object), typeof(string), typeof(int))] + public static class Host; + """, "PKAGG001")] + [InlineData(""" + using PatternKit.Generators.Aggregates; + [GenerateAggregateCommandHandler(typeof(object), typeof(string), typeof(int))] + public static partial class Host; + """, "PKAGG002")] + [InlineData(""" + using System.Collections.Generic; + using PatternKit.Generators.Aggregates; + [GenerateAggregateCommandHandler(typeof(object), typeof(string), typeof(int))] + public static partial class Host + { + [AggregateDecision] private static IEnumerable Decide(object aggregate, string command) => []; + } + """, "PKAGG003")] + [InlineData(""" + using PatternKit.Generators.Aggregates; + [GenerateAggregateCommandHandler(typeof(object), typeof(string), typeof(int))] + public static partial class Host + { + [AggregateDecision] private static string Decide(object aggregate, string command) => ""; + [AggregateEventApplier] private static void Apply(object aggregate, int domainEvent) { } + } + """, "PKAGG004")] + [InlineData(""" + using System.Collections.Generic; + using PatternKit.Generators.Aggregates; + [GenerateAggregateCommandHandler(typeof(object), typeof(string), typeof(int))] + public static partial class Host + { + [AggregateDecision] private static IEnumerable Decide(object aggregate, string command) => []; + [AggregateEventApplier] private static int Apply(object aggregate, int domainEvent) => 0; + } + """, "PKAGG005")] + public void Reports_Aggregate_Command_Handler_Diagnostics(string source, string expected) + { + var diagnostic = RunAndGetSingleDiagnostic(source, expected); + + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + [Scenario("Reports missing decision when aggregate host has duplicate decisions")] + [Fact] + public void Reports_Missing_Decision_When_Aggregate_Host_Has_Duplicate_Decisions() + { + var source = """ + using System.Collections.Generic; + using PatternKit.Generators.Aggregates; + [GenerateAggregateCommandHandler(typeof(object), typeof(string), typeof(int))] + public static partial class Host + { + [AggregateDecision] private static IEnumerable Decide(object aggregate, string command) => []; + [AggregateDecision] private static IEnumerable DecideAgain(object aggregate, string command) => []; + [AggregateEventApplier] private static void Apply(object aggregate, int domainEvent) { } + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(Reports_Missing_Decision_When_Aggregate_Host_Has_Duplicate_Decisions)); + + ScenarioExpect.Equal("PKAGG002", diagnostic.Id); + } + + [Scenario("Reports missing applier when aggregate host has duplicate appliers")] + [Fact] + public void Reports_Missing_Applier_When_Aggregate_Host_Has_Duplicate_Appliers() + { + var source = """ + using System.Collections.Generic; + using PatternKit.Generators.Aggregates; + [GenerateAggregateCommandHandler(typeof(object), typeof(string), typeof(int))] + public static partial class Host + { + [AggregateDecision] private static IEnumerable Decide(object aggregate, string command) => []; + [AggregateEventApplier] private static void Apply(object aggregate, int domainEvent) { } + [AggregateEventApplier] private static void ApplyAgain(object aggregate, int domainEvent) { } + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(Reports_Missing_Applier_When_Aggregate_Host_Has_Duplicate_Appliers)); + + ScenarioExpect.Equal("PKAGG003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(PatternKit.Application.Aggregates.AggregateCommandHandler<,,>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(AggregateCommandHandlerGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new AggregateCommandHandlerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(static result => result.Diagnostics)); + } +} diff --git a/test/PatternKit.Tests/Application/Aggregates/AggregateRootTests.cs b/test/PatternKit.Tests/Application/Aggregates/AggregateRootTests.cs new file mode 100644 index 00000000..daed4347 --- /dev/null +++ b/test/PatternKit.Tests/Application/Aggregates/AggregateRootTests.cs @@ -0,0 +1,164 @@ +using PatternKit.Application.Aggregates; +using TinyBDD; + +namespace PatternKit.Tests.Application.Aggregates; + +public sealed class AggregateRootTests +{ + private interface IOrderEvent; + + private sealed record OrderOpened(string OrderId, decimal Total) : IOrderEvent; + + private sealed record OrderPaid(string OrderId) : IOrderEvent; + + private sealed record PayOrder; + + private sealed class OrderAggregate(string id) : AggregateRoot(id) + { + public bool IsOpen { get; private set; } + + public bool IsPaid { get; private set; } + + public decimal Total { get; private set; } + + public static OrderAggregate Open(string id, decimal total) + { + var aggregate = new OrderAggregate(id); + aggregate.Raise(new OrderOpened(id, total), aggregate.Apply); + return aggregate; + } + + public void Apply(IOrderEvent domainEvent) + { + switch (domainEvent) + { + case OrderOpened opened: + IsOpen = true; + Total = opened.Total; + break; + case OrderPaid: + IsPaid = true; + break; + } + } + + public void Record(IOrderEvent domainEvent) => Raise(domainEvent, Apply); + + public void ReplayCommitted(IOrderEvent domainEvent) => Replay(domainEvent, Apply); + + public void RecordWithNullApply(IOrderEvent domainEvent) => Raise(domainEvent, null!); + + public void ReplayWithNullApply(IOrderEvent domainEvent) => Replay(domainEvent, null!); + } + + [Scenario("Aggregate root tracks uncommitted events and version")] + [Fact] + public void Aggregate_Root_Tracks_Uncommitted_Events_And_Version() + { + var order = OrderAggregate.Open("ORD-100", 25m); + + ScenarioExpect.True(order.IsOpen); + ScenarioExpect.Equal(1L, order.Version); + ScenarioExpect.Single(order.UncommittedEvents); + + var events = order.DequeueUncommittedEvents(); + + ScenarioExpect.Single(events); + ScenarioExpect.Empty(order.UncommittedEvents); + } + + [Scenario("Aggregate root replays committed events without uncommitted changes")] + [Fact] + public void Aggregate_Root_Replays_Committed_Events_Without_Uncommitted_Changes() + { + var order = new OrderAggregate("ORD-100"); + + order.ReplayCommitted(new OrderOpened("ORD-100", 25m)); + + ScenarioExpect.True(order.IsOpen); + ScenarioExpect.Equal(1L, order.Version); + ScenarioExpect.Empty(order.UncommittedEvents); + } + + [Scenario("Aggregate command handler decides and applies events")] + [Fact] + public void Aggregate_Command_Handler_Decides_And_Applies_Events() + { + var order = OrderAggregate.Open("ORD-100", 25m); + order.MarkCommitted(); + var handler = AggregateCommandHandler.Create( + "pay-order", + static (aggregate, _) => aggregate.IsPaid ? [] : [new OrderPaid(aggregate.Id)], + static (aggregate, domainEvent) => aggregate.Record(domainEvent)); + + var result = handler.Execute(order, new PayOrder()); + + ScenarioExpect.True(result.HasChanges); + ScenarioExpect.Equal("pay-order", result.Handler); + ScenarioExpect.True(order.IsPaid); + ScenarioExpect.Single(result.Events); + ScenarioExpect.Single(order.UncommittedEvents); + } + + [Scenario("Aggregate command handler reports no changes")] + [Fact] + public void Aggregate_Command_Handler_Reports_No_Changes() + { + var order = OrderAggregate.Open("ORD-100", 25m); + order.Record(new OrderPaid("ORD-100")); + order.MarkCommitted(); + var handler = AggregateCommandHandler.Create( + "pay-order", + static (aggregate, _) => aggregate.IsPaid ? [] : [new OrderPaid(aggregate.Id)], + static (aggregate, domainEvent) => aggregate.Record(domainEvent)); + + var result = handler.Execute(order, new PayOrder()); + + ScenarioExpect.False(result.HasChanges); + ScenarioExpect.Empty(result.Events); + ScenarioExpect.Empty(order.UncommittedEvents); + } + + [Scenario("Aggregate command handler treats null decisions as no changes")] + [Fact] + public void Aggregate_Command_Handler_Treats_Null_Decisions_As_No_Changes() + { + var order = OrderAggregate.Open("ORD-100", 25m); + order.MarkCommitted(); + var handler = AggregateCommandHandler.Create( + "pay-order", + static (_, _) => null!, + static (aggregate, domainEvent) => aggregate.Record(domainEvent)); + + var result = handler.Execute(order, new PayOrder()); + + ScenarioExpect.False(result.HasChanges); + ScenarioExpect.Empty(result.Events); + } + + [Scenario("Aggregate command handler rejects invalid construction and execution")] + [Fact] + public void Aggregate_Command_Handler_Rejects_Invalid_Construction_And_Execution() + { + var handler = AggregateCommandHandler.Create( + "pay-order", + static (_, _) => [], + static (_, _) => { }); + + ScenarioExpect.Throws(() => AggregateCommandHandler.Create("", static (_, _) => [], static (_, _) => { })); + ScenarioExpect.Throws(() => AggregateCommandHandler.Create("pay", null!, static (_, _) => { })); + ScenarioExpect.Throws(() => AggregateCommandHandler.Create("pay", static (_, _) => [], null!)); + ScenarioExpect.Throws(() => handler.Execute(null!, new PayOrder())); + ScenarioExpect.Throws(() => handler.Execute(OrderAggregate.Open("ORD-100", 25m), null!)); + } + + [Scenario("Aggregate root rejects null apply delegates")] + [Fact] + public void Aggregate_Root_Rejects_Null_Apply_Delegates() + { + var order = new OrderAggregate("ORD-100"); + + ScenarioExpect.Throws(() => order.RecordWithNullApply(new OrderOpened("ORD-100", 25m))); + ScenarioExpect.Throws(() => order.ReplayWithNullApply(new OrderOpened("ORD-100", 25m))); + } +}