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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/examples/order-domain-event-pattern.md
Original file line number Diff line number Diff line change
@@ -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<OrderDomainEvent>` construction
- generated dispatcher factory with `[GenerateDomainEventDispatcher]`
- multiple ordered handlers for the same domain event
- scoped `IDomainEventDispatcher<OrderDomainEvent>` 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<OrderDomainEventWorkflow>();
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`
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions docs/generators/domain-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Domain Event Generator

`GenerateDomainEventDispatcherAttribute` creates a typed `DomainEventDispatcher<TEventBase>` 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<OrderDomainEvent>
.Create("order-domain-events")
.Handle<OrderPlaced>(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.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]` |

Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
- name: Data Mapper
href: data-mapper.md

- name: Domain Event
href: domain-event.md

- name: Dispatcher
href: dispatcher.md

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Application Architecture | Identity Map | `IdentityMap<TEntity,TKey>` | Identity Map generator |
| Application Architecture | Transaction Script | `TransactionScript<TRequest,TResponse>` | Transaction Script generator |
| Application Architecture | Service Layer | `IServiceOperation<TRequest,TResponse>` and `ServiceLayerOperation<TRequest,TResponse>` | Service Layer generator |
| Application Architecture | Domain Event | `IDomainEvent` and `DomainEventDispatcher<TEventBase>` | Domain Event generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
32 changes: 32 additions & 0 deletions docs/patterns/application/domain-event.md
Original file line number Diff line number Diff line change
@@ -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<TEventBase>`, and `DomainEventDispatcher<TEventBase>` in `PatternKit.Application.DomainEvents`.

```csharp
var dispatcher = DomainEventDispatcher<OrderDomainEvent>
.Create("order-domain-events")
.Handle<OrderPlaced>((domainEvent, ct) =>
{
projection.Apply(domainEvent);
return ValueTask.CompletedTask;
})
.Handle<OrderPlaced>((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)
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
namespace PatternKit.Application.DomainEvents;

/// <summary>Base contract for domain events emitted by aggregates and application workflows.</summary>
public interface IDomainEvent
{
Guid EventId { get; }

DateTimeOffset OccurredAt { get; }
}

/// <summary>Dispatches domain events to registered handlers.</summary>
public interface IDomainEventDispatcher<in TEventBase>
{
string Name { get; }

ValueTask<DomainEventDispatchResult> DispatchAsync(TEventBase domainEvent, CancellationToken cancellationToken = default);
}

/// <summary>Typed in-process domain event dispatcher.</summary>
public sealed class DomainEventDispatcher<TEventBase> : IDomainEventDispatcher<TEventBase>
{
private readonly IReadOnlyDictionary<Type, IReadOnlyList<Func<TEventBase, CancellationToken, ValueTask>>> _handlers;

private DomainEventDispatcher(
string name,
IReadOnlyDictionary<Type, IReadOnlyList<Func<TEventBase, CancellationToken, ValueTask>>> handlers)
{
Name = name;
_handlers = handlers;
}

public string Name { get; }

public static Builder Create(string name)
=> new(name);

public async ValueTask<DomainEventDispatchResult> 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);

Comment on lines +37 to +46
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<Type, List<Func<TEventBase, CancellationToken, ValueTask>>> _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<TEvent>(Func<TEvent, CancellationToken, ValueTask> 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<Func<TEventBase, CancellationToken, ValueTask>>();
_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<TEventBase> Build()
{
var handlers = _handlers.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyList<Func<TEventBase, CancellationToken, ValueTask>>)pair.Value.ToArray());
return new(_name, handlers);
}
}
}

/// <summary>Result returned after dispatching one domain event.</summary>
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)));
}

/// <summary>Dispatch status for one domain event.</summary>
public enum DomainEventDispatchStatus
{
Handled,
Unhandled,
Failed
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, PrototypeDemo.PrototypeDemo.GameCharacter> Factory);
public sealed record ProxyPatternDemonstrationsExample(Proxy<int, string> RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy);
public sealed record FlyweightGlyphCacheExample(Func<string, IReadOnlyList<(FlyweightDemo.FlyweightDemo.Glyph Glyph, int X)>> RenderSentence);
Expand Down Expand Up @@ -194,6 +196,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddOrderIdentityMapPatternExample()
.AddOrderTransactionScriptPatternExample()
.AddCustomerServiceLayerPatternExample()
.AddOrderDomainEventPatternExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -574,6 +577,13 @@ public static IServiceCollection AddCustomerServiceLayerPatternExample(this ISer
return services.RegisterExample<CustomerServiceLayerPatternExample>("Customer Service Layer Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddOrderDomainEventPatternExample(this IServiceCollection services)
{
services.AddOrderDomainEventDemo();
services.AddSingleton<OrderDomainEventPatternExample>(sp => new(sp.GetRequiredService<OrderDomainEventDemoRunner>()));
return services.RegisterExample<OrderDomainEventPatternExample>("Order Domain Event Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services)
{
services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory());
Expand Down
Loading
Loading