From 7d53bd549f9182d7c679c694c0af09445bc8ef77 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 21 May 2026 01:27:34 -0500 Subject: [PATCH] feat: add domain event pattern support --- docs/examples/order-domain-event-pattern.md | 28 +++ docs/examples/toc.yml | 3 + docs/generators/domain-event.md | 34 +++ docs/generators/index.md | 1 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/application/domain-event.md | 32 +++ docs/patterns/toc.yml | 2 + .../DomainEvents/DomainEventDispatcher.cs | 147 +++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 + .../DomainEventDemo/OrderDomainEventDemo.cs | 148 +++++++++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../DomainEvents/DomainEventAttributes.cs | 32 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../DomainEventDispatcherGenerator.cs | 199 ++++++++++++++++++ ...tternKitExampleDependencyInjectionTests.cs | 3 + .../OrderDomainEventDemoTests.cs | 55 +++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 16 ++ .../DomainEventDispatcherGeneratorTests.cs | 84 ++++++++ .../DomainEventDispatcherTests.cs | 98 +++++++++ 22 files changed, 923 insertions(+), 1 deletion(-) create mode 100644 docs/examples/order-domain-event-pattern.md create mode 100644 docs/generators/domain-event.md create mode 100644 docs/patterns/application/domain-event.md create mode 100644 src/PatternKit.Core/Application/DomainEvents/DomainEventDispatcher.cs create mode 100644 src/PatternKit.Examples/DomainEventDemo/OrderDomainEventDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/DomainEvents/DomainEventAttributes.cs create mode 100644 src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/DomainEvents/DomainEventDispatcherTests.cs diff --git a/docs/examples/order-domain-event-pattern.md b/docs/examples/order-domain-event-pattern.md new file mode 100644 index 0000000..020bf29 --- /dev/null +++ b/docs/examples/order-domain-event-pattern.md @@ -0,0 +1,28 @@ +# Order Domain Event Pattern + +This production-shaped example shows order events dispatched to projection and audit handlers. + +It demonstrates: + +- fluent `DomainEventDispatcher` construction +- generated dispatcher factory with `[GenerateDomainEventDispatcher]` +- multiple ordered handlers for the same domain event +- scoped `IDomainEventDispatcher` registration through `IServiceCollection` + +```csharp +var services = new ServiceCollection(); +services.AddOrderDomainEventDemo(); + +using var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); + +var workflow = scope.ServiceProvider.GetRequiredService(); +var summary = await workflow.PlaceAsync("order-100", "customer-1", 125m); +``` + +The registered dispatcher is scoped so importing applications can safely compose event handlers with projections, audit stores, unit-of-work state, database sessions, tenant services, or ASP.NET Core request services. + +Files: + +- `src/PatternKit.Examples/DomainEventDemo/OrderDomainEventDemo.cs` +- `test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index af88f19..e337414 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -97,6 +97,9 @@ - name: Customer Service Layer Pattern href: customer-service-layer-pattern.md +- name: Order Domain Event Pattern + href: order-domain-event-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/domain-event.md b/docs/generators/domain-event.md new file mode 100644 index 0000000..0d09003 --- /dev/null +++ b/docs/generators/domain-event.md @@ -0,0 +1,34 @@ +# Domain Event Generator + +`GenerateDomainEventDispatcherAttribute` creates a typed `DomainEventDispatcher` factory from attributed handler methods. + +```csharp +[GenerateDomainEventDispatcher(typeof(OrderDomainEvent), FactoryName = "CreateDispatcher", DispatcherName = "order-domain-events")] +public static partial class GeneratedOrderDomainEvents +{ + [DomainEventHandler(typeof(OrderPlaced), 10)] + private static ValueTask Project(OrderPlaced domainEvent, CancellationToken cancellationToken) + { + projection.Apply(domainEvent); + return ValueTask.CompletedTask; + } +} +``` + +The generated factory is equivalent to: + +```csharp +DomainEventDispatcher + .Create("order-domain-events") + .Handle(Project) + .Build(); +``` + +Handlers are grouped by event type and ordered by the `order` argument on `[DomainEventHandler]`. + +Diagnostics: + +- `PKDE001`: host type must be partial. +- `PKDE002`: at least one `[DomainEventHandler]` method is required. +- `PKDE003`: handler must be static and return `ValueTask` from `(TEvent, CancellationToken)`, and the event type must derive from the dispatcher base event type. +- `PKDE004`: handler order values must be unique per event type. diff --git a/docs/generators/index.md b/docs/generators/index.md index 4d69277..5596e03 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -66,6 +66,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | | [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` | | [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` | +| [**Domain Event**](domain-event.md) | Domain event dispatcher factories | `[GenerateDomainEventDispatcher]` | | [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` | | [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 8b6d32e..b31da79 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -40,6 +40,9 @@ - name: Data Mapper href: data-mapper.md +- name: Domain Event + href: domain-event.md + - name: Dispatcher href: dispatcher.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index a0d5e3c..3ee6fb7 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -73,6 +73,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Identity Map | `IdentityMap` | Identity Map generator | | Application Architecture | Transaction Script | `TransactionScript` | Transaction Script generator | | Application Architecture | Service Layer | `IServiceOperation` and `ServiceLayerOperation` | Service Layer generator | +| Application Architecture | Domain Event | `IDomainEvent` and `DomainEventDispatcher` | Domain Event generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/domain-event.md b/docs/patterns/application/domain-event.md new file mode 100644 index 0000000..2560939 --- /dev/null +++ b/docs/patterns/application/domain-event.md @@ -0,0 +1,32 @@ +# Domain Event + +Domain Event models facts that already happened inside a domain or application workflow. Use it to decouple aggregate decisions from projections, audit trails, notifications, and integration handoff logic. + +PatternKit provides `IDomainEvent`, `IDomainEventDispatcher`, and `DomainEventDispatcher` in `PatternKit.Application.DomainEvents`. + +```csharp +var dispatcher = DomainEventDispatcher + .Create("order-domain-events") + .Handle((domainEvent, ct) => + { + projection.Apply(domainEvent); + return ValueTask.CompletedTask; + }) + .Handle((domainEvent, ct) => + { + audit.Add($"placed:{domainEvent.OrderId}"); + return ValueTask.CompletedTask; + }) + .Build(); + +var result = await dispatcher.DispatchAsync(new OrderPlaced(id, now, "order-100", "customer-1", 125m)); +``` + +The dispatcher returns `DomainEventDispatchResult` so callers can distinguish handled, unhandled, and failed dispatch. Register the dispatcher as scoped when handlers depend on scoped projections, unit-of-work state, tenant context, or request services. + +Use the source-generated path when event handlers are stable application code and you want compiler diagnostics for missing partial hosts, invalid handler signatures, or duplicated handler order. + +See also: + +- [Domain Event generator](../../generators/domain-event.md) +- [Order Domain Event example](../../examples/order-domain-event-pattern.md) diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index b449a3a..ac0f228 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -345,6 +345,8 @@ href: application/transaction-script.md - name: Service Layer href: application/service-layer.md + - name: Domain Event + href: application/domain-event.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/DomainEvents/DomainEventDispatcher.cs b/src/PatternKit.Core/Application/DomainEvents/DomainEventDispatcher.cs new file mode 100644 index 0000000..c7db772 --- /dev/null +++ b/src/PatternKit.Core/Application/DomainEvents/DomainEventDispatcher.cs @@ -0,0 +1,147 @@ +namespace PatternKit.Application.DomainEvents; + +/// Base contract for domain events emitted by aggregates and application workflows. +public interface IDomainEvent +{ + Guid EventId { get; } + + DateTimeOffset OccurredAt { get; } +} + +/// Dispatches domain events to registered handlers. +public interface IDomainEventDispatcher +{ + string Name { get; } + + ValueTask DispatchAsync(TEventBase domainEvent, CancellationToken cancellationToken = default); +} + +/// Typed in-process domain event dispatcher. +public sealed class DomainEventDispatcher : IDomainEventDispatcher +{ + private readonly IReadOnlyDictionary>> _handlers; + + private DomainEventDispatcher( + string name, + IReadOnlyDictionary>> handlers) + { + Name = name; + _handlers = handlers; + } + + public string Name { get; } + + public static Builder Create(string name) + => new(name); + + public async ValueTask DispatchAsync(TEventBase domainEvent, CancellationToken cancellationToken = default) + { + if (domainEvent is null) + throw new ArgumentNullException(nameof(domainEvent)); + + cancellationToken.ThrowIfCancellationRequested(); + var eventType = domainEvent.GetType(); + if (!_handlers.TryGetValue(eventType, out var handlers) || handlers.Count == 0) + return DomainEventDispatchResult.Unhandled(eventType); + + try + { + foreach (var handler in handlers) + await handler(domainEvent, cancellationToken).ConfigureAwait(false); + + return DomainEventDispatchResult.Handled(eventType, handlers.Count); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return DomainEventDispatchResult.Failed(eventType, ex); + } + } + + public sealed class Builder + { + private readonly string _name; + private readonly Dictionary>> _handlers = new(); + + internal Builder(string name) + { + _name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Domain Event dispatcher name is required.", nameof(name)) + : name; + } + + public Builder Handle(Func handler) + where TEvent : TEventBase + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + var eventType = typeof(TEvent); + if (!_handlers.TryGetValue(eventType, out var handlers)) + { + handlers = new List>(); + _handlers[eventType] = handlers; + } + + handlers.Add((domainEvent, cancellationToken) => + { + if (domainEvent is not TEvent typedEvent) + throw new InvalidOperationException($"Domain event '{domainEvent?.GetType().FullName}' is not assignable to handler event type '{typeof(TEvent).FullName}'."); + + return handler(typedEvent, cancellationToken); + }); + return this; + } + + public DomainEventDispatcher Build() + { + var handlers = _handlers.ToDictionary( + static pair => pair.Key, + static pair => (IReadOnlyList>)pair.Value.ToArray()); + return new(_name, handlers); + } + } +} + +/// Result returned after dispatching one domain event. +public sealed class DomainEventDispatchResult +{ + private DomainEventDispatchResult(Type eventType, DomainEventDispatchStatus status, int handlerCount, Exception? exception) + { + EventType = eventType ?? throw new ArgumentNullException(nameof(eventType)); + Status = status; + HandlerCount = handlerCount; + Exception = exception; + } + + public Type EventType { get; } + + public DomainEventDispatchStatus Status { get; } + + public int HandlerCount { get; } + + public Exception? Exception { get; } + + public bool Succeeded => Status == DomainEventDispatchStatus.Handled; + + public static DomainEventDispatchResult Handled(Type eventType, int handlerCount) + { + if (handlerCount <= 0) + throw new ArgumentOutOfRangeException(nameof(handlerCount)); + + return new(eventType, DomainEventDispatchStatus.Handled, handlerCount, null); + } + + public static DomainEventDispatchResult Unhandled(Type eventType) + => new(eventType, DomainEventDispatchStatus.Unhandled, 0, null); + + public static DomainEventDispatchResult Failed(Type eventType, Exception exception) + => new(eventType, DomainEventDispatchStatus.Failed, 0, exception ?? throw new ArgumentNullException(nameof(exception))); +} + +/// Dispatch status for one domain event. +public enum DomainEventDispatchStatus +{ + Handled, + Unhandled, + Failed +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 0616434..e25af5e 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ using PatternKit.Examples.Chain.ConfigDriven; using PatternKit.Examples.CircuitBreakerDemo; using PatternKit.Examples.DataMapperDemo; +using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.EnterpriseFeatureSlices; using PatternKit.Examples.FlyweightDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; @@ -132,6 +133,7 @@ public sealed record OrderDataMapperPatternExample(OrderDataMapperDemoRunner Run public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner Runner); public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner Runner); public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner); +public sealed record OrderDomainEventPatternExample(OrderDomainEventDemoRunner Runner); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -194,6 +196,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderIdentityMapPatternExample() .AddOrderTransactionScriptPatternExample() .AddCustomerServiceLayerPatternExample() + .AddOrderDomainEventPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -574,6 +577,13 @@ public static IServiceCollection AddCustomerServiceLayerPatternExample(this ISer return services.RegisterExample("Customer Service Layer Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderDomainEventPatternExample(this IServiceCollection services) + { + services.AddOrderDomainEventDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Order Domain Event Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) { services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); diff --git a/src/PatternKit.Examples/DomainEventDemo/OrderDomainEventDemo.cs b/src/PatternKit.Examples/DomainEventDemo/OrderDomainEventDemo.cs new file mode 100644 index 0000000..b76ad02 --- /dev/null +++ b/src/PatternKit.Examples/DomainEventDemo/OrderDomainEventDemo.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.DomainEvents; +using PatternKit.Generators.DomainEvents; + +namespace PatternKit.Examples.DomainEventDemo; + +public static class OrderDomainEventDemo +{ + public static async ValueTask RunFluentAsync() + { + var projection = new OrderEventProjection(); + var audit = new List(); + var dispatcher = OrderDomainEventPolicies.CreateFluentDispatcher(projection, audit); + var placed = new OrderPlaced(Guid.NewGuid(), DateTimeOffset.UtcNow, "order-100", "customer-1", 125m); + var result = await dispatcher.DispatchAsync(placed); + return new(result.Succeeded, projection.OrderIds.ToArray(), audit.ToArray()); + } + + public static async ValueTask RunGeneratedAsync() + { + GeneratedOrderDomainEvents.Projection = new OrderEventProjection(); + GeneratedOrderDomainEvents.Audit = new List(); + var placed = new OrderPlaced(Guid.NewGuid(), DateTimeOffset.UtcNow, "order-200", "customer-2", 75m); + var result = await GeneratedOrderDomainEvents.CreateDispatcher().DispatchAsync(placed); + return new(result.Succeeded, GeneratedOrderDomainEvents.Projection.OrderIds.ToArray(), GeneratedOrderDomainEvents.Audit.ToArray()); + } +} + +public abstract record OrderDomainEvent(Guid EventId, DateTimeOffset OccurredAt) : IDomainEvent; + +public sealed record OrderPlaced(Guid EventId, DateTimeOffset OccurredAt, string OrderId, string CustomerId, decimal Total) + : OrderDomainEvent(EventId, OccurredAt); + +public sealed record OrderBilled(Guid EventId, DateTimeOffset OccurredAt, string OrderId, decimal Total) + : OrderDomainEvent(EventId, OccurredAt); + +public sealed record OrderDomainEventSummary(bool Dispatched, IReadOnlyList ProjectedOrderIds, IReadOnlyList AuditEntries); + +public sealed class OrderEventProjection +{ + private readonly List _orderIds = new(); + + public IReadOnlyList OrderIds => _orderIds; + + public void Apply(OrderPlaced domainEvent) + => _orderIds.Add(domainEvent.OrderId); +} + +public static class OrderDomainEventPolicies +{ + public static DomainEventDispatcher CreateFluentDispatcher(OrderEventProjection projection, List audit) + { + if (projection is null) + throw new ArgumentNullException(nameof(projection)); + if (audit is null) + throw new ArgumentNullException(nameof(audit)); + + return DomainEventDispatcher.Create("order-domain-events") + .Handle((domainEvent, _) => + { + projection.Apply(domainEvent); + return ValueTask.CompletedTask; + }) + .Handle((domainEvent, _) => + { + audit.Add($"placed:{domainEvent.OrderId}:{domainEvent.Total}"); + return ValueTask.CompletedTask; + }) + .Handle((domainEvent, _) => + { + audit.Add($"billed:{domainEvent.OrderId}:{domainEvent.Total}"); + return ValueTask.CompletedTask; + }) + .Build(); + } +} + +public sealed class OrderDomainEventWorkflow +{ + private readonly IDomainEventDispatcher _dispatcher; + private readonly OrderEventProjection _projection; + private readonly List _audit; + + public OrderDomainEventWorkflow(IDomainEventDispatcher dispatcher, OrderEventProjection projection, List audit) + { + _dispatcher = dispatcher; + _projection = projection; + _audit = audit; + } + + public async ValueTask PlaceAsync(string orderId, string customerId, decimal total, CancellationToken cancellationToken = default) + { + var placed = new OrderPlaced(Guid.NewGuid(), DateTimeOffset.UtcNow, orderId, customerId, total); + var result = await _dispatcher.DispatchAsync(placed, cancellationToken).ConfigureAwait(false); + return new(result.Succeeded, _projection.OrderIds.ToArray(), _audit.ToArray()); + } +} + +public sealed record OrderDomainEventDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderDomainEventServiceCollectionExtensions +{ + public static IServiceCollection AddOrderDomainEventDemo(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped>(); + services.AddScoped>(sp => + OrderDomainEventPolicies.CreateFluentDispatcher( + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddScoped(); + services.AddSingleton(new OrderDomainEventDemoRunner( + OrderDomainEventDemo.RunFluentAsync, + OrderDomainEventDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateDomainEventDispatcher(typeof(OrderDomainEvent), FactoryName = "CreateDispatcher", DispatcherName = "order-domain-events")] +public static partial class GeneratedOrderDomainEvents +{ + public static OrderEventProjection Projection { get; set; } = new(); + + public static List Audit { get; set; } = new(); + + [DomainEventHandler(typeof(OrderPlaced), 10)] + private static ValueTask Project(OrderPlaced domainEvent, CancellationToken cancellationToken) + { + Projection.Apply(domainEvent); + return ValueTask.CompletedTask; + } + + [DomainEventHandler(typeof(OrderPlaced), 20)] + private static ValueTask AuditPlaced(OrderPlaced domainEvent, CancellationToken cancellationToken) + { + Audit.Add($"placed:{domainEvent.OrderId}:{domainEvent.Total}"); + return ValueTask.CompletedTask; + } + + [DomainEventHandler(typeof(OrderBilled), 30)] + private static ValueTask AuditBilled(OrderBilled domainEvent, CancellationToken cancellationToken) + { + Audit.Add($"billed:{domainEvent.OrderId}:{domainEvent.Total}"); + return ValueTask.CompletedTask; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 427d0a4..4b2f93d 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -344,6 +344,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["ServiceLayer"], ["application operation boundary", "source-generated operation factory", "DI composition"]), + Descriptor( + "Order Domain Event Pattern", + "src/PatternKit.Examples/DomainEventDemo/OrderDomainEventDemo.cs", + "test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs", + "docs/examples/order-domain-event-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["DomainEvent"], + ["aggregate event dispatch", "source-generated dispatcher factory", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 546dd88..66a9a70 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -739,6 +739,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/ServiceLayerDemo/CustomerServiceLayerDemoTests.cs", ["fluent application service boundary", "generated operation factory", "DI-importable workflow"]), + Pattern("Domain Event", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/domain-event.md", + "src/PatternKit.Core/Application/DomainEvents/DomainEventDispatcher.cs", + "test/PatternKit.Tests/Application/DomainEvents/DomainEventDispatcherTests.cs", + "docs/generators/domain-event.md", + "src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs", + "test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs", + null, + "docs/examples/order-domain-event-pattern.md", + "src/PatternKit.Examples/DomainEventDemo/OrderDomainEventDemo.cs", + "test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs", + ["fluent domain event dispatcher", "generated handler registry", "DI-importable projection and audit workflow"]), + Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture, "docs/patterns/application/anti-corruption-layer.md", "src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs", diff --git a/src/PatternKit.Generators.Abstractions/DomainEvents/DomainEventAttributes.cs b/src/PatternKit.Generators.Abstractions/DomainEvents/DomainEventAttributes.cs new file mode 100644 index 0000000..76056b2 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/DomainEvents/DomainEventAttributes.cs @@ -0,0 +1,32 @@ +namespace PatternKit.Generators.DomainEvents; + +/// Generates a Domain Event dispatcher factory from attributed handler methods. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateDomainEventDispatcherAttribute : Attribute +{ + public GenerateDomainEventDispatcherAttribute(Type eventBaseType) + { + EventBaseType = eventBaseType ?? throw new ArgumentNullException(nameof(eventBaseType)); + } + + public Type EventBaseType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string DispatcherName { get; set; } = ""; +} + +/// Marks a handler method for a generated Domain Event dispatcher. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class DomainEventHandlerAttribute : Attribute +{ + public DomainEventHandlerAttribute(Type eventType, int order) + { + EventType = eventType ?? throw new ArgumentNullException(nameof(eventType)); + Order = order; + } + + public Type EventType { get; } + + public int Order { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 970a0a6..f5ce2fb 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -94,6 +94,10 @@ PKDEC006 | PatternKit.Generators.Decorator | Error | Nested types are not suppor PKMAP001 | PatternKit.Generators.DataMapping | Error | Data Mapper host must be partial. PKMAP002 | PatternKit.Generators.DataMapping | Error | Data Mapper must declare exactly one projection in each direction. PKMAP003 | PatternKit.Generators.DataMapping | Error | Data Mapper projection signature is invalid. +PKDE001 | PatternKit.Generators.DomainEvents | Error | Domain Event dispatcher host must be partial. +PKDE002 | PatternKit.Generators.DomainEvents | Error | Domain Event dispatcher must declare at least one handler. +PKDE003 | PatternKit.Generators.DomainEvents | Error | Domain Event handler signature is invalid. +PKDE004 | PatternKit.Generators.DomainEvents | Error | Domain Event handler order is duplicated. PKPRO001 | PatternKit.Generators.Prototype | Error | Type marked with [Prototype] must be partial PKPRO002 | PatternKit.Generators.Prototype | Error | Cannot construct clone target (no supported clone construction path) PKPRO003 | PatternKit.Generators.Prototype | Warning | Unsafe reference capture (mutable reference types) diff --git a/src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs b/src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs new file mode 100644 index 0000000..db1112e --- /dev/null +++ b/src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs @@ -0,0 +1,199 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.DomainEvents; + +[Generator] +public sealed class DomainEventDispatcherGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.DomainEvents.GenerateDomainEventDispatcherAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.DomainEvents.DomainEventHandlerAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKDE001", "Domain Event host must be partial", + "Type '{0}' is marked with [GenerateDomainEventDispatcher] but is not declared as partial", + "PatternKit.Generators.DomainEvents", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingHandler = new( + "PKDE002", "Domain Event handlers are missing", + "Domain Event dispatcher '{0}' must declare at least one [DomainEventHandler] method", + "PatternKit.Generators.DomainEvents", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidHandler = new( + "PKDE003", "Domain Event handler signature is invalid", + "Domain Event handler '{0}' must be static and return ValueTask from event type and CancellationToken parameters", + "PatternKit.Generators.DomainEvents", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateOrder = new( + "PKDE004", "Domain Event handler order is duplicated", + "Domain Event handler order values must be unique per event type", + "PatternKit.Generators.DomainEvents", 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 eventBaseType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (eventBaseType is null) + return; + + var handlers = GetHandlers(type); + if (handlers.Count == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + foreach (var group in handlers.GroupBy(static handler => handler.EventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))) + { + if (group.Select(static handler => handler.Order).Distinct().Count() != group.Count()) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateOrder, node.Identifier.GetLocation())); + return; + } + } + + foreach (var handler in handlers) + { + if (!IsHandler(handler.Method, handler.EventType, eventBaseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handler.Method.Locations.FirstOrDefault(), handler.Method.Name)); + return; + } + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var dispatcherName = GetNamedString(attribute, "DispatcherName"); + if (string.IsNullOrWhiteSpace(dispatcherName)) + dispatcherName = type.Name; + + context.AddSource($"{type.Name}.DomainEvents.g.cs", SourceText.From( + GenerateSource(type, eventBaseType, handlers + .OrderBy(static handler => handler.EventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .ThenBy(static handler => handler.Order) + .ToArray(), factoryName, dispatcherName!), + Encoding.UTF8)); + } + + private static List GetHandlers(INamedTypeSymbol type) + { + var handlers = new List(); + foreach (var method in type.GetMembers().OfType()) + { + foreach (var attribute in method.GetAttributes().Where(static attr => attr.AttributeClass?.ToDisplayString() == HandlerAttributeName)) + { + if (attribute.ConstructorArguments[0].Value is INamedTypeSymbol eventType) + handlers.Add(new HandlerConfig(method, eventType, (int)(attribute.ConstructorArguments[1].Value ?? 0))); + } + } + + return handlers; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol eventBaseType, + IReadOnlyList handlers, + string factoryName, + string dispatcherName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var eventBaseName = eventBaseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.DomainEvents.DomainEventDispatcher<") + .Append(eventBaseName).Append("> ").Append(factoryName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Application.DomainEvents.DomainEventDispatcher<") + .Append(eventBaseName).Append(">.Create(\"").Append(Escape(dispatcherName)).AppendLine("\");"); + foreach (var handler in handlers) + { + var eventTypeName = handler.EventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + sb.Append(" builder.Handle<").Append(eventTypeName).Append(">(").Append(handler.Method.Name).AppendLine(");"); + } + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol eventType, INamedTypeSymbol eventBaseType) + => method.IsStatic + && !method.IsGenericMethod + && method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.Tasks.ValueTask" + && method.Parameters.Length == 2 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, eventType) + && InheritsFrom(eventType, eventBaseType) + && method.Parameters[1].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken"; + + private static bool InheritsFrom(ITypeSymbol type, ITypeSymbol baseType) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + return true; + } + + return type.AllInterfaces.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate, baseType)); + } + + 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("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private sealed record HandlerConfig(IMethodSymbol Method, INamedTypeSymbol EventType, int Order); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index b2ea73d..71af81b 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -6,6 +6,7 @@ using PatternKit.Examples.CircuitBreakerDemo; using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.IdentityMapDemo; using PatternKit.Examples.Messaging; using PatternKit.Examples.ObserverDemo; @@ -110,6 +111,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var identityMap = provider.GetRequiredService(); var transactionScript = provider.GetRequiredService(); var serviceLayer = provider.GetRequiredService(); + var domainEvents = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -186,6 +188,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("identity map example reuses loaded orders", identityMap.Runner.RunFluent().ReusedInstance), ("transaction script example submits orders", transactionScript.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Submitted), ("service layer example registers customers", serviceLayer.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Registered), + ("domain event example dispatches order events", domainEvents.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Dispatched), ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available), ("generated circuit breaker isolates fulfillment outages", CircuitBreakerOpens(fulfillmentBreaker.Service)), ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), diff --git a/test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs b/test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs new file mode 100644 index 0000000..0438acc --- /dev/null +++ b/test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DomainEventDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.DomainEventDemo; + +[Feature("Order Domain Event demo")] +public sealed partial class OrderDomainEventDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Order Domain Event demo dispatches events")] + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Order_Domain_Event_Demo_Dispatches_Events(bool sourceGenerated) + => Given("the order domain event demo", () => sourceGenerated) + .When("the selected path runs", (Func>)(async generated => + generated + ? await OrderDomainEventDemo.RunGeneratedAsync() + : await OrderDomainEventDemo.RunFluentAsync())) + .Then("the event updates projection and audit state", summary => + { + ScenarioExpect.True(summary.Dispatched); + ScenarioExpect.Single(summary.ProjectedOrderIds); + ScenarioExpect.Single(summary.AuditEntries); + }) + .AssertPassed(); + + [Scenario("Order Domain Event demo is importable through IServiceCollection")] + [Fact] + public Task Order_Domain_Event_Demo_Is_Importable_Through_IServiceCollection() + => Given("a service provider with the order domain event demo", () => + { + var services = new ServiceCollection(); + services.AddOrderDomainEventDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("a scoped workflow places an order", (Func>)(async provider => + { + using (provider) + using (var scope = provider.CreateScope()) + { + var workflow = scope.ServiceProvider.GetRequiredService(); + return await workflow.PlaceAsync("order-300", "customer-3", 50m); + } + })) + .Then("the imported dispatcher handles the event", summary => + { + ScenarioExpect.True(summary.Dispatched); + ScenarioExpect.Equal("order-300", ScenarioExpect.Single(summary.ProjectedOrderIds)); + ScenarioExpect.Equal("placed:order-300:50", ScenarioExpect.Single(summary.AuditEntries)); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 094700c..794907e 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -67,6 +67,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Identity Map", "Transaction Script", "Service Layer", + "Domain Event", "Anti-Corruption Layer" ]; @@ -111,7 +112,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(13, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(9, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(10, 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 ff81ebd..e836c04 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -9,6 +9,7 @@ using PatternKit.Generators.Composer; using PatternKit.Generators.DataMapping; using PatternKit.Generators.Decorator; +using PatternKit.Generators.DomainEvents; using PatternKit.Generators.Facade; using PatternKit.Generators.Flyweight; using PatternKit.Generators.Factories; @@ -86,6 +87,8 @@ private enum TestTrigger { typeof(GenerateDataMapperAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(DataMapperToDataAttribute), AttributeTargets.Method, false, false }, { typeof(DataMapperToDomainAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateDomainEventDispatcherAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(DomainEventHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateFacadeAttribute), AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, true, false }, { typeof(FacadeExposeAttribute), AttributeTargets.Method, false, false }, { typeof(FacadeMapAttribute), AttributeTargets.Method, false, false }, @@ -1037,6 +1040,12 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() OperationName = "register-customer" }; var serviceLayerRule = new ServiceLayerRuleAttribute("email", "Email is required.", 10); + var domainEvents = new GenerateDomainEventDispatcherAttribute(typeof(string)) + { + FactoryName = "BuildOrderEvents", + DispatcherName = "order-events" + }; + var domainEventHandler = new DomainEventHandlerAttribute(typeof(string), 20); ScenarioExpect.Equal(typeof(TestState), stateMachine.StateType); ScenarioExpect.Equal(typeof(TestTrigger), stateMachine.TriggerType); @@ -1079,6 +1088,11 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal("email", serviceLayerRule.Code); ScenarioExpect.Equal("Email is required.", serviceLayerRule.Message); ScenarioExpect.Equal(10, serviceLayerRule.Order); + ScenarioExpect.Equal(typeof(string), domainEvents.EventBaseType); + ScenarioExpect.Equal("BuildOrderEvents", domainEvents.FactoryName); + ScenarioExpect.Equal("order-events", domainEvents.DispatcherName); + ScenarioExpect.Equal(typeof(string), domainEventHandler.EventType); + ScenarioExpect.Equal(20, domainEventHandler.Order); ScenarioExpect.Throws(() => new UnitOfWorkStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(null!, typeof(int))); ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(typeof(string), null!)); @@ -1086,6 +1100,8 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new GenerateServiceLayerOperationAttribute(typeof(string), null!)); ScenarioExpect.Throws(() => new ServiceLayerRuleAttribute("", "message", 1)); ScenarioExpect.Throws(() => new ServiceLayerRuleAttribute("code", "", 1)); + ScenarioExpect.Throws(() => new GenerateDomainEventDispatcherAttribute(null!)); + ScenarioExpect.Throws(() => new DomainEventHandlerAttribute(null!, 1)); ScenarioExpect.IsType(new TransactionScriptHandlerAttribute()); ScenarioExpect.IsType(new TransactionScriptValidatorAttribute()); ScenarioExpect.IsType(new ServiceLayerHandlerAttribute()); diff --git a/test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs b/test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs new file mode 100644 index 0000000..efec2d9 --- /dev/null +++ b/test/PatternKit.Generators.Tests/DomainEventDispatcherGeneratorTests.cs @@ -0,0 +1,84 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.DomainEvents; +using PatternKit.Generators.DomainEvents; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Domain Event dispatcher generator")] +public sealed partial class DomainEventDispatcherGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits domain event dispatcher factory")] + [Fact] + public Task Generator_Emits_Domain_Event_Dispatcher_Factory() + => Given("a valid domain event dispatcher declaration", () => Compile(""" + using System; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Application.DomainEvents; + using PatternKit.Generators.DomainEvents; + namespace Demo; + public abstract record OrderEvent(Guid EventId, DateTimeOffset OccurredAt) : IDomainEvent; + public sealed record OrderPlaced(Guid EventId, DateTimeOffset OccurredAt, string OrderId) : OrderEvent(EventId, OccurredAt); + [GenerateDomainEventDispatcher(typeof(OrderEvent), FactoryName = "Build", DispatcherName = "order-events")] + public static partial class OrderEventHandlers + { + [DomainEventHandler(typeof(OrderPlaced), 2)] + private static ValueTask Audit(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + [DomainEventHandler(typeof(OrderPlaced), 1)] + private static ValueTask Project(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """)) + .Then("generated source creates ordered handlers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("Create(\"order-events\")", source); + ScenarioExpect.Contains("Handle(Project)", source); + ScenarioExpect.Contains("Handle(Audit)", source); + ScenarioExpect.True(source.IndexOf("Project", StringComparison.Ordinal) < source.IndexOf("Audit", StringComparison.Ordinal)); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid domain event declarations")] + [Theory] + [InlineData("public static class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Handle(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE001")] + [InlineData("public static partial class OrderEventHandlers;", "PKDE002")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static string Handle(OrderPlaced domainEvent) => domainEvent.OrderId; }", "PKDE003")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(NotAnOrderEvent), 1)] private static ValueTask Handle(NotAnOrderEvent domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE003")] + [InlineData("public static partial class OrderEventHandlers { [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask One(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; [DomainEventHandler(typeof(OrderPlaced), 1)] private static ValueTask Two(OrderPlaced domainEvent, CancellationToken cancellationToken) => ValueTask.CompletedTask; }", "PKDE004")] + public Task Generator_Reports_Invalid_Domain_Event_Declarations(string declaration, string diagnosticId) + => Given("an invalid domain event dispatcher declaration", () => Compile($$""" + using System; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Application.DomainEvents; + using PatternKit.Generators.DomainEvents; + public abstract record OrderEvent(Guid EventId, DateTimeOffset OccurredAt) : IDomainEvent; + public sealed record OrderPlaced(Guid EventId, DateTimeOffset OccurredAt, string OrderId) : OrderEvent(EventId, OccurredAt); + public sealed record NotAnOrderEvent(string Id); + [GenerateDomainEventDispatcher(typeof(OrderEvent))] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "DomainEventDispatcherGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(DomainEventDispatcher<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new DomainEventDispatcherGenerator(), out var run, out _); + var result = run.Results.Single(); + return new GeneratorResult( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources); +} diff --git a/test/PatternKit.Tests/Application/DomainEvents/DomainEventDispatcherTests.cs b/test/PatternKit.Tests/Application/DomainEvents/DomainEventDispatcherTests.cs new file mode 100644 index 0000000..f595f09 --- /dev/null +++ b/test/PatternKit.Tests/Application/DomainEvents/DomainEventDispatcherTests.cs @@ -0,0 +1,98 @@ +using PatternKit.Application.DomainEvents; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.DomainEvents; + +[Feature("Domain Event")] +public sealed partial class DomainEventDispatcherTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Domain Event dispatcher invokes typed handlers")] + [Fact] + public Task Domain_Event_Dispatcher_Invokes_Typed_Handlers() + => Given("a dispatcher with two handlers for the same event", () => + { + var handled = new List(); + var dispatcher = DomainEventDispatcher.Create("orders") + .Handle((domainEvent, _) => + { + handled.Add($"projection:{domainEvent.OrderId}"); + return ValueTask.CompletedTask; + }) + .Handle((domainEvent, _) => + { + handled.Add($"audit:{domainEvent.OrderId}"); + return ValueTask.CompletedTask; + }) + .Build(); + return new DispatcherContext(dispatcher, handled); + }) + .When("an order placed event is dispatched", (Func>)(async ctx => + new DispatchedEvent(await ctx.Dispatcher.DispatchAsync(new OrderPlaced(Guid.NewGuid(), DateTimeOffset.UtcNow, "order-100")), ctx.Handled))) + .Then("all matching handlers run", result => + { + ScenarioExpect.Equal(DomainEventDispatchStatus.Handled, result.Result.Status); + ScenarioExpect.Equal(2, result.Result.HandlerCount); + ScenarioExpect.Equal(["projection:order-100", "audit:order-100"], result.Handled); + }) + .AssertPassed(); + + [Scenario("Domain Event dispatcher returns unhandled for unknown event types")] + [Fact] + public Task Domain_Event_Dispatcher_Returns_Unhandled_For_Unknown_Event_Types() + => Given("a dispatcher for placed orders", () => DomainEventDispatcher.Create("orders") + .Handle(static (_, _) => ValueTask.CompletedTask) + .Build()) + .When("a different event type is dispatched", (Func, ValueTask>)(async dispatcher => + await dispatcher.DispatchAsync(new OrderBilled(Guid.NewGuid(), DateTimeOffset.UtcNow, "order-100")))) + .Then("the result is unhandled", result => + { + ScenarioExpect.Equal(DomainEventDispatchStatus.Unhandled, result.Status); + ScenarioExpect.False(result.Succeeded); + ScenarioExpect.Equal(0, result.HandlerCount); + ScenarioExpect.Equal(typeof(OrderBilled), result.EventType); + }) + .AssertPassed(); + + [Scenario("Domain Event dispatcher reports handler failures")] + [Fact] + public Task Domain_Event_Dispatcher_Reports_Handler_Failures() + => Given("a dispatcher with a failing handler", () => DomainEventDispatcher.Create("orders") + .Handle(static (_, _) => throw new InvalidOperationException("projection failed")) + .Build()) + .When("the event is dispatched", (Func, ValueTask>)(async dispatcher => + await dispatcher.DispatchAsync(new OrderPlaced(Guid.NewGuid(), DateTimeOffset.UtcNow, "order-100")))) + .Then("the failure is returned", result => + { + ScenarioExpect.Equal(DomainEventDispatchStatus.Failed, result.Status); + ScenarioExpect.False(result.Succeeded); + ScenarioExpect.IsType(result.Exception); + }) + .AssertPassed(); + + [Scenario("Domain Event dispatcher validates required configuration")] + [Fact] + public Task Domain_Event_Dispatcher_Validates_Required_Configuration() + => Given("domain event dispatcher builders", () => true) + .Then("invalid arguments are rejected", _ => + { + ScenarioExpect.Throws(() => DomainEventDispatcher.Create("")); + ScenarioExpect.Throws(() => DomainEventDispatcher.Create("orders").Handle(null!)); + ScenarioExpect.Throws(() => DomainEventDispatcher.Create("orders").Build().DispatchAsync(null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => DomainEventDispatchResult.Handled(typeof(OrderPlaced), 0)); + ScenarioExpect.Throws(() => DomainEventDispatchResult.Unhandled(null!)); + ScenarioExpect.Throws(() => DomainEventDispatchResult.Failed(typeof(OrderPlaced), null!)); + }) + .AssertPassed(); + + private abstract record OrderDomainEvent(Guid EventId, DateTimeOffset OccurredAt) : IDomainEvent; + + private sealed record OrderPlaced(Guid EventId, DateTimeOffset OccurredAt, string OrderId) : OrderDomainEvent(EventId, OccurredAt); + + private sealed record OrderBilled(Guid EventId, DateTimeOffset OccurredAt, string OrderId) : OrderDomainEvent(EventId, OccurredAt); + + private sealed record DispatcherContext(IDomainEventDispatcher Dispatcher, List Handled); + + private sealed record DispatchedEvent(DomainEventDispatchResult Result, List Handled); +}