From 56eb61f11e877ed5ca75e80c9ce2db76a0e2cf2a Mon Sep 17 00:00:00 2001 From: Ninja Date: Sat, 14 Mar 2026 12:19:56 +0000 Subject: [PATCH 1/8] - checkpoint: event interception and configurability --- Ninja.DomainEvents.sln => DomainEvents.sln | 0 GitVersion.yml | 2 +- QWEN.md | 190 ++++++++++++ README.md | 286 ++++++++++++++++-- RELEASE.md | 144 +++++++++ src/DomainEvents/Aggregate.cs | 43 +++ src/DomainEvents/DomainEvents.csproj | 34 ++- src/DomainEvents/IAggregateFactory.cs | 30 ++ src/DomainEvents/IDomainAggregate.cs | 12 + src/DomainEvents/IEventDispatcher.cs | 23 ++ src/DomainEvents/IEventInterceptor.cs | 12 + src/DomainEvents/IHandle.cs | 6 +- src/DomainEvents/IResolver.cs | 2 +- src/DomainEvents/Impl/AggregateFactory.cs | 70 +++++ src/DomainEvents/Impl/EventDispatcher.cs | 161 ++++++++++ src/DomainEvents/Impl/EventInterceptor.cs | 102 +++++++ src/DomainEvents/Impl/Publisher.cs | 6 +- src/DomainEvents/Impl/Resolver.cs | 22 +- .../ServiceCollectionExtensions.cs | 183 +++++++++++ .../Telemetry/DomainEventsActivitySource.cs | 62 ++++ src/DomainEvents/assemblyinfo.cs | 14 +- .../Aggregates/TestAggregates.cs | 50 +++ .../DomainEvents.Tests.csproj | 11 +- .../Handlers/CustomerCreatedHandler.cs | 14 +- .../Handlers/OrderReceivedHandler.cs | 14 +- .../Handlers/SimpleHandlers.cs | 35 +++ .../Run/AggregateFactoryIntegrationTests.cs | 214 +++++++++++++ .../Run/AggregateFactoryTests.cs | 130 ++++++++ test/DomainEvents.Tests/Run/AggregateTests.cs | 105 +++++++ .../Run/CustomDispatcherTests.cs | 166 ++++++++++ .../Run/DependencyInjectionTests.cs | 146 +++++++++ test/DomainEvents.Tests/Run/DomainTests.cs | 28 +- .../Run/EventInterceptorTests.cs | 158 ++++++++++ .../Run/OpenTelemetryTests.cs | 154 ++++++++++ version.json | 2 +- 35 files changed, 2548 insertions(+), 83 deletions(-) rename Ninja.DomainEvents.sln => DomainEvents.sln (100%) create mode 100644 QWEN.md create mode 100644 RELEASE.md create mode 100644 src/DomainEvents/Aggregate.cs create mode 100644 src/DomainEvents/IAggregateFactory.cs create mode 100644 src/DomainEvents/IDomainAggregate.cs create mode 100644 src/DomainEvents/IEventDispatcher.cs create mode 100644 src/DomainEvents/IEventInterceptor.cs create mode 100644 src/DomainEvents/Impl/AggregateFactory.cs create mode 100644 src/DomainEvents/Impl/EventDispatcher.cs create mode 100644 src/DomainEvents/Impl/EventInterceptor.cs create mode 100644 src/DomainEvents/ServiceCollectionExtensions.cs create mode 100644 src/DomainEvents/Telemetry/DomainEventsActivitySource.cs create mode 100644 test/DomainEvents.Tests/Aggregates/TestAggregates.cs create mode 100644 test/DomainEvents.Tests/Handlers/SimpleHandlers.cs create mode 100644 test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs create mode 100644 test/DomainEvents.Tests/Run/AggregateFactoryTests.cs create mode 100644 test/DomainEvents.Tests/Run/AggregateTests.cs create mode 100644 test/DomainEvents.Tests/Run/CustomDispatcherTests.cs create mode 100644 test/DomainEvents.Tests/Run/DependencyInjectionTests.cs create mode 100644 test/DomainEvents.Tests/Run/EventInterceptorTests.cs create mode 100644 test/DomainEvents.Tests/Run/OpenTelemetryTests.cs diff --git a/Ninja.DomainEvents.sln b/DomainEvents.sln similarity index 100% rename from Ninja.DomainEvents.sln rename to DomainEvents.sln diff --git a/GitVersion.yml b/GitVersion.yml index 3e0c313..94e320b 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,4 +1,4 @@ -next-version: 4.0.1 +next-version: 5.0.0 tag-prefix: '[vV]' mode: ContinuousDeployment branches: diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 0000000..e7c1b77 --- /dev/null +++ b/QWEN.md @@ -0,0 +1,190 @@ +# DomainEvents Library - Project Context + +## Project Overview + +**DomainEvents** is a .NET library that facilitates implementing **transactional domain events** within domain-driven design (DDD) bounded contexts. The library provides a pub/sub mechanism for raising and handling domain events to manage side effects across multiple aggregates while maintaining consistency. + +### Purpose + +- Enable explicit implementation of side effects triggered by domain changes +- Support in-process, same-domain event handling +- Ensure transactional consistency (all event-related operations succeed or all fail) +- Provide a clean separation between event publishers and handlers + +### Core Components + +| Component | Description | +|-----------|-------------| +| `IDomainEvent` | Marker interface for defining domain events | +| `IPublisher` | Interface for raising/publishing domain events | +| `IHandler` | Interface for implementing event handlers | +| `IResolver` | Interface for resolving handlers for a given event type | +| `Publisher` | Concrete implementation of `IPublisher` | +| `Resolver` | Concrete implementation of `IResolver` | + +### Architecture + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Publisher │────▶│ Resolver │────▶│ IHandler[] │ +│ (IPublisher)│ │ (IResolver) │ │ (Event Handlers)│ +└─────────────┘ └──────────────┘ └─────────────────┘ +``` + +## Building and Running + +### Prerequisites + +- .NET SDK 9.0 or later (for building/testing) +- The library targets multiple frameworks: `net462`, `netstandard2.0`, `netstandard2.1`, `net9.0`, `net10.0` + +### Build Commands + +```bash +# Restore dependencies +dotnet restore + +# Build the library (Release configuration) +dotnet build --configuration Release + +# Run tests +dotnet test --configuration Release --verbosity normal + +# Build with specific version +dotnet build --configuration Release -p:PackageVersion=4.1.0 +``` + +### Project Structure + +``` +DomainEvents/ +├── src/ +│ └── DomainEvents/ # Main library source +│ ├── IDomainEvent.cs # Event marker interface +│ ├── IHandle.cs # Handler interface +│ ├── IPublisher.cs # Publisher interface +│ ├── IResolver.cs # Resolver interface +│ └── Impl/ +│ ├── Publisher.cs # Publisher implementation +│ └── Resolver.cs # Resolver implementation +├── test/ +│ └── DomainEvents.Tests/ # NUnit test project +│ ├── Events/ # Test event definitions +│ ├── Handlers/ # Test handler implementations +│ └── Run/ # Test cases +└── .github/ + └── workflows/ + └── CI-Build.yml # GitHub Actions CI/CD +``` + +## Development Conventions + +### Coding Style + +- **Nullable reference types**: Disabled (`Nullable>disable`) +- **Implicit usings**: Disabled (`ImplicitUsings>disable`) +- **Target frameworks**: Multi-targeted for broad compatibility +- **Naming**: PascalCase for interfaces (`IPublisher`, `IHandler`), classes follow standard .NET conventions + +### Testing Practices + +- **Framework**: NUnit 4.x +- **Test adapter**: NUnit3TestAdapter +- **Coverage**: coverlet.collector for code coverage +- **Test structure**: Separate folders for Events, Handlers, and Run (test cases) + +### Versioning + +- Uses **Nerdbank.GitVersioning** for semantic versioning +- Version configuration in `version.json` +- Public releases from `master` branch only +- Current version: **4.1.0** + +### CI/CD Workflow + +The GitHub Actions workflow (`.github/workflows/CI-Build.yml`) handles: + +1. **Linting**: Super-linter on PR events +2. **Build (Beta)**: For non-release branches with auto-versioning +3. **Build (Release)**: For `release/*` branches +4. **Testing**: Runs on every build +5. **Packaging**: Publishes to GitHub Packages +6. **Release**: Publishes to NuGet.org for release branches + +### Package Information + +- **Package ID**: `Dormito.DomainEvents` +- **Assembly Name**: `Dormito` +- **Root Namespace**: `DomainEvents` +- **License**: MIT License +- **Repository**: https://github.com/CodeShayk/DomainEvents + +## Usage Pattern + +### 1. Define an Event + +```csharp +public class CustomerCreated : IDomainEvent +{ + public string Name { get; set; } +} +``` + +### 2. Create a Handler + +```csharp +public class CustomerCreatedHandler : IHandler +{ + public Task HandleAsync(CustomerCreated @event) + { + Console.WriteLine($"Customer created: {@event.Name}"); + return Task.CompletedTask; + } +} +``` + +### 3. Register with DI Container + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddTransient(sp => + new Resolver(sp.GetServices())); + services.AddTransient(); + services.AddTransient(); +} +``` + +### 4. Publish Events + +```csharp +public class OrderService +{ + private readonly IPublisher _publisher; + + public OrderService(IPublisher publisher) + { + _publisher = publisher; + } + + public async Task CreateOrderAsync(Order order) + { + // ... create order logic + var @event = new OrderCreated { OrderId = order.Id }; + await _publisher.RaiseAsync(@event); + } +} +``` + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `README.md` | Library documentation and usage examples | +| `DomainEvents.sln` | Visual Studio solution file | +| `src/DomainEvents/DomainEvents.csproj` | Library project file with package metadata | +| `test/DomainEvents.Tests/DomainEvents.Tests.csproj` | Test project configuration | +| `.github/workflows/CI-Build.yml` | CI/CD pipeline definition | +| `nuget.config` | NuGet package source configuration | +| `version.json` | GitVersioning configuration | +| `License.md` | MIT License | diff --git a/README.md b/README.md index 8b58375..0619d42 100644 --- a/README.md +++ b/README.md @@ -8,50 +8,290 @@ [![.Net Standard 2.0](https://img.shields.io/badge/.NetStandard-2.0-green)](https://github.com/dotnet/standard/blob/v2.0.0/docs/versions/netstandard2.0.md) [![.Net Framework 4.6.4](https://img.shields.io/badge/.Net-4.6.4-blue)](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net46) ## Library to help implement transactional events in domain bounded context. -Use domain events to explicitly implement side effects of changes within your domain. In other words, and using DDD terminology, use domain events to explicitly implement side effects across multiple aggregates. + +Use domain events to explicitly implement side effects of changes within your domain. In other words, and using DDD terminology, use domain events to explicitly implement side effects across multiple aggregates. + ### What is a Domain Event? + > An event is something that has happened in the past. A domain event is, something that happened in the domain that you want other parts of the same domain (in-process) to be aware of. The notified parts usually react somehow to the events. + The domain events and their side effects (the actions triggered afterwards that are managed by event handlers) should occur almost immediately, usually in-process, and within the same domain. + It's important to ensure that, just like a database transaction, either all the operations related to a domain event finish successfully or none of them do. Figure below shows how consistency between aggregates is achieved by domain events. When the user initiates an order, the `Order Aggregate` sends an `OrderStarted` domain event. The OrderStarted domain event is handled by the `Buyer Aggregate` to create a Buyer object in the ordering microservice (bounded context). Please read [Domain Events](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation) for more details. ![image](https://user-images.githubusercontent.com/6259981/204060193-d2f5241e-c1d2-46ab-a16d-1c3047bc151b.png) +--- + +## v5.x - New Features + +### 1. Async Handler Interface (IHandler) -### How to Define, Publish and Subscribe to an Event using DomainEvents library? +The library now uses async handlers with `IHandler` interface: -1. Define - To implement a domain event, simply derive the event class from `IDomainEvent` interface. +```csharp +public class CustomerCreatedHandler : IHandler +{ + public Task HandleAsync(CustomerCreated @event) + { + Console.WriteLine($"Customer created: {@event.Name}"); + return Task.CompletedTask; + } +} ``` -public class CustomerCreated : IDomainEvent { - public string Name { get; set; } + +### 2. Custom Dispatcher Support + +You can now provide your own custom dispatcher to customize how events are dispatched to handlers. The standard EventInterceptor with telemetry remains unchanged. + +```csharp +public class MyCustomDispatcher : IEventDispatcher +{ + public void Dispatch(object @event) + { + // Custom dispatch logic + Console.WriteLine($"Dispatching event: {@event.GetType().Name}"); + + // Resolve handlers and dispatch + // ... + } + + public Task DispatchAsync(object @event) + { + Dispatch(@event); + return Task.CompletedTask; + } } - ``` -2. Publish - To raise the domain event, Inject `IPublisher` using your favourite IoC container and call the `RaiseAsync()` method. ``` - var @event = new CustomerCreated { Name = "Ninja Sha!4h" }; - await _Publisher.RaiseAsync(@event); + +Register with DI: + +```csharp +// Using type +services.AddDomainEventsWithDispatcher(assembly); + +// Using instance +services.AddDomainEventsWithDispatcher(new MyCustomDispatcher(), assembly); ``` -3. Subscribe - To listen to a domain event, implement `IHandler` interface where T is the event type you intend to handle. + +### 3. Standard EventInterceptor with Telemetry + +The `EventInterceptor` provides standard interception with built-in telemetry: + +- OpenTelemetry activity tracking +- Logging of event dispatching +- Error handling and reporting + +The interceptor is automatically registered and should not be customized. + +### 4. Aggregate Base Class + +The library provides an `Aggregate` base class that can raise and handle domain events: + +```csharp +public class CustomerAggregate : Aggregate +{ + public void RegisterCustomer(string name) + { + var @event = new CustomerCreated { Name = name }; + Raise(@event); // Automatically intercepted and dispatched + } +} + +public class WarehouseAggregate : Aggregate, IHandler +{ + public Task HandleAsync(OrderReceived @event) + { + Console.WriteLine($"Warehouse received order: {@event.OrderNo}"); + return Task.CompletedTask; + } + + public void ProcessOrder(string orderNo) + { + Raise(new OrderReceived { OrderNo = orderNo }); + } +} +``` + +### 5. Microsoft.Extensions.DependencyInjection Integration + +Automatically scan assemblies and register all event handlers: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + // Auto-scan assemblies for IHandler implementations + services.AddDomainEvents(typeof(MyHandler).Assembly); +} +``` + +### 6. Castle DynamicProxy Interception + +Use the `IAggregateFactory` to create proxied aggregates with automatic event interception: + +```csharp +// Register in DI +services.AddDomainEvents(assembly); +services.AddSingleton(); + +// Usage +var factory = serviceProvider.GetRequiredService(); +var customer = await factory.CreateAsync(); +customer.RegisterCustomer("John Doe"); // Automatically intercepted ``` + +--- + +## Usage Patterns + +### Pattern 1: Traditional Publisher/Handler + +1. **Define Event** - Derive from `IDomainEvent`: + +```csharp +public class CustomerCreated : IDomainEvent +{ + public string Name { get; set; } +} +``` + +2. **Publish** - Inject `IPublisher` and call `RaiseAsync()`: + +```csharp +var @event = new CustomerCreated { Name = "John" }; +await _publisher.RaiseAsync(@event); +``` + +3. **Subscribe** - Implement `IHandler` interface: + +```csharp public class CustomerCreatedHandler : IHandler { - public Task HandleAsync(CustomerCreated @event) - { - Console.WriteLine($"Customer created: {@event.Name}"); - ..... - } + public Task HandleAsync(CustomerCreated @event) + { + Console.WriteLine($"Customer created: {@event.Name}"); + return Task.CompletedTask; + } +} +``` + +4. **Auto-Register with DI**: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddDomainEvents(typeof(CustomerCreatedHandler).Assembly); +} +``` + +### Pattern 2: Aggregate-Based with Auto-Registration + +1. **Define Event**: + +```csharp +public class OrderCreated : IDomainEvent +{ + public string OrderId { get; set; } +} +``` + +2. **Create Aggregate**: + +```csharp +public class OrderAggregate : Aggregate +{ + public void CreateOrder(string customerId) + { + Raise(new OrderCreated { OrderId = Guid.NewGuid().ToString() }); + } } ``` -4. Example - IoC Container Registrations + +3. **Create Handler (Aggregate that handles events)**: + +```csharp +public class InventoryAggregate : Aggregate, IHandler +{ + public Task HandleAsync(OrderCreated @event) + { + Console.WriteLine($"Reserving inventory for order: {@event.OrderId}"); + return Task.CompletedTask; + } +} ``` + +4. **Auto-Register with DI**: + +```csharp public void ConfigureServices(IServiceCollection services) -{ - // register publisher with required lifetime. - services.AddTransient(); - - // register all implemented event handlers. - services.AddTransient(); - services.AddTransient(); +{ + services.AddDomainEvents(typeof(OrderCreated).Assembly); +} +``` + +### Pattern 3: Custom Dispatcher + +To add custom dispatch logic (e.g., logging, filtering, transformations): + +```csharp +public class LoggingDispatcher : IEventDispatcher +{ + private readonly IEventDispatcher _innerDispatcher; + private readonly ILogger _logger; + + public LoggingDispatcher(IEventDispatcher innerDispatcher, ILogger logger) + { + _innerDispatcher = innerDispatcher; + _logger = logger; + } + + public void Dispatch(object @event) + { + _logger.LogInformation("Dispatching event: {EventType}", @event.GetType().Name); + _innerDispatcher.Dispatch(@event); + } + + public Task DispatchAsync(object @event) + { + Dispatch(@event); + return Task.CompletedTask; + } } + +// Register +services.AddDomainEventsWithDispatcher(assembly); ``` + +--- + +## Interface Summary + +| Interface | Purpose | +|-----------|---------| +| `IDomainEvent` | Marker interface for domain events | +| `IHandler` | Async handler interface for specific event types | +| `IHandler` | Marker interface for handlers | +| `IPublisher` | Interface for raising/publishing domain events | +| `IResolver` | Interface for resolving handlers for a given event type | +| `IEventInterceptor` | Interface for intercepting Raise/RaiseAsync calls (standard, with telemetry) | +| `IEventDispatcher` | Interface for dispatching events to handlers (customizable) | + +## Implementation Classes + +| Class | Purpose | +|-------|---------| +| `Aggregate` | Base class for domain aggregates with Raise() method | +| `Publisher` | Concrete implementation of `IPublisher` | +| `Resolver` | Concrete implementation of `IResolver` | +| `AggregateFactory` | Factory for creating proxied aggregate instances | +| `EventInterceptor` | Default Castle DynamicProxy interceptor with telemetry | +| `EventDispatcher` | Default implementation of `IEventDispatcher` | + +## Package Information + +- **Package ID**: `Dormito.DomainEvents` +- **Target Frameworks**: netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0 +- **License**: MIT +- **Repository**: https://github.com/CodeShayk/DomainEvents diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..b611cbb --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,144 @@ +# Release Notes - v5.0.0 + +## New Features + +### 1. Custom Dispatcher Support +Users can now provide their own custom dispatcher to customize how events are dispatched to handlers. This enables adding custom behavior such as logging, filtering, or transformations while keeping the standard EventInterceptor with telemetry. + +```csharp +public class MyCustomDispatcher : IEventDispatcher +{ + private readonly IEventDispatcher _innerDispatcher; + + public MyCustomDispatcher(IEventDispatcher innerDispatcher) + { + _innerDispatcher = innerDispatcher; + } + + public void Dispatch(object @event) + { + // Custom logic before + Console.WriteLine($"Dispatching: {@event.GetType().Name}"); + + // Forward to inner dispatcher + _innerDispatcher.Dispatch(@event); + } + + public Task DispatchAsync(object @event) + { + Dispatch(@event); + return Task.CompletedTask; + } +} +``` + +**Registration:** +```csharp +// Using type +services.AddDomainEventsWithDispatcher(assembly); + +// Using instance +services.AddDomainEventsWithDispatcher(new MyCustomDispatcher(dispatcher), assembly); +``` + +### 2. IEventDispatcher Interface +Introduced `IEventDispatcher` interface to separate event dispatching logic from interception. Custom dispatchers can wrap the inner dispatcher for decorator patterns. + +**Interface:** +```csharp +public interface IEventDispatcher +{ + void Dispatch(object @event); + Task DispatchAsync(object @event); +} +``` + +### 3. Standard EventInterceptor with Telemetry +The `EventInterceptor` remains standard and includes: +- OpenTelemetry activity tracking +- Logging of event dispatching +- Error handling and reporting + +The interceptor is automatically registered and should not be customized. + +### 4. Async Handler Interface (IHandler) +Changed from synchronous `IHandle` to asynchronous `IHandler` interface: + +```csharp +// Before (v4.x) +public interface IHandle : IHandle where T : IDomainEvent +{ + void Handle(T @event); +} + +// After (v5.x) +public interface IHandler : IHandler where T : IDomainEvent +{ + Task HandleAsync(T @event); +} +``` + +### 5. Service Locator Pattern in AggregateFactory +The `AggregateFactory` now uses the service locator pattern to resolve dispatchers at proxy creation time. + +## Breaking Changes + +### 1. IHandle -> IHandler +- `IHandle` renamed to `IHandler` +- `IHandle` renamed to `IHandler` +- `Handle()` method changed to `HandleAsync()` returning `Task` + +### 2. EventInterceptor Constructor +- `EventInterceptor` now requires `IEventDispatcher` instead of `IResolver` +- Constructor signature: `EventInterceptor(IEventDispatcher dispatcher, ILogger logger = null)` + +### 3. Service Registration +- `AddDomainEventsWithDispatcher()` replaces `AddDomainEventsWithInterceptor()` + +## Bug Fixes + +- Fixed issue where custom interceptors would not dispatch events to handlers + +## Migration Guide + +### Update Handlers +```csharp +// Before +public class CustomerCreatedHandler : IHandle +{ + public void Handle(CustomerCreated @event) { } +} + +// After +public class CustomerCreatedHandler : IHandler +{ + public Task HandleAsync(CustomerCreated @event) => Task.CompletedTask; +} +``` + +### Update Aggregate Implementations +```csharp +// Before +public class WarehouseAggregate : Aggregate, IHandle +{ + public void Handle(OrderReceived @event) { } +} + +// After +public class WarehouseAggregate : Aggregate, IHandler +{ + public Task HandleAsync(OrderReceived @event) => Task.CompletedTask; +} +``` + +### Adding Custom Dispatcher (instead of Interceptor) +```csharp +// Register custom dispatcher +services.AddDomainEventsWithDispatcher(assembly); +``` + +## Dependencies +- Castle.DynamicProxy +- Microsoft.Extensions.DependencyInjection.Abstractions +- Microsoft.Extensions.Logging.Abstractions +- OpenTelemetry (optional, for telemetry support) diff --git a/src/DomainEvents/Aggregate.cs b/src/DomainEvents/Aggregate.cs new file mode 100644 index 0000000..9f5d1e6 --- /dev/null +++ b/src/DomainEvents/Aggregate.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; + +namespace DomainEvents +{ + /// + /// Abstract base class for domain aggregates that can raise and handle domain events. + /// Aggregates derived from this class can raise events which will be intercepted + /// and dispatched to registered handlers via Castle DynamicProxy. + /// + public abstract class Aggregate : IDomainAggregate + { + /// + /// Initializes a new instance of the class. + /// + protected Aggregate() + { + } + + /// + /// Raises a domain event synchronously. The event will be intercepted and dispatched + /// to all registered handlers for the event type when using proxied aggregates. + /// + /// The type of the event to raise. + /// The event instance to raise. + protected virtual void Raise(TEvent @event) where TEvent : IDomainEvent + { + // Empty body - intercepted by EventInterceptor when using proxied aggregates + } + + /// + /// Raises a domain event asynchronously. The event will be intercepted and dispatched + /// to all registered handlers for the event type when using proxied aggregates. + /// + /// The type of the event to raise. + /// The event instance to raise. + /// A task that represents the asynchronous operation. + public virtual Task RaiseAsync(TEvent @event) where TEvent : IDomainEvent + { + // Empty body - intercepted by EventInterceptor when using proxied aggregates + return Task.CompletedTask; + } + } +} diff --git a/src/DomainEvents/DomainEvents.csproj b/src/DomainEvents/DomainEvents.csproj index 27562b8..602560f 100644 --- a/src/DomainEvents/DomainEvents.csproj +++ b/src/DomainEvents/DomainEvents.csproj @@ -1,7 +1,7 @@ - net462;netstandard2.0;netstandard2.1;net9.0 + netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0 disable disable true @@ -17,25 +17,34 @@ False snupkg DomainEvents - Code Shayk - Code Shayk + CodeShayk + CodeShayk DomainEvents .Net Library to implement transactional events in domain model. - Copyright (c) 2025 Code Shayk + Copyright (c) 2026 Code Shayk README.md https://github.com/CodeShayk/DomainEvents git - domain-events; domain events; .net9.0; c# domain events; domain pub/sub; event pub sub - 4.0.1 - ninja-icon-16.png + domain-events; domain-model, events; event-handling; c#; transactional-events; domain-pub/sub; event-pub-sub; ddd; aggregates; castle-dynamicproxy + 4.1.0 + pub-sub-icon.png True https://github.com/CodeShayk/DomainEvents/wiki - Release v4.0.1- Targets .Net Framework 4.6.2, .Net Standards 2.0 and 2.1, .Net 9.0 + + Release v4.2.0 - Updated all dependencies to latest stable versions + - OpenTelemetry.Api 1.11.2 -> 1.15.0 (security fix for CVE-2025-27513) + - Castle.Core 5.1.1 -> 5.2.1 + - Microsoft.Extensions 9.0.0 -> 10.0.5 + - Targets .Net 10.0 + - Target .Net 9.0 + - Target .Net 8.0 + - Target .Net Standard 2.1 + - Target .Net Standard 2.0 - + True \ @@ -49,4 +58,11 @@ + + + + + + + diff --git a/src/DomainEvents/IAggregateFactory.cs b/src/DomainEvents/IAggregateFactory.cs new file mode 100644 index 0000000..0c76fb4 --- /dev/null +++ b/src/DomainEvents/IAggregateFactory.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace DomainEvents +{ + /// + /// Factory interface for creating proxied aggregate instances. + /// Aggregates created through this factory will have their Raise/RaiseAsync methods + /// intercepted to automatically dispatch events to registered handlers. + /// + public interface IAggregateFactory + { + /// + /// Creates a proxied instance of the specified aggregate type. + /// The proxy will intercept Raise/RaiseAsync method calls and dispatch events to handlers. + /// + /// The aggregate type. + /// Constructor arguments for the aggregate. + /// A proxied instance of the aggregate implementing IDomainAggregate. + Task CreateAsync(params object[] constructorArguments) where T : Aggregate; + + /// + /// Creates a proxied instance of the specified aggregate type. + /// + /// The aggregate type. + /// Constructor arguments for the aggregate. + /// A proxied instance of the aggregate implementing IDomainAggregate. + Task CreateAsync(Type aggregateType, params object[] constructorArguments); + } +} diff --git a/src/DomainEvents/IDomainAggregate.cs b/src/DomainEvents/IDomainAggregate.cs new file mode 100644 index 0000000..562f8fe --- /dev/null +++ b/src/DomainEvents/IDomainAggregate.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace DomainEvents +{ + /// + /// Interface for domain aggregates that can publish domain events. + /// Extends IPublisher to provide event publishing capabilities. + /// + public interface IDomainAggregate : IPublisher + { + } +} diff --git a/src/DomainEvents/IEventDispatcher.cs b/src/DomainEvents/IEventDispatcher.cs new file mode 100644 index 0000000..af564af --- /dev/null +++ b/src/DomainEvents/IEventDispatcher.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace DomainEvents +{ + /// + /// Interface for dispatching domain events to registered handlers. + /// + public interface IEventDispatcher + { + /// + /// Dispatches an event to all registered handlers synchronously. + /// + /// The event to dispatch. + void Dispatch(object @event); + + /// + /// Dispatches an event to all registered handlers asynchronously. + /// + /// The event to dispatch. + Task DispatchAsync(object @event); + } +} \ No newline at end of file diff --git a/src/DomainEvents/IEventInterceptor.cs b/src/DomainEvents/IEventInterceptor.cs new file mode 100644 index 0000000..2d8eaa4 --- /dev/null +++ b/src/DomainEvents/IEventInterceptor.cs @@ -0,0 +1,12 @@ +using Castle.DynamicProxy; + +namespace DomainEvents +{ + /// + /// Interface for custom event interceptors. + /// Implement this interface to provide custom interception logic for aggregate event raising. + /// + public interface IEventInterceptor : IInterceptor + { + } +} diff --git a/src/DomainEvents/IHandle.cs b/src/DomainEvents/IHandle.cs index 4a6f556..8acf16f 100644 --- a/src/DomainEvents/IHandle.cs +++ b/src/DomainEvents/IHandle.cs @@ -11,6 +11,10 @@ public interface IHandler : IHandler where T : IDomainEvent Task HandleAsync(T @event); } + /// + /// Marker interface for domain event handlers. + /// public interface IHandler - { } + { + } } \ No newline at end of file diff --git a/src/DomainEvents/IResolver.cs b/src/DomainEvents/IResolver.cs index 4213681..e5d3219 100644 --- a/src/DomainEvents/IResolver.cs +++ b/src/DomainEvents/IResolver.cs @@ -4,7 +4,7 @@ namespace DomainEvents { /// - /// Implement Resolver to return all the handlers implemented for domain event type T + /// Implement Resolver to return all the handlers implemented for event type T /// public interface IResolver { diff --git a/src/DomainEvents/Impl/AggregateFactory.cs b/src/DomainEvents/Impl/AggregateFactory.cs new file mode 100644 index 0000000..20175e9 --- /dev/null +++ b/src/DomainEvents/Impl/AggregateFactory.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; +using Castle.DynamicProxy; +using Microsoft.Extensions.DependencyInjection; + +namespace DomainEvents.Impl +{ + /// + /// Factory for creating proxied aggregate instances. + /// Aggregates created through this factory will have their Raise/RaiseAsync methods + /// intercepted to automatically dispatch events to registered handlers. + /// + public class AggregateFactory : IAggregateFactory + { + private readonly ProxyGenerator _proxyGenerator = new ProxyGenerator(); + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider for resolving interceptors. + public AggregateFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + /// Creates a proxied instance of the specified aggregate type. + /// The proxy will intercept Raise/RaiseAsync method calls and dispatch events to handlers. + /// + /// The aggregate type. + /// Constructor arguments for the aggregate. + /// A proxied instance of the aggregate implementing IDomainAggregate. + public Task CreateAsync(params object[] constructorArguments) where T : Aggregate + { + var interceptor = _serviceProvider.GetService(); + + if (interceptor == null) + { + var dispatcher = _serviceProvider.GetService() + ?? new EventDispatcher(_serviceProvider.GetRequiredService()); + interceptor = new EventInterceptor(dispatcher); + } + + var proxy = _proxyGenerator.CreateClassProxy(interceptor); + return Task.FromResult(proxy); + } + + /// + /// Creates a proxied instance of the specified aggregate type. + /// + /// The aggregate type. + /// Constructor arguments for the aggregate. + /// A proxied instance of the aggregate implementing IDomainAggregate. + public Task CreateAsync(Type aggregateType, params object[] constructorArguments) + { + var interceptor = _serviceProvider.GetService(); + + if (interceptor == null) + { + var dispatcher = _serviceProvider.GetService() + ?? new EventDispatcher(_serviceProvider.GetRequiredService()); + interceptor = new EventInterceptor(dispatcher); + } + + var proxy = (IDomainAggregate)_proxyGenerator.CreateClassProxy(aggregateType, interceptor); + return Task.FromResult(proxy); + } + } +} diff --git a/src/DomainEvents/Impl/EventDispatcher.cs b/src/DomainEvents/Impl/EventDispatcher.cs new file mode 100644 index 0000000..4497199 --- /dev/null +++ b/src/DomainEvents/Impl/EventDispatcher.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using DomainEvents.Impl; +using Microsoft.Extensions.Logging; + +namespace DomainEvents +{ + /// + /// Default implementation of IEventDispatcher that dispatches events to registered handlers. + /// + public class EventDispatcher : IEventDispatcher + { + private readonly IResolver _resolver; + private readonly ILogger _logger; + + public EventDispatcher(IResolver resolver, ILogger logger = null) + { + _resolver = resolver; + _logger = logger; + } + + public void Dispatch(object @event) + { + if (@event == null) return; + + var eventType = @event.GetType(); + _logger?.LogDebug("Dispatching event {EventType}", eventType.Name); + + if (!(_resolver is Resolver resolver)) + { + _logger?.LogWarning("Resolver is not of type Resolver, cannot dispatch event"); + return; + } + + var handlers = resolver.ResolveAsync(eventType).GetAwaiter().GetResult(); + var handlerList = handlers.ToList(); + + _logger?.LogDebug("Found {HandlerCount} handlers for event {EventType}", handlerList.Count, eventType.Name); + + var exceptions = new List(); + + foreach (var handler in handlerList) + { + var handlerType = handler.GetType(); + + var activity = DomainEventsActivitySource.Source.StartActivity( + DomainEventsActivitySource.HandleEventActivityName, + ActivityKind.Internal); + + if (activity != null) + { + activity.SetTag(DomainEventsTags.EventType, eventType.Name); + activity.SetTag(DomainEventsTags.HandlerType, handlerType.Name); + } + + try + { + var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(eventType); + var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); + handleMethod?.Invoke(handler, new[] { @event }); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Ok); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error in handler {HandlerType} for event {EventType}", + handlerType.Name, eventType.Name); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); + activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); + } + exceptions.Add(ex); + } + finally + { + activity?.Dispose(); + } + } + + if (exceptions.Count > 0) + { + throw new AggregateException($"Errors occurred while dispatching event {eventType.Name}", exceptions); + } + } + + public async Task DispatchAsync(object @event) + { + if (@event == null) return; + + var eventType = @event.GetType(); + _logger?.LogDebug("Dispatching event async {EventType}", eventType.Name); + + if (!(_resolver is Resolver resolver)) + { + _logger?.LogWarning("Resolver is not of type Resolver, cannot dispatch event"); + return; + } + + var handlers = await resolver.ResolveAsync(eventType); + var handlerList = handlers.ToList(); + + _logger?.LogDebug("Found {HandlerCount} handlers for event {EventType}", handlerList.Count, eventType.Name); + + var exceptions = new List(); + + foreach (var handler in handlerList) + { + var handlerType = handler.GetType(); + + var activity = DomainEventsActivitySource.Source.StartActivity( + DomainEventsActivitySource.HandleEventActivityName, + ActivityKind.Internal); + + if (activity != null) + { + activity.SetTag(DomainEventsTags.EventType, eventType.Name); + activity.SetTag(DomainEventsTags.HandlerType, handlerType.Name); + } + + try + { + var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(eventType); + var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); + handleMethod?.Invoke(handler, new[] { @event }); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Ok); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error in handler {HandlerType} for event {EventType}", + handlerType.Name, eventType.Name); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); + activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); + } + exceptions.Add(ex); + } + finally + { + activity?.Dispose(); + } + } + + if (exceptions.Count > 0) + { + throw new AggregateException($"Errors occurred while dispatching event {eventType.Name}", exceptions); + } + } + } +} \ No newline at end of file diff --git a/src/DomainEvents/Impl/EventInterceptor.cs b/src/DomainEvents/Impl/EventInterceptor.cs new file mode 100644 index 0000000..f7e0fb2 --- /dev/null +++ b/src/DomainEvents/Impl/EventInterceptor.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Threading.Tasks; +using Castle.DynamicProxy; +using Microsoft.Extensions.Logging; + +namespace DomainEvents.Impl +{ + /// + /// Default interceptor for aggregate Raise/RaiseAsync method calls. + /// Intercepts event raising and dispatches to registered handlers with error handling and logging. + /// + public class EventInterceptor : IEventInterceptor + { + private readonly IEventDispatcher _dispatcher; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The event dispatcher for dispatching events to handlers. + /// Optional logger for diagnostic information. + public EventInterceptor(IEventDispatcher dispatcher, ILogger logger = null) + { + _dispatcher = dispatcher; + _logger = logger; + } + + /// + /// Intercepts the Raise/RaiseAsync method call and dispatches the event to handlers. + /// + /// The method invocation. + public void Intercept(IInvocation invocation) + { + var method = invocation.Method; + if (!IsRaiseMethod(method)) + { + invocation.Proceed(); + return; + } + + var @event = invocation.Arguments[0]; + var eventType = @event.GetType(); + var methodName = method.Name; + var isAsync = methodName == "RaiseAsync"; + + _logger?.LogDebug("Intercepted {MethodName} for event type {EventType}", methodName, eventType.Name); + + var activity = DomainEventsActivitySource.Source.StartActivity( + DomainEventsActivitySource.PublishEventActivityName, + ActivityKind.Internal); + + if (activity != null) + { + activity.SetTag(DomainEventsTags.EventType, eventType.Name); + activity.SetTag(DomainEventsTags.AggregateType, invocation.InvocationTarget?.GetType().Name ?? "Unknown"); + } + + try + { + invocation.Proceed(); + + if (isAsync) + { + _dispatcher.DispatchAsync(@event).GetAwaiter().GetResult(); + } + else + { + _dispatcher.Dispatch(@event); + } + + _logger?.LogDebug("Successfully dispatched event {EventType}", eventType.Name); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Ok); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error dispatching event {EventType}", eventType.Name); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); + activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); + } + throw; + } + finally + { + activity?.Dispose(); + } + } + + private static bool IsRaiseMethod(MethodInfo method) + { + return method.Name == "Raise" || method.Name == "RaiseAsync"; + } + } +} diff --git a/src/DomainEvents/Impl/Publisher.cs b/src/DomainEvents/Impl/Publisher.cs index ed0ccdb..ae2b6c1 100644 --- a/src/DomainEvents/Impl/Publisher.cs +++ b/src/DomainEvents/Impl/Publisher.cs @@ -8,16 +8,16 @@ namespace DomainEvents.Impl /// public sealed class Publisher : IPublisher { - private readonly IResolver _Resolver; + private readonly IResolver _resolver; public Publisher(IResolver resolver) { - _Resolver = resolver; + _resolver = resolver; } public async Task RaiseAsync(T @event) where T : IDomainEvent { - var handlers = await _Resolver.ResolveAsync(); + var handlers = await _resolver.ResolveAsync(); foreach (var handler in handlers.ToArray()) await handler.HandleAsync(@event); } diff --git a/src/DomainEvents/Impl/Resolver.cs b/src/DomainEvents/Impl/Resolver.cs index 6cb7b21..f607237 100644 --- a/src/DomainEvents/Impl/Resolver.cs +++ b/src/DomainEvents/Impl/Resolver.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -7,18 +8,31 @@ namespace DomainEvents.Impl /// /// Default Resolver to return all handlers implemented for event type T. /// - public sealed class Resolver : IResolver + public class Resolver : IResolver { - private readonly IEnumerable _Handlers; + protected readonly IEnumerable _handlers; public Resolver(IEnumerable handlers) { - _Handlers = handlers; + _handlers = handlers; } public Task>> ResolveAsync() where T : IDomainEvent { - var handlers = _Handlers.Where(t => typeof(IHandler).IsAssignableFrom(t.GetType())).Cast>(); + var handlers = _handlers.OfType>(); + return Task.FromResult(handlers); + } + + /// + /// Resolves handlers for a given event type at runtime. + /// + /// The event type. + /// All handlers for the specified event type. + public virtual Task> ResolveAsync(Type eventType) + { + var handlers = _handlers.Where(h => h.GetType().GetInterfaces() + .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IHandler<>) + && i.GetGenericArguments()[0] == eventType)); return Task.FromResult(handlers); } } diff --git a/src/DomainEvents/ServiceCollectionExtensions.cs b/src/DomainEvents/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d66b147 --- /dev/null +++ b/src/DomainEvents/ServiceCollectionExtensions.cs @@ -0,0 +1,183 @@ +using System; +using System.Linq; +using System.Reflection; +using DomainEvents; +using DomainEvents.Impl; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace DomainEvents +{ + /// + /// Extension methods for registering domain events with dependency injection. + /// + public static class ServiceCollectionExtensions + { + /// + /// Adds domain events support to the service collection. + /// Automatically scans the specified assemblies for IHandle implementations and registers them. + /// Uses the default EventInterceptor for event interception. + /// + /// The service collection. + /// The assemblies to scan for event handlers. + /// The service collection for chaining. + public static IServiceCollection AddDomainEvents(this IServiceCollection services, params Assembly[] assemblies) + { + if (assemblies == null || assemblies.Length == 0) + { + throw new ArgumentException("At least one assembly must be specified", nameof(assemblies)); + } + + // Register the publisher + services.AddSingleton(); + + // Register the resolver + services.AddSingleton(sp => + { + var handlers = sp.GetServices(); + return new Resolver(handlers); + }); + + // Register the default event dispatcher + services.AddSingleton(sp => + { + var resolver = sp.GetRequiredService(); + var logger = sp.GetService>(); + return new EventDispatcher(resolver, logger); + }); + + // Register the default event interceptor + services.AddSingleton(sp => + { + var dispatcher = sp.GetRequiredService(); + var logger = sp.GetService>(); + return new EventInterceptor(dispatcher, logger); + }); + + // Register the aggregate factory + services.AddSingleton(); + + // Scan assemblies and register all IHandler implementations with parameterless constructors + foreach (var assembly in assemblies) + { + var handlerTypes = assembly.GetTypes() + .Where(t => !t.IsAbstract && !t.IsInterface) + .Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IHandler<>))) + .Where(t => t.GetConstructor(Type.EmptyTypes) != null); // Only parameterless constructors + + foreach (var handlerType in handlerTypes) + { + services.AddSingleton(typeof(IHandler), handlerType); + services.AddSingleton(handlerType); + } + } + + return services; + } + + /// + /// Adds domain events support to the service collection. + /// Automatically scans the calling assembly for IHandle implementations and registers them. + /// Uses the default EventInterceptor for event interception. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddDomainEvents(this IServiceCollection services) + { + var callingAssembly = Assembly.GetCallingAssembly(); + return services.AddDomainEvents(callingAssembly); + } + + /// + /// Adds domain events support with a custom event dispatcher. + /// Use this to customize how events are dispatched to handlers. + /// The default EventInterceptor with telemetry is still used. + /// + /// The custom dispatcher type implementing IEventDispatcher. + /// The service collection. + /// The assemblies to scan for event handlers. + /// The service collection for chaining. + public static IServiceCollection AddDomainEventsWithDispatcher(this IServiceCollection services, params Assembly[] assemblies) + where TDispatcher : class, IEventDispatcher + { + services.AddDomainEventsCore(assemblies); + services.AddSingleton(); + return services; + } + + /// + /// Adds domain events support with a custom event dispatcher instance. + /// Use this to customize how events are dispatched to handlers. + /// The default EventInterceptor with telemetry is still used. + /// + /// The service collection. + /// The custom dispatcher instance. + /// The assemblies to scan for event handlers. + /// The service collection for chaining. + public static IServiceCollection AddDomainEventsWithDispatcher(this IServiceCollection services, IEventDispatcher dispatcher, params Assembly[] assemblies) + { + services.AddDomainEventsCore(assemblies); + services.AddSingleton(dispatcher); + return services; + } + + /// + /// Adds domain events support with OpenTelemetry instrumentation. + /// Uses the default EventInterceptor which includes OpenTelemetry activity tracking. + /// + /// The service collection. + /// The assemblies to scan for event handlers. + /// The service collection for chaining. + public static IServiceCollection AddDomainEventsWithTelemetry(this IServiceCollection services, params Assembly[] assemblies) + { + return services.AddDomainEvents(assemblies); + } + + /// + /// Adds domain events support with OpenTelemetry instrumentation. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddDomainEventsWithTelemetry(this IServiceCollection services) + { + var callingAssembly = Assembly.GetCallingAssembly(); + return services.AddDomainEventsWithTelemetry(callingAssembly); + } + + /// + /// Core domain events registration without interceptor registration. + /// + private static IServiceCollection AddDomainEventsCore(this IServiceCollection services, Assembly[] assemblies) + { + // Register the publisher + services.AddSingleton(); + + // Register the resolver + services.AddSingleton(sp => + { + var handlers = sp.GetServices(); + return new Resolver(handlers); + }); + + // Register the aggregate factory + services.AddSingleton(); + + // Scan assemblies and register all IHandler implementations with parameterless constructors + foreach (var assembly in assemblies) + { + var handlerTypes = assembly.GetTypes() + .Where(t => !t.IsAbstract && !t.IsInterface) + .Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IHandler<>))) + .Where(t => t.GetConstructor(Type.EmptyTypes) != null); + + foreach (var handlerType in handlerTypes) + { + services.AddSingleton(typeof(IHandler), handlerType); + services.AddSingleton(handlerType); + } + } + + return services; + } + } +} diff --git a/src/DomainEvents/Telemetry/DomainEventsActivitySource.cs b/src/DomainEvents/Telemetry/DomainEventsActivitySource.cs new file mode 100644 index 0000000..f2b308e --- /dev/null +++ b/src/DomainEvents/Telemetry/DomainEventsActivitySource.cs @@ -0,0 +1,62 @@ +using System; +using System.Diagnostics; + +namespace DomainEvents +{ + /// + /// OpenTelemetry activity source for DomainEvents. + /// + public static class DomainEventsActivitySource + { + /// + /// Activity source name for DomainEvents. + /// + public const string ActivitySourceName = "DomainEvents"; + + /// + /// Activity source instance for DomainEvents. + /// + public static readonly ActivitySource Source = new ActivitySource(ActivitySourceName, "1.0.0"); + + /// + /// Activity name for event publishing. + /// + public const string PublishEventActivityName = "DomainEvents.Publish"; + + /// + /// Activity name for event handling. + /// + public const string HandleEventActivityName = "DomainEvents.Handle"; + } + + /// + /// Activity tags for DomainEvents. + /// + public static class DomainEventsTags + { + /// + /// Tag for event type. + /// + public const string EventType = "domain.event.type"; + + /// + /// Tag for handler type. + /// + public const string HandlerType = "domain.handler.type"; + + /// + /// Tag for aggregate type. + /// + public const string AggregateType = "domain.aggregate.type"; + + /// + /// Tag for error message. + /// + public const string ErrorMessage = "error.message"; + + /// + /// Tag for error type. + /// + public const string ErrorType = "error.type"; + } +} diff --git a/src/DomainEvents/assemblyinfo.cs b/src/DomainEvents/assemblyinfo.cs index c812692..6715fbe 100644 --- a/src/DomainEvents/assemblyinfo.cs +++ b/src/DomainEvents/assemblyinfo.cs @@ -11,16 +11,16 @@ using System; using System.Reflection; -[assembly: System.Reflection.AssemblyCompanyAttribute("Tech Ninja Labs")] +[assembly: System.Reflection.AssemblyCompanyAttribute("CodeShayk")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] -[assembly: System.Reflection.AssemblyCopyrightAttribute("2024")] +[assembly: System.Reflection.AssemblyCopyrightAttribute("2026")] [assembly: System.Reflection.AssemblyDescriptionAttribute(".Net Library to implement transactional events in domain model.")] -[assembly: System.Reflection.AssemblyFileVersionAttribute("3.0.1.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("3.0.1")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("4.1.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("4.1.0")] [assembly: System.Reflection.AssemblyProductAttribute("Dormito")] -[assembly: System.Reflection.AssemblyTitleAttribute("Dormito.DomainEvents")] -[assembly: System.Reflection.AssemblyVersionAttribute("3.0.1.0")] -[assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/TechNinjaLabs/Ninja.DomainEvents")] +[assembly: System.Reflection.AssemblyTitleAttribute("DomainEvents")] +[assembly: System.Reflection.AssemblyVersionAttribute("4.1.0")] +[assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/Codeshayk/DomainEvents")] // Generated by the MSBuild WriteCodeFragment class. diff --git a/test/DomainEvents.Tests/Aggregates/TestAggregates.cs b/test/DomainEvents.Tests/Aggregates/TestAggregates.cs new file mode 100644 index 0000000..f3ebbe4 --- /dev/null +++ b/test/DomainEvents.Tests/Aggregates/TestAggregates.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +using DomainEvents.Tests.Events; + +namespace DomainEvents.Tests.Aggregates +{ + /// + /// Test aggregate that raises CustomerCreated events. + /// + public class CustomerAggregate : Aggregate + { + public CustomerAggregate() : base() + { + } + + public void RegisterCustomer(string name) + { + // Some business logic here... + var @event = new CustomerCreated { Name = name }; + Raise(@event); + } + } + + /// + /// Test aggregate that handles OrderCreated events and raises OrderProcessed events. + /// + public class WarehouseAggregate : Aggregate, IHandler + { + private readonly List _receivedOrders = new(); + + public WarehouseAggregate() : base() + { + } + + public Task HandleAsync(OrderReceived @event) + { + _receivedOrders.Add(@event); + Console.WriteLine($"Warehouse processed order: {@event.OrderNo}"); + return Task.CompletedTask; + } + + public void ProcessOrder(string orderNo) + { + // Some business logic here... + var @event = new OrderReceived { OrderNo = orderNo }; + Raise(@event); + } + + public IReadOnlyList GetReceivedOrders() => _receivedOrders.AsReadOnly(); + } +} diff --git a/test/DomainEvents.Tests/DomainEvents.Tests.csproj b/test/DomainEvents.Tests/DomainEvents.Tests.csproj index ba29041..56f9483 100644 --- a/test/DomainEvents.Tests/DomainEvents.Tests.csproj +++ b/test/DomainEvents.Tests/DomainEvents.Tests.csproj @@ -8,14 +8,15 @@ - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/DomainEvents.Tests/Handlers/CustomerCreatedHandler.cs b/test/DomainEvents.Tests/Handlers/CustomerCreatedHandler.cs index 4798d8c..869c301 100644 --- a/test/DomainEvents.Tests/Handlers/CustomerCreatedHandler.cs +++ b/test/DomainEvents.Tests/Handlers/CustomerCreatedHandler.cs @@ -1,21 +1,21 @@ -using DomainEvents.Tests.Events; +using System.Threading.Tasks; +using DomainEvents.Tests.Events; namespace DomainEvents.Tests.Handlers { public class CustomerCreatedHandler : IHandler { - private readonly Dictionary _HandlerResult; + private readonly Dictionary _handlerResult; public CustomerCreatedHandler(Dictionary handlerResult) { - _HandlerResult = handlerResult; + _handlerResult = handlerResult; } - public Task HandleAsync(CustomerCreated args) + public Task HandleAsync(CustomerCreated @event) { - Console.WriteLine($"Customer created: {args.Name}"); - _HandlerResult.Add(args, this.GetType()); - + Console.WriteLine($"Customer created: {@event.Name}"); + _handlerResult.Add(@event, this.GetType()); return Task.CompletedTask; } } diff --git a/test/DomainEvents.Tests/Handlers/OrderReceivedHandler.cs b/test/DomainEvents.Tests/Handlers/OrderReceivedHandler.cs index 871c98d..3b2ba50 100644 --- a/test/DomainEvents.Tests/Handlers/OrderReceivedHandler.cs +++ b/test/DomainEvents.Tests/Handlers/OrderReceivedHandler.cs @@ -1,21 +1,21 @@ -using DomainEvents.Tests.Events; +using System.Threading.Tasks; +using DomainEvents.Tests.Events; namespace DomainEvents.Tests.Handlers { public class OrderReceivedHandler : IHandler { - private readonly Dictionary _HandlerResult; + private readonly Dictionary _handlerResult; public OrderReceivedHandler(Dictionary handlerResult) { - _HandlerResult = handlerResult; + _handlerResult = handlerResult; } - public Task HandleAsync(OrderReceived args) + public Task HandleAsync(OrderReceived @event) { - Console.WriteLine($"Order received: {args.OrderNo}"); - _HandlerResult.Add(args, this.GetType()); - + Console.WriteLine($"Order received: {@event.OrderNo}"); + _handlerResult.Add(@event, this.GetType()); return Task.CompletedTask; } } diff --git a/test/DomainEvents.Tests/Handlers/SimpleHandlers.cs b/test/DomainEvents.Tests/Handlers/SimpleHandlers.cs new file mode 100644 index 0000000..50c7205 --- /dev/null +++ b/test/DomainEvents.Tests/Handlers/SimpleHandlers.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using DomainEvents.Tests.Events; + +namespace DomainEvents.Tests.Handlers +{ + /// + /// Simple handler for DI tests without constructor dependencies. + /// + public class SimpleCustomerCreatedHandler : IHandler + { + public static int HandleCount { get; set; } + + public Task HandleAsync(CustomerCreated @event) + { + HandleCount++; + Console.WriteLine($"Simple handler: Customer created: {@event.Name}"); + return Task.CompletedTask; + } + } + + /// + /// Simple handler for DI tests without constructor dependencies. + /// + public class SimpleOrderReceivedHandler : IHandler + { + public static int HandleCount { get; set; } + + public Task HandleAsync(OrderReceived @event) + { + HandleCount++; + Console.WriteLine($"Simple handler: Order received: {@event.OrderNo}"); + return Task.CompletedTask; + } + } +} diff --git a/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs b/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs new file mode 100644 index 0000000..34c795b --- /dev/null +++ b/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs @@ -0,0 +1,214 @@ +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Integration tests for IAggregateFactory. + /// + public class AggregateFactoryIntegrationTests + { + private IServiceProvider _serviceProvider; + private IAggregateFactory _aggregateFactory; + + [SetUp] + public void Setup() + { + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + _aggregateFactory = _serviceProvider.GetRequiredService(); + + SimpleCustomerCreatedHandler.HandleCount = 0; + SimpleOrderReceivedHandler.HandleCount = 0; + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [Test] + public async Task CreateAsync_ShouldCreateProxiedCustomerAggregate() + { + // Arrange & Act + var customer = await _aggregateFactory.CreateAsync(); + + // Assert + Assert.That(customer, Is.Not.Null); + Assert.That(customer, Is.InstanceOf()); + } + + [Test] + public async Task CreateAsync_ShouldCreateProxiedWarehouseAggregate() + { + // Arrange & Act + var warehouse = await _aggregateFactory.CreateAsync(); + + // Assert + Assert.That(warehouse, Is.Not.Null); + Assert.That(warehouse, Is.InstanceOf()); + } + + [Test] + public async Task CreateAsync_CustomerAggregate_RaiseShouldDispatchEvents() + { + // Arrange + var handlerResult = new Dictionary(); + var services = new ServiceCollection(); + services.AddSingleton(_ => + { + var handlers = new List { new CustomerCreatedHandler(handlerResult) }; + return new Resolver(handlers); + }); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + // Act + var customer = await factory.CreateAsync(); + customer.RegisterCustomer("Integration Test Customer"); + + // Assert + Assert.That(handlerResult.Count, Is.EqualTo(1)); + Assert.That(handlerResult.Values.First(), Is.EqualTo(typeof(CustomerCreatedHandler))); + } + + [Test] + public async Task CreateAsync_WarehouseAggregate_RaiseShouldDispatchEvents() + { + // Arrange + var handlerResult = new Dictionary(); + var services = new ServiceCollection(); + services.AddSingleton(_ => + { + var handlers = new List { new OrderReceivedHandler(handlerResult) }; + return new Resolver(handlers); + }); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + // Act + var warehouse = await factory.CreateAsync(); + warehouse.ProcessOrder("ORD-456"); + + // Assert + Assert.That(handlerResult.Count, Is.EqualTo(1)); + Assert.That(handlerResult.Values.First(), Is.EqualTo(typeof(OrderReceivedHandler))); + } + + [Test] + public async Task CreateAsync_WithMultipleHandlers_ShouldDispatchToAll() + { + // Arrange + var handlerResult = new Dictionary(); + var services = new ServiceCollection(); + services.AddSingleton(_ => + { + var handlers = new List + { + new CustomerCreatedHandler(handlerResult), + new SimpleCustomerCreatedHandler() + }; + return new Resolver(handlers); + }); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + // Act + var customer = await factory.CreateAsync(); + customer.RegisterCustomer("Multi Handler Test"); + + // Assert + Assert.That(handlerResult.Count, Is.EqualTo(1)); + Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(1)); + } + + [Test] + public async Task CreateAsync_NonGeneric_ShouldCreateProxiedAggregate() + { + // Arrange & Act + var aggregate = await _aggregateFactory.CreateAsync(typeof(CustomerAggregate)); + + // Assert + Assert.That(aggregate, Is.Not.Null); + Assert.That(aggregate, Is.InstanceOf()); + } + + [Test] + public async Task CreateAggregate_UsingServiceProvider_ShouldWork() + { + // Arrange + var factory = _serviceProvider.GetRequiredService(); + + // Act + var customer = await factory.CreateAsync(); + + // Assert + Assert.That(customer, Is.Not.Null); + } + + [Test] + public async Task RaiseAsync_OnProxiedAggregate_ShouldInterceptAndDispatch() + { + // Arrange + var handlerResult = new Dictionary(); + var services = new ServiceCollection(); + services.AddSingleton(_ => + { + var handlers = new List { new CustomerCreatedHandler(handlerResult) }; + return new Resolver(handlers); + }); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + // Act + var customer = await factory.CreateAsync(); + customer.RegisterCustomer("Async Test"); + + // Assert + Assert.That(handlerResult.Count, Is.EqualTo(1)); + } + + [Test] + public void Aggregate_WithoutProxy_ShouldNotDispatchEvents() + { + // Arrange + var handlerResult = new Dictionary(); + var handlers = new List + { + new CustomerCreatedHandler(handlerResult) + }; + var resolver = new Resolver(handlers); + var publisher = new Publisher(resolver); + + // Create aggregate without proxy + var customer = new CustomerAggregate(); + + // Act + customer.RegisterCustomer("No Proxy Test"); + + // Assert - no handlers should be called since we're not using a proxy + Assert.That(handlerResult.Count, Is.EqualTo(0)); + } + } +} diff --git a/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs b/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs new file mode 100644 index 0000000..3e752e4 --- /dev/null +++ b/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs @@ -0,0 +1,130 @@ +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Unit tests for AggregateFactory. + /// + public class AggregateFactoryTests + { + private IServiceProvider _serviceProvider; + private IAggregateFactory _factory; + private Dictionary _handlerResult; + + [SetUp] + public void Setup() + { + _handlerResult = new Dictionary(); + var services = new ServiceCollection(); + services.AddSingleton(_ => + { + var handlers = new List + { + new CustomerCreatedHandler(_handlerResult), + new OrderReceivedHandler(_handlerResult) + }; + return new Resolver(handlers); + }); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + _serviceProvider = services.BuildServiceProvider(); + _factory = _serviceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [Test] + public async Task CreateAsync_ShouldReturnNonNull() + { + // Act + var aggregate = await _factory.CreateAsync(); + + // Assert + Assert.That(aggregate, Is.Not.Null); + } + + [Test] + public async Task CreateAsync_ShouldReturnCorrectType() + { + // Act + var aggregate = await _factory.CreateAsync(); + + // Assert + Assert.That(aggregate, Is.InstanceOf()); + Assert.That(aggregate, Is.InstanceOf()); + } + + [Test] + public async Task CreateAsync_NonGeneric_ShouldReturnNonNull() + { + // Act + var aggregate = await _factory.CreateAsync(typeof(CustomerAggregate)); + + // Assert + Assert.That(aggregate, Is.Not.Null); + Assert.That(aggregate, Is.InstanceOf()); + } + + [Test] + public async Task CreateAsync_ProxiedAggregate_RaiseShouldDispatchEvents() + { + // Arrange + var customer = await _factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Factory Test"); + + // Assert + Assert.That(_handlerResult.Count, Is.EqualTo(1)); + Assert.That(_handlerResult.Values.First(), Is.EqualTo(typeof(CustomerCreatedHandler))); + } + + [Test] + public async Task CreateAsync_ProxiedWarehouseAggregate_ShouldHandleEvents() + { + // Arrange + var warehouse = await _factory.CreateAsync(); + + // Act + warehouse.ProcessOrder("ORD-FACTORY"); + + // Assert - warehouse handles internally and external handlers receive + Assert.That(_handlerResult.Count, Is.GreaterThan(0)); + } + + [Test] + public async Task CreateAsync_MultipleInstances_ShouldBeIndependent() + { + // Arrange + var customer1 = await _factory.CreateAsync(); + var customer2 = await _factory.CreateAsync(); + + // Act + customer1.RegisterCustomer("Customer 1"); + customer2.RegisterCustomer("Customer 2"); + + // Assert + Assert.That(_handlerResult.Count, Is.EqualTo(2)); + } + + [Test] + public async Task CreateAsync_WithNullConstructorArgs_ShouldWork() + { + // Act & Assert + Assert.DoesNotThrowAsync(async () => await _factory.CreateAsync(null)); + } + } +} diff --git a/test/DomainEvents.Tests/Run/AggregateTests.cs b/test/DomainEvents.Tests/Run/AggregateTests.cs new file mode 100644 index 0000000..403f797 --- /dev/null +++ b/test/DomainEvents.Tests/Run/AggregateTests.cs @@ -0,0 +1,105 @@ +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Tests for Aggregate base class. + /// + public class AggregateTests + { + private IServiceProvider _serviceProvider; + private IResolver _resolver; + private IPublisher _publisher; + private IAggregateFactory _aggregateFactory; + private Dictionary _handlerResult; + + [SetUp] + public void Setup() + { + _handlerResult = new Dictionary(); + var handlers = new List + { + new CustomerCreatedHandler(_handlerResult), + new OrderReceivedHandler(_handlerResult) + }; + + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(_ => new Resolver(handlers)); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + + _serviceProvider = services.BuildServiceProvider(); + _resolver = _serviceProvider.GetRequiredService(); + _publisher = _serviceProvider.GetRequiredService(); + _aggregateFactory = _serviceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [Test] + public void Aggregate_Raise_ShouldDispatchEventToHandlers() + { + // Arrange + var customer = new CustomerAggregate(); + + // Act + customer.RegisterCustomer("John Doe"); + + // Assert - no handlers should be called since we're not using a proxy + Assert.That(_handlerResult.Count, Is.EqualTo(0)); + } + + [Test] + public async Task AggregateFactory_CreateAsync_ShouldCreateProxiedAggregate() + { + // Arrange + var customer = await _aggregateFactory.CreateAsync(); + + // Act - cast to concrete type to access RegisterCustomer + customer.RegisterCustomer("John Doe"); + + // Assert + Assert.That(_handlerResult.Count, Is.EqualTo(1)); + Assert.That(_handlerResult.Values.First(), Is.EqualTo(typeof(CustomerCreatedHandler))); + } + + [Test] + public async Task Aggregate_AsEventSubscriber_ShouldReceiveEvents() + { + // Arrange + var warehouse = await _aggregateFactory.CreateAsync(); + + // Act - cast to concrete type to access ProcessOrder + warehouse.ProcessOrder("ORD-123"); + + // Assert - The warehouse handles the event internally, plus any registered handlers + Assert.That(_handlerResult.Count, Is.GreaterThan(0)); + Assert.That(_handlerResult.ContainsValue(typeof(OrderReceivedHandler)), Is.True); + } + + [Test] + public async Task AggregateFactory_CreateAsync_WithEventType_ShouldCreateProxiedAggregate() + { + // Arrange + var aggregate = await _aggregateFactory.CreateAsync(typeof(CustomerAggregate)); + + // Assert + Assert.That(aggregate, Is.Not.Null); + Assert.That(aggregate, Is.InstanceOf()); + } + } +} diff --git a/test/DomainEvents.Tests/Run/CustomDispatcherTests.cs b/test/DomainEvents.Tests/Run/CustomDispatcherTests.cs new file mode 100644 index 0000000..084df8a --- /dev/null +++ b/test/DomainEvents.Tests/Run/CustomDispatcherTests.cs @@ -0,0 +1,166 @@ +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Tests for custom event dispatcher support. + /// + public class CustomDispatcherTests + { + [SetUp] + public void Setup() + { + TestEventDispatcher.DispatchedEvents.Clear(); + SimpleCustomerCreatedHandler.HandleCount = 0; + SimpleOrderReceivedHandler.HandleCount = 0; + } + + [Test] + public void AddDomainEvents_ShouldRegisterDefaultDispatcher() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var dispatcher = serviceProvider.GetService(); + + // Assert + Assert.That(dispatcher, Is.Not.Null); + Assert.That(dispatcher, Is.InstanceOf()); + } + + [Test] + public void AddDomainEventsWithDispatcher_Type_ShouldRegisterCustomDispatcher() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var dispatcher = serviceProvider.GetService(); + + // Assert + Assert.That(dispatcher, Is.Not.Null); + Assert.That(dispatcher, Is.InstanceOf()); + } + + [Test] + public void AddDomainEventsWithDispatcher_Instance_ShouldRegisterCustomDispatcher() + { + // Arrange + var services = new ServiceCollection(); + var customDispatcher = new TestEventDispatcher(); + services.AddDomainEventsWithDispatcher(customDispatcher, typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var dispatcher = serviceProvider.GetService(); + + // Assert + Assert.That(dispatcher, Is.Not.Null); + Assert.That(dispatcher, Is.EqualTo(customDispatcher)); + } + + [Test] + public async Task CustomDispatcher_ShouldDispatchEvents() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test Customer"); + + // Assert - custom dispatcher should have received the event + Assert.That(TestEventDispatcher.DispatchedEvents.Count, Is.EqualTo(1)); + Assert.That(TestEventDispatcher.DispatchedEvents[0], Is.InstanceOf()); + } + + [Test] + public async Task DefaultInterceptor_ShouldWorkWithCustomDispatcher() + { + // Arrange - custom dispatcher only tracks, doesn't forward to handlers + // This tests that the interceptor correctly uses the custom dispatcher + var services = new ServiceCollection(); + services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test Customer"); + + // Assert - custom dispatcher should have received the event + Assert.That(TestEventDispatcher.DispatchedEvents.Count, Is.EqualTo(1)); + } + + [Test] + public async Task DefaultDispatcher_ShouldWorkWithoutCustomDispatcher() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test Customer"); + + // Assert + Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(1)); + } + + [Test] + public void AggregateFactory_WithServiceProvider_ShouldResolveDispatcher() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var factory = serviceProvider.GetRequiredService(); + + // Assert + Assert.That(factory, Is.Not.Null); + Assert.That(factory, Is.InstanceOf()); + } + } + + /// + /// Test event dispatcher that tracks dispatched events. + /// + public class TestEventDispatcher : IEventDispatcher + { + public static List DispatchedEvents { get; } = new(); + + public void Dispatch(object @event) + { + if (@event != null) + { + DispatchedEvents.Add(@event); + } + } + + public Task DispatchAsync(object @event) + { + Dispatch(@event); + return Task.CompletedTask; + } + } +} diff --git a/test/DomainEvents.Tests/Run/DependencyInjectionTests.cs b/test/DomainEvents.Tests/Run/DependencyInjectionTests.cs new file mode 100644 index 0000000..4fe36e1 --- /dev/null +++ b/test/DomainEvents.Tests/Run/DependencyInjectionTests.cs @@ -0,0 +1,146 @@ +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Tests for Microsoft.Extensions.DependencyInjection extensions. + /// + public class DependencyInjectionTests + { + [SetUp] + public void SetUp() + { + SimpleCustomerCreatedHandler.HandleCount = 0; + SimpleOrderReceivedHandler.HandleCount = 0; + } + + [Test] + public void AddDomainEvents_ShouldRegisterPublisher() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var publisher = serviceProvider.GetService(); + Assert.That(publisher, Is.Not.Null); + Assert.That(publisher, Is.InstanceOf()); + } + + [Test] + public void AddDomainEvents_ShouldRegisterResolver() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var resolver = serviceProvider.GetService(); + Assert.That(resolver, Is.Not.Null); + Assert.That(resolver, Is.InstanceOf()); + } + + [Test] + public void AddDomainEvents_ShouldRegisterAggregateFactory() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var aggregateFactory = serviceProvider.GetService(); + Assert.That(aggregateFactory, Is.Not.Null); + } + + [Test] + public void AddDomainEvents_ShouldRegisterHandlers() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var handlers = serviceProvider.GetServices(); + Assert.That(handlers.Count(), Is.GreaterThan(0)); + Assert.That(handlers.Any(h => h is SimpleCustomerCreatedHandler), Is.True); + Assert.That(handlers.Any(h => h is SimpleOrderReceivedHandler), Is.True); + } + + [Test] + public void AddDomainEvents_ResolverShouldResolveHandlers() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolver = serviceProvider.GetRequiredService(); + var customerHandlers = resolver.ResolveAsync().Result; + + // Assert + Assert.That(customerHandlers.Count(), Is.EqualTo(1)); + Assert.That(customerHandlers.First(), Is.InstanceOf()); + } + + [Test] + public void AddDomainEvents_FullScenario_WithPublisher() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + var serviceProvider = services.BuildServiceProvider(); + + var publisher = serviceProvider.GetRequiredService(); + + // Act + var customerEvent = new CustomerCreated { Name = "Test Customer" }; + publisher.RaiseAsync(customerEvent).Wait(); + + // Assert + Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(1)); + } + + [Test] + public void AddDomainEvents_WithCallingAssembly_ShouldScanCallingAssembly() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddDomainEvents(); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var handlers = serviceProvider.GetServices(); + Assert.That(handlers.Count(), Is.GreaterThan(0)); + } + + [Test] + public void AddDomainEvents_WithNoAssemblies_ShouldThrow() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.Throws(() => services.AddDomainEvents(Array.Empty())); + } + } +} diff --git a/test/DomainEvents.Tests/Run/DomainTests.cs b/test/DomainEvents.Tests/Run/DomainTests.cs index 3a99f35..b80f786 100644 --- a/test/DomainEvents.Tests/Run/DomainTests.cs +++ b/test/DomainEvents.Tests/Run/DomainTests.cs @@ -7,16 +7,16 @@ namespace DomainEvents.Tests.Run { public class DomainTests { - private Publisher _Publisher; - private Dictionary _HandlerResult; + private Publisher _publisher; + private Dictionary _handlerResult; [SetUp] public void Setup() { - _HandlerResult = new Dictionary(); - _Publisher = new Publisher(new Resolver( - new List { new CustomerCreatedHandler(_HandlerResult), - new OrderReceivedHandler(_HandlerResult) }) + _handlerResult = new Dictionary(); + _publisher = new Publisher(new Resolver( + new List { new CustomerCreatedHandler(_handlerResult), + new OrderReceivedHandler(_handlerResult) }) ); } @@ -24,22 +24,22 @@ public void Setup() public async Task PublishCustomerCreatedTest() { var @event = new CustomerCreated { Name = "Ninja Sha!4h" }; - await _Publisher.RaiseAsync(@event); + await _publisher.RaiseAsync(@event); - Assert.That(_HandlerResult.Count, Is.EqualTo(1)); - Assert.That(_HandlerResult.ContainsKey(@event), Is.True); - Assert.That(_HandlerResult.ContainsValue(typeof(CustomerCreatedHandler)), Is.True); + Assert.That(_handlerResult.Count, Is.EqualTo(1)); + Assert.That(_handlerResult.ContainsKey(@event), Is.True); + Assert.That(_handlerResult.ContainsValue(typeof(CustomerCreatedHandler)), Is.True); } [Test] public async Task PublishOrderReceivedTest() { var @event = new OrderReceived { OrderNo = "23451GHY0WQ" }; - await _Publisher.RaiseAsync(@event); + await _publisher.RaiseAsync(@event); - Assert.That(_HandlerResult.Count, Is.EqualTo(1)); - Assert.That(_HandlerResult.ContainsKey(@event), Is.True); - Assert.That(_HandlerResult.ContainsValue(typeof(OrderReceivedHandler)), Is.True); + Assert.That(_handlerResult.Count, Is.EqualTo(1)); + Assert.That(_handlerResult.ContainsKey(@event), Is.True); + Assert.That(_handlerResult.ContainsValue(typeof(OrderReceivedHandler)), Is.True); } } } \ No newline at end of file diff --git a/test/DomainEvents.Tests/Run/EventInterceptorTests.cs b/test/DomainEvents.Tests/Run/EventInterceptorTests.cs new file mode 100644 index 0000000..75c54ef --- /dev/null +++ b/test/DomainEvents.Tests/Run/EventInterceptorTests.cs @@ -0,0 +1,158 @@ +using System.Diagnostics; +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Unit tests for EventInterceptor. + /// + public class EventInterceptorTests + { + private IServiceProvider _serviceProvider; + private IResolver _resolver; + private Dictionary _handlerResult; + + [SetUp] + public void Setup() + { + _handlerResult = new Dictionary(); + var handlers = new List + { + new CustomerCreatedHandler(_handlerResult), + new OrderReceivedHandler(_handlerResult) + }; + + var services = new ServiceCollection(); + services.AddSingleton(_ => new Resolver(handlers)); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + + _serviceProvider = services.BuildServiceProvider(); + _resolver = _serviceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [Test] + public void Intercept_WithNullLogger_ShouldNotThrow() + { + var dispatcher = _serviceProvider.GetRequiredService(); + var interceptor = new EventInterceptor(dispatcher, null); + var factory = new TestAggregateFactory(interceptor); + var customer = factory.CreateAsync().Result; + + Assert.DoesNotThrow(() => customer.RegisterCustomer("Test")); + } + + [Test] + public void Intercept_WithLogger_ShouldLog() + { + var dispatcher = _serviceProvider.GetRequiredService(); + var logger = new MockLogger(); + var interceptor = new EventInterceptor(dispatcher, logger); + var factory = new TestAggregateFactory(interceptor); + + var customer = factory.CreateAsync().Result; + + customer.RegisterCustomer("Test"); + + Assert.That(logger.LogMessages.Count, Is.GreaterThan(0)); + } + + [Test] + public void Intercept_WithNonResolver_ShouldNotDispatch() + { + var mockDispatcher = new MockEventDispatcher(); + var interceptor = new EventInterceptor(mockDispatcher); + var factory = new TestAggregateFactory(interceptor); + + var customer = factory.CreateAsync().Result; + + customer.RegisterCustomer("Test"); + + Assert.That(_handlerResult.Count, Is.EqualTo(0)); + } + + [Test] + public void Constructor_WithNullDispatcher_ShouldNotThrow() + { + Assert.DoesNotThrow(() => new EventInterceptor(null)); + } + } + + /// + /// Mock resolver for testing. + /// + public class MockResolver : IResolver + { + public Task>> ResolveAsync() where T : IDomainEvent + { + return Task.FromResult(Enumerable.Empty>()); + } + } + + /// + /// Mock event dispatcher for testing. + /// + public class MockEventDispatcher : IEventDispatcher + { + public void Dispatch(object @event) { } + public Task DispatchAsync(object @event) => Task.CompletedTask; + } + + /// + /// Mock logger for testing. + /// + public class MockLogger : ILogger + { + public List LogMessages { get; } = new List(); + + public IDisposable BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + LogMessages.Add(formatter(state, exception)); + } + } + + /// + /// Test AggregateFactory wrapper for testing with custom interceptor. + /// + public class TestAggregateFactory + { + private readonly Castle.DynamicProxy.ProxyGenerator _proxyGenerator = new(); + private readonly EventInterceptor _interceptor; + + public TestAggregateFactory(IEventDispatcher dispatcher) + { + _interceptor = new EventInterceptor(dispatcher); + } + + public TestAggregateFactory(EventInterceptor interceptor) + { + _interceptor = interceptor; + } + + public Task CreateAsync(params object[] constructorArguments) where T : Aggregate + { + var proxy = _proxyGenerator.CreateClassProxy(_interceptor); + return Task.FromResult(proxy); + } + } +} diff --git a/test/DomainEvents.Tests/Run/OpenTelemetryTests.cs b/test/DomainEvents.Tests/Run/OpenTelemetryTests.cs new file mode 100644 index 0000000..f2d33e4 --- /dev/null +++ b/test/DomainEvents.Tests/Run/OpenTelemetryTests.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using DomainEvents; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Tests for OpenTelemetry support. + /// + public class OpenTelemetryTests + { + [Test] + public void DomainEventsActivitySource_ShouldHaveCorrectName() + { + // Assert + Assert.That(DomainEventsActivitySource.ActivitySourceName, Is.EqualTo("DomainEvents")); + } + + [Test] + public void DomainEventsActivitySource_Source_ShouldNotBeNull() + { + // Assert + Assert.That(DomainEventsActivitySource.Source, Is.Not.Null); + } + + [Test] + public void DomainEventsActivitySource_ShouldHaveCorrectVersion() + { + // Act + var source = DomainEventsActivitySource.Source; + + // Assert + Assert.That(source.Version, Is.EqualTo("1.0.0")); + } + + [Test] + public void DomainEventsTags_ShouldHaveCorrectEventType() + { + // Assert + Assert.That(DomainEventsTags.EventType, Is.EqualTo("domain.event.type")); + } + + [Test] + public void DomainEventsTags_ShouldHaveCorrectHandlerType() + { + // Assert + Assert.That(DomainEventsTags.HandlerType, Is.EqualTo("domain.handler.type")); + } + + [Test] + public void DomainEventsTags_ShouldHaveCorrectAggregateType() + { + // Assert + Assert.That(DomainEventsTags.AggregateType, Is.EqualTo("domain.aggregate.type")); + } + + [Test] + public void DomainEventsTags_ShouldHaveCorrectErrorMessage() + { + // Assert + Assert.That(DomainEventsTags.ErrorMessage, Is.EqualTo("error.message")); + } + + [Test] + public void DomainEventsTags_ShouldHaveCorrectErrorType() + { + // Assert + Assert.That(DomainEventsTags.ErrorType, Is.EqualTo("error.type")); + } + + [Test] + public void ActivitySource_ShouldCreateActivity() + { + // Arrange + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == DomainEventsActivitySource.ActivitySourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData + }; + + ActivitySource.AddActivityListener(listener); + + // Act + using var activity = DomainEventsActivitySource.Source.StartActivity("TestActivity"); + + // Assert + Assert.That(activity, Is.Not.Null); + Assert.That(activity.DisplayName, Is.EqualTo("TestActivity")); + } + + [Test] + public void Activity_ShouldSetTags() + { + // Arrange + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == DomainEventsActivitySource.ActivitySourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData + }; + + ActivitySource.AddActivityListener(listener); + + // Act + using var activity = DomainEventsActivitySource.Source.StartActivity("TestActivity"); + activity?.SetTag(DomainEventsTags.EventType, "TestEvent"); + activity?.SetTag(DomainEventsTags.HandlerType, "TestHandler"); + + // Assert + Assert.That(activity?.GetTagItem(DomainEventsTags.EventType), Is.EqualTo("TestEvent")); + Assert.That(activity?.GetTagItem(DomainEventsTags.HandlerType), Is.EqualTo("TestHandler")); + } + + [Test] + public void Activity_ShouldSetStatus() + { + // Arrange + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == DomainEventsActivitySource.ActivitySourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData + }; + + ActivitySource.AddActivityListener(listener); + + // Act + using var activity = DomainEventsActivitySource.Source.StartActivity("TestActivity"); + activity?.SetStatus(ActivityStatusCode.Ok); + + // Assert + Assert.That(activity?.Status, Is.EqualTo(ActivityStatusCode.Ok)); + } + + [Test] + public void Activity_ShouldSetErrorStatus() + { + // Arrange + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == DomainEventsActivitySource.ActivitySourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData + }; + + ActivitySource.AddActivityListener(listener); + + // Act + using var activity = DomainEventsActivitySource.Source.StartActivity("TestActivity"); + activity?.SetStatus(ActivityStatusCode.Error, "Test error"); + + // Assert + Assert.That(activity?.Status, Is.EqualTo(ActivityStatusCode.Error)); + Assert.That(activity?.StatusDescription, Is.EqualTo("Test error")); + } + } +} diff --git a/version.json b/version.json index 6bbde51..9c336c0 100644 --- a/version.json +++ b/version.json @@ -17,5 +17,5 @@ }, "inherit": false, "publicReleaseRefSpec": [ "^refs/heads/master$" ], - "version": "3.0.0" + "version": "4.1.0" } \ No newline at end of file From ca94bd8585229cd307f7dec17e408d4aa297be97 Mon Sep 17 00:00:00 2001 From: Ninja Date: Sat, 14 Mar 2026 14:49:34 +0000 Subject: [PATCH 2/8] - Redefine event pub-sub flow with middleware's --- README.md | 283 ++-- RELEASE.md | 156 +-- WIKI.md | 1133 +++++++++++++++++ src/DomainEvents/DomainEvents.csproj | 1 + src/DomainEvents/EventMiddlewareBase.cs | 69 + src/DomainEvents/IEventListener.cs | 155 +++ src/DomainEvents/IEventMiddleware.cs | 59 + src/DomainEvents/IEventQueue.cs | 136 ++ src/DomainEvents/Impl/EventDispatcher.cs | 171 +-- src/DomainEvents/Impl/EventInterceptor.cs | 8 + .../ServiceCollectionExtensions.cs | 67 +- .../Run/AggregateFactoryIntegrationTests.cs | 18 +- .../Run/AggregateFactoryTests.cs | 4 +- test/DomainEvents.Tests/Run/AggregateTests.cs | 4 +- .../Run/CustomDispatcherTests.cs | 166 --- .../Run/IntegrationTests.cs | 245 ++++ .../DomainEvents.Tests/Run/MiddlewareTests.cs | 258 ++++ 17 files changed, 2363 insertions(+), 570 deletions(-) create mode 100644 WIKI.md create mode 100644 src/DomainEvents/EventMiddlewareBase.cs create mode 100644 src/DomainEvents/IEventListener.cs create mode 100644 src/DomainEvents/IEventMiddleware.cs create mode 100644 src/DomainEvents/IEventQueue.cs delete mode 100644 test/DomainEvents.Tests/Run/CustomDispatcherTests.cs create mode 100644 test/DomainEvents.Tests/Run/IntegrationTests.cs create mode 100644 test/DomainEvents.Tests/Run/MiddlewareTests.cs diff --git a/README.md b/README.md index 0619d42..463b896 100644 --- a/README.md +++ b/README.md @@ -19,152 +19,130 @@ The domain events and their side effects (the actions triggered afterwards that It's important to ensure that, just like a database transaction, either all the operations related to a domain event finish successfully or none of them do. -Figure below shows how consistency between aggregates is achieved by domain events. When the user initiates an order, the `Order Aggregate` sends an `OrderStarted` domain event. The OrderStarted domain event is handled by the `Buyer Aggregate` to create a Buyer object in the ordering microservice (bounded context). Please read [Domain Events](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation) for more details. +--- -![image](https://user-images.githubusercontent.com/6259981/204060193-d2f5241e-c1d2-46ab-a16d-1c3047bc151b.png) +## Architecture Flow ---- +``` +Aggregate -> Interceptor -> Middleware -> Dispatcher -> Queue <- Listener -> Middleware -> Resolver -> Handler +``` -## v5.x - New Features +### Components: -### 1. Async Handler Interface (IHandler) +1. **Aggregate** - Domain aggregate that raises events +2. **Interceptor** - Castle DynamicProxy interceptor (standard, with telemetry) +3. **Middleware** - Custom plugins that run before/after dispatch and handling +4. **Dispatcher** - Dispatches events to handlers (customizable) +5. **Queue** - In-flight non-persistent queue for events (optional) +6. **Listener** - Listens to queue and triggers handling +7. **Resolver** - Resolves handlers for events +8. **Handler** - Handles the events -The library now uses async handlers with `IHandler` interface: +--- -```csharp -public class CustomerCreatedHandler : IHandler -{ - public Task HandleAsync(CustomerCreated @event) - { - Console.WriteLine($"Customer created: {@event.Name}"); - return Task.CompletedTask; - } -} -``` +## v5.x - New Features -### 2. Custom Dispatcher Support +### 1. Event Middleware -You can now provide your own custom dispatcher to customize how events are dispatched to handlers. The standard EventInterceptor with telemetry remains unchanged. +Custom plugins that run at various points in the event pipeline: ```csharp -public class MyCustomDispatcher : IEventDispatcher +public class MyMiddleware : IEventMiddleware { - public void Dispatch(object @event) + public Task OnDispatchingAsync(EventContext context) { - // Custom dispatch logic - Console.WriteLine($"Dispatching event: {@event.GetType().Name}"); - - // Resolve handlers and dispatch - // ... + // Runs before event is dispatched + Console.WriteLine($"Before dispatch: {context.EventType.Name}"); + return Task.FromResult(true); // Return false to skip } - public Task DispatchAsync(object @event) + public Task OnDispatchedAsync(EventContext context) { - Dispatch(@event); + // Runs after event is dispatched return Task.CompletedTask; } -} -``` - -Register with DI: - -```csharp -// Using type -services.AddDomainEventsWithDispatcher(assembly); - -// Using instance -services.AddDomainEventsWithDispatcher(new MyCustomDispatcher(), assembly); -``` -### 3. Standard EventInterceptor with Telemetry - -The `EventInterceptor` provides standard interception with built-in telemetry: - -- OpenTelemetry activity tracking -- Logging of event dispatching -- Error handling and reporting - -The interceptor is automatically registered and should not be customized. - -### 4. Aggregate Base Class - -The library provides an `Aggregate` base class that can raise and handle domain events: - -```csharp -public class CustomerAggregate : Aggregate -{ - public void RegisterCustomer(string name) + public Task OnHandlingAsync(EventContext context) { - var @event = new CustomerCreated { Name = name }; - Raise(@event); // Automatically intercepted and dispatched + // Runs before each handler processes the event + return Task.FromResult(true); } -} -public class WarehouseAggregate : Aggregate, IHandler -{ - public Task HandleAsync(OrderReceived @event) + public Task OnHandledAsync(EventContext context) { - Console.WriteLine($"Warehouse received order: {@event.OrderNo}"); + // Runs after each handler processes the event return Task.CompletedTask; } - - public void ProcessOrder(string orderNo) - { - Raise(new OrderReceived { OrderNo = orderNo }); - } } ``` -### 5. Microsoft.Extensions.DependencyInjection Integration - -Automatically scan assemblies and register all event handlers: +Register middleware: ```csharp -public void ConfigureServices(IServiceCollection services) -{ - // Auto-scan assemblies for IHandler implementations - services.AddDomainEvents(typeof(MyHandler).Assembly); -} +services.AddDomainEvents(assembly); +services.AddSingleton(); ``` -### 6. Castle DynamicProxy Interception +### 2. Event Queue -Use the `IAggregateFactory` to create proxied aggregates with automatic event interception: +In-flight non-persistent queue for events: ```csharp -// Register in DI +// Use default in-memory queue services.AddDomainEvents(assembly); -services.AddSingleton(); -// Usage -var factory = serviceProvider.GetRequiredService(); -var customer = await factory.CreateAsync(); -customer.RegisterCustomer("John Doe"); // Automatically intercepted +// Or use custom queue +services.AddSingleton(); ``` ---- +Process queue: -## Usage Patterns +```csharp +var dispatcher = serviceProvider.GetRequiredService(); +await dispatcher.ProcessQueueAsync(); +``` -### Pattern 1: Traditional Publisher/Handler +### 3. Custom Dispatcher -1. **Define Event** - Derive from `IDomainEvent`: +Customize how events are dispatched: ```csharp -public class CustomerCreated : IDomainEvent +public class MyCustomDispatcher : IEventDispatcher { - public string Name { get; set; } + private readonly IEventDispatcher _innerDispatcher; + + public MyCustomDispatcher(IEventDispatcher innerDispatcher) + { + _innerDispatcher = innerDispatcher; + } + + public void Dispatch(object @event) + { + // Custom logic + _innerDispatcher.Dispatch(@event); + } + + public Task DispatchAsync(object @event) + { + Dispatch(@event); + return Task.CompletedTask; + } } ``` -2. **Publish** - Inject `IPublisher` and call `RaiseAsync()`: - +**Registration:** ```csharp -var @event = new CustomerCreated { Name = "John" }; -await _publisher.RaiseAsync(@event); +services.AddDomainEventsWithDispatcher(assembly); ``` -3. **Subscribe** - Implement `IHandler` interface: +### 4. Standard EventInterceptor with Telemetry + +The `EventInterceptor` provides standard interception with: +- OpenTelemetry activity tracking +- Logging +- Error handling + +### 5. Async Handler Interface (IHandler) ```csharp public class CustomerCreatedHandler : IHandler @@ -177,91 +155,39 @@ public class CustomerCreatedHandler : IHandler } ``` -4. **Auto-Register with DI**: - -```csharp -public void ConfigureServices(IServiceCollection services) -{ - services.AddDomainEvents(typeof(CustomerCreatedHandler).Assembly); -} -``` +--- -### Pattern 2: Aggregate-Based with Auto-Registration +## Usage Patterns -1. **Define Event**: +### Pattern 1: Basic Usage ```csharp -public class OrderCreated : IDomainEvent -{ - public string OrderId { get; set; } -} +services.AddDomainEvents(typeof(CustomerCreatedHandler).Assembly); ``` -2. **Create Aggregate**: +### Pattern 2: With Custom Middleware ```csharp -public class OrderAggregate : Aggregate -{ - public void CreateOrder(string customerId) - { - Raise(new OrderCreated { OrderId = Guid.NewGuid().ToString() }); - } -} +services.AddDomainEvents(assembly); +services.AddSingleton(); +services.AddSingleton(); ``` -3. **Create Handler (Aggregate that handles events)**: +### Pattern 3: With Custom Queue ```csharp -public class InventoryAggregate : Aggregate, IHandler -{ - public Task HandleAsync(OrderCreated @event) - { - Console.WriteLine($"Reserving inventory for order: {@event.OrderId}"); - return Task.CompletedTask; - } -} -``` - -4. **Auto-Register with DI**: +services.AddDomainEvents(assembly); +services.AddSingleton(); -```csharp -public void ConfigureServices(IServiceCollection services) -{ - services.AddDomainEvents(typeof(OrderCreated).Assembly); -} +// Process events from queue +var dispatcher = serviceProvider.GetRequiredService(); +await dispatcher.ProcessQueueAsync(); ``` -### Pattern 3: Custom Dispatcher - -To add custom dispatch logic (e.g., logging, filtering, transformations): +### Pattern 4: With Custom Dispatcher ```csharp -public class LoggingDispatcher : IEventDispatcher -{ - private readonly IEventDispatcher _innerDispatcher; - private readonly ILogger _logger; - - public LoggingDispatcher(IEventDispatcher innerDispatcher, ILogger logger) - { - _innerDispatcher = innerDispatcher; - _logger = logger; - } - - public void Dispatch(object @event) - { - _logger.LogInformation("Dispatching event: {EventType}", @event.GetType().Name); - _innerDispatcher.Dispatch(@event); - } - - public Task DispatchAsync(object @event) - { - Dispatch(@event); - return Task.CompletedTask; - } -} - -// Register -services.AddDomainEventsWithDispatcher(assembly); +services.AddDomainEventsWithDispatcher(assembly); ``` --- @@ -271,27 +197,32 @@ services.AddDomainEventsWithDispatcher(assembly); | Interface | Purpose | |-----------|---------| | `IDomainEvent` | Marker interface for domain events | -| `IHandler` | Async handler interface for specific event types | +| `IHandler` | Async handler interface | | `IHandler` | Marker interface for handlers | -| `IPublisher` | Interface for raising/publishing domain events | -| `IResolver` | Interface for resolving handlers for a given event type | -| `IEventInterceptor` | Interface for intercepting Raise/RaiseAsync calls (standard, with telemetry) | -| `IEventDispatcher` | Interface for dispatching events to handlers (customizable) | +| `IPublisher` | Interface for raising events | +| `IResolver` | Interface for resolving handlers | +| `IEventInterceptor` | Interceptor for Raise/RaiseAsync | +| `IEventDispatcher` | Dispatches events to handlers | +| `IEventMiddleware` | Plugin for event pipeline | +| `IEventQueue` | In-flight event queue | +| `IEventListener` | Queue listener | ## Implementation Classes | Class | Purpose | |-------|---------| -| `Aggregate` | Base class for domain aggregates with Raise() method | -| `Publisher` | Concrete implementation of `IPublisher` | -| `Resolver` | Concrete implementation of `IResolver` | -| `AggregateFactory` | Factory for creating proxied aggregate instances | -| `EventInterceptor` | Default Castle DynamicProxy interceptor with telemetry | -| `EventDispatcher` | Default implementation of `IEventDispatcher` | +| `Aggregate` | Base class for domain aggregates | +| `Publisher` | IPublisher implementation | +| `Resolver` | IResolver implementation | +| `AggregateFactory` | Creates proxied aggregates | +| `EventInterceptor` | Default interceptor with telemetry | +| `EventDispatcher` | Default dispatcher | +| `InMemoryEventQueue` | Default in-memory queue | +| `EventMiddlewareBase` | Base class for middleware | +| `LoggingMiddleware` | Built-in logging middleware | ## Package Information - **Package ID**: `Dormito.DomainEvents` - **Target Frameworks**: netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0 - **License**: MIT -- **Repository**: https://github.com/CodeShayk/DomainEvents diff --git a/RELEASE.md b/RELEASE.md index b611cbb..6b94c3a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,144 +1,82 @@ # Release Notes - v5.0.0 +## New Architecture + +``` +Aggregate -> Interceptor -> Middleware -> Dispatcher -> Queue <- Listener -> Middleware -> Resolver -> Handler +``` + +### Components: + +1. **IEventMiddleware** - Custom plugins that run before/after dispatch and handling +2. **IEventQueue** - In-flight non-persistent queue for events +3. **IEventListener** - Listens to queue and triggers handling + ## New Features -### 1. Custom Dispatcher Support -Users can now provide their own custom dispatcher to customize how events are dispatched to handlers. This enables adding custom behavior such as logging, filtering, or transformations while keeping the standard EventInterceptor with telemetry. +### 1. Event Middleware + +Custom plugins that run at various points in the event pipeline: ```csharp -public class MyCustomDispatcher : IEventDispatcher +public class MyMiddleware : IEventMiddleware { - private readonly IEventDispatcher _innerDispatcher; - - public MyCustomDispatcher(IEventDispatcher innerDispatcher) - { - _innerDispatcher = innerDispatcher; - } - - public void Dispatch(object @event) - { - // Custom logic before - Console.WriteLine($"Dispatching: {@event.GetType().Name}"); - - // Forward to inner dispatcher - _innerDispatcher.Dispatch(@event); - } - - public Task DispatchAsync(object @event) - { - Dispatch(@event); - return Task.CompletedTask; - } + public Task OnDispatchingAsync(EventContext context) { ... } + public Task OnDispatchedAsync(EventContext context) { ... } + public Task OnHandlingAsync(EventContext context) { ... } + public Task OnHandledAsync(EventContext context) { ... } } ``` **Registration:** ```csharp -// Using type -services.AddDomainEventsWithDispatcher(assembly); - -// Using instance -services.AddDomainEventsWithDispatcher(new MyCustomDispatcher(dispatcher), assembly); +services.AddDomainEvents(assembly); +services.AddSingleton(); ``` -### 2. IEventDispatcher Interface -Introduced `IEventDispatcher` interface to separate event dispatching logic from interception. Custom dispatchers can wrap the inner dispatcher for decorator patterns. +### 2. Event Queue + +In-flight non-persistent queue for events: -**Interface:** ```csharp -public interface IEventDispatcher -{ - void Dispatch(object @event); - Task DispatchAsync(object @event); -} -``` +// Use default in-memory queue +services.AddDomainEvents(assembly); -### 3. Standard EventInterceptor with Telemetry -The `EventInterceptor` remains standard and includes: -- OpenTelemetry activity tracking -- Logging of event dispatching -- Error handling and reporting +// Or use custom queue +services.AddSingleton(); -The interceptor is automatically registered and should not be customized. +// Process queue +var dispatcher = serviceProvider.GetRequiredService(); +await dispatcher.ProcessQueueAsync(); +``` -### 4. Async Handler Interface (IHandler) -Changed from synchronous `IHandle` to asynchronous `IHandler` interface: +### 3. Custom Dispatcher ```csharp -// Before (v4.x) -public interface IHandle : IHandle where T : IDomainEvent -{ - void Handle(T @event); -} - -// After (v5.x) -public interface IHandler : IHandler where T : IDomainEvent -{ - Task HandleAsync(T @event); -} +services.AddDomainEventsWithDispatcher(assembly); ``` -### 5. Service Locator Pattern in AggregateFactory -The `AggregateFactory` now uses the service locator pattern to resolve dispatchers at proxy creation time. - -## Breaking Changes - -### 1. IHandle -> IHandler -- `IHandle` renamed to `IHandler` -- `IHandle` renamed to `IHandler` -- `Handle()` method changed to `HandleAsync()` returning `Task` - -### 2. EventInterceptor Constructor -- `EventInterceptor` now requires `IEventDispatcher` instead of `IResolver` -- Constructor signature: `EventInterceptor(IEventDispatcher dispatcher, ILogger logger = null)` +### 4. Standard EventInterceptor with Telemetry +The `EventInterceptor` remains standard with OpenTelemetry and logging. -### 3. Service Registration -- `AddDomainEventsWithDispatcher()` replaces `AddDomainEventsWithInterceptor()` +### 5. Async Handler Interface (IHandler) +Changed from `IHandle` to `IHandler` with async `HandleAsync()`. -## Bug Fixes +## Breaking Changes -- Fixed issue where custom interceptors would not dispatch events to handlers +1. `IHandle` -> `IHandler` +2. `Handle()` -> `HandleAsync()` returning `Task` +3. `EventInterceptor` requires `IEventDispatcher` ## Migration Guide -### Update Handlers ```csharp // Before -public class CustomerCreatedHandler : IHandle -{ - public void Handle(CustomerCreated @event) { } -} +public class Handler : IHandle { void Handle(Event e) { } } // After -public class CustomerCreatedHandler : IHandler -{ - public Task HandleAsync(CustomerCreated @event) => Task.CompletedTask; +public class Handler : IHandler +{ + Task HandleAsync(Event e) => Task.CompletedTask; } ``` - -### Update Aggregate Implementations -```csharp -// Before -public class WarehouseAggregate : Aggregate, IHandle -{ - public void Handle(OrderReceived @event) { } -} - -// After -public class WarehouseAggregate : Aggregate, IHandler -{ - public Task HandleAsync(OrderReceived @event) => Task.CompletedTask; -} -``` - -### Adding Custom Dispatcher (instead of Interceptor) -```csharp -// Register custom dispatcher -services.AddDomainEventsWithDispatcher(assembly); -``` - -## Dependencies -- Castle.DynamicProxy -- Microsoft.Extensions.DependencyInjection.Abstractions -- Microsoft.Extensions.Logging.Abstractions -- OpenTelemetry (optional, for telemetry support) diff --git a/WIKI.md b/WIKI.md new file mode 100644 index 0000000..a2156ea --- /dev/null +++ b/WIKI.md @@ -0,0 +1,1133 @@ +# DomainEvents Library - Comprehensive Wiki + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Core Concepts](#core-concepts) +4. [Getting Started](#getting-started) +5. [Registration Methods](#registration-methods) +6. [Extension Points](#extension-points) + - [Custom Event Dispatcher](#custom-event-dispatcher) + - [Custom Event Queue](#custom-event-queue) + - [Custom Event Interceptor](#custom-event-interceptor) + - [Custom Handler Resolver](#custom-handler-resolver) + - [Event Middleware](#event-middleware) + - [Custom Aggregate Factory](#custom-aggregate-factory) +7. [Auto-Registration](#auto-registration) +8. [API Reference](#api-reference) +9. [Best Practices](#best-practices) +10. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +DomainEvents is a library for implementing transactional domain events in domain-driven design bounded contexts. It provides a robust infrastructure for raising, dispatching, and handling domain events within your application. + +### Key Features + +- **Automatic Event Dispatching**: Domain aggregates automatically dispatch events when `Raise()` or `RaiseAsync()` is called +- **Middleware Pipeline**: Hook into the event lifecycle with custom middleware +- **Event Queue**: Support for in-flight event queuing +- **OpenTelemetry Integration**: Built-in telemetry support +- **Flexible Registration**: Auto-discovery of handlers and middlewares +- **Multiple Extension Points**: Customize behavior at every layer + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Aggregate │ │ Publisher │ │ +│ │ (Raise Event) │ │ (Manual Raise) │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ EventInterceptor (Proxy) │ │ +│ │ - Castle DynamicProxy interception │ │ +│ │ - OpenTelemetry tracking │ │ +│ └─────────────────────┬───────────────────────┘ │ +│ │ │ +└────────────────────────┼──────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Middleware Pipeline │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Middleware 1 │ │ Middleware 2 │ │ Middleware N │ │ +│ │ OnDispatching │ │ OnDispatching │ │ OnDispatching │ │ +│ │ OnDispatched │ │ OnDispatched │ │ OnDispatched │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └────────────────────┼────────────────────┘ │ +│ │ │ +└────────────────────────────────┼──────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Dispatcher Layer │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ EventDispatcher │ │ +│ │ - Checks Event Queue │ │ +│ │ - Processes events │ │ +│ └─────────────────────────┬───────────────────────────┘ │ +│ │ │ +└────────────────────────────┼──────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Handler Layer │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Resolver │ │ +│ │ - Resolves handlers for event type │ │ +│ └─────────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Handler 1 │ │ Handler 2 │ │ Handler N │ │ +│ │ OnHandling │ │ OnHandling │ │ OnHandling │ │ +│ │ OnHandled │ │ OnHandled │ │ OnHandled │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└───────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Core Concepts + +### Domain Events + +Domain events represent something that happened in the domain that other parts need to be aware of: + +```csharp +public class CustomerCreated : IDomainEvent +{ + public string CustomerId { get; set; } + public string Name { get; set; } + public DateTime CreatedAt { get; set; } +} +``` + +### Event Handlers + +Handlers process domain events: + +```csharp +public class CustomerCreatedHandler : IHandler +{ + public Task HandleAsync(CustomerCreated @event) + { + // Process the event + Console.WriteLine($"Customer created: {@event.Name}"); + return Task.CompletedTask; + } +} +``` + +### Domain Aggregates + +Aggregates are domain objects that can raise events: + +```csharp +public class CustomerAggregate : Aggregate +{ + public void CreateCustomer(string name) + { + // Business logic + var @event = new CustomerCreated + { + CustomerId = Guid.NewGuid().ToString(), + Name = name, + CreatedAt = DateTime.UtcNow + }; + Raise(@event); + } +} +``` + +--- + +## Getting Started + +### 1. Install the Package + +```bash +dotnet add package Dormito.DomainEvents +``` + +### 2. Define a Domain Event + +```csharp +public class OrderPlaced : IDomainEvent +{ + public string OrderId { get; set; } + public decimal Amount { get; set; } +} +``` + +### 3. Create an Event Handler + +```csharp +public class OrderPlacedHandler : IHandler +{ + public async Task HandleAsync(OrderPlaced @event) + { + // Send confirmation email, update inventory, etc. + await SendConfirmationAsync(@event.OrderId); + } + + private Task SendConfirmationAsync(string orderId) + { + // Implementation + return Task.CompletedTask; + } +} +``` + +### 4. Create an Aggregate + +```csharp +public class OrderAggregate : Aggregate +{ + public void PlaceOrder(decimal amount) + { + // Business logic here... + + var @event = new OrderPlaced + { + OrderId = Guid.NewGuid().ToString(), + Amount = amount + }; + Raise(@event); + } +} +``` + +### 5. Register Services + +```csharp +services.AddDomainEvents(typeof(OrderPlacedHandler).Assembly); +``` + +### 6. Use in Your Application + +```csharp +public class OrderService +{ + private readonly IAggregateFactory _aggregateFactory; + + public OrderService(IAggregateFactory aggregateFactory) + { + _aggregateFactory = aggregateFactory; + } + + public async Task PlaceOrder(decimal amount) + { + var order = await _aggregateFactory.CreateAsync(); + order.PlaceOrder(amount); + // Event is automatically dispatched to handlers + } +} +``` + +--- + +## Registration Methods + +### Basic Registration + +```csharp +// Scan specific assembly +services.AddDomainEvents(typeof(OrderPlacedHandler).Assembly); + +// Scan multiple assemblies +services.AddDomainEvents( + typeof(OrderPlacedHandler).Assembly, + typeof(CustomerCreatedHandler).Assembly +); + +// Scan calling assembly +services.AddDomainEvents(); +``` + +### With Custom Dispatcher + +```csharp +services.AddDomainEventsWithDispatcher(assembly); +``` + +### With Custom Dispatcher Instance + +```csharp +var customDispatcher = new MyCustomDispatcher(); +services.AddDomainEventsWithDispatcher(customDispatcher, assembly); +``` + +### With Telemetry + +```csharp +services.AddDomainEventsWithTelemetry(assembly); +``` + +### Manual Registration (Advanced) + +```csharp +var services = new ServiceCollection(); + +// Register publisher +services.AddSingleton(); + +// Register resolver +services.AddSingleton(sp => + new Resolver(sp.GetServices())); + +// Register dispatcher +services.AddSingleton(); + +// Register interceptor +services.AddSingleton(sp => + new EventInterceptor(sp.GetRequiredService())); + +// Register aggregate factory +services.AddSingleton(); + +// Register handlers +services.AddSingleton(); +services.AddSingleton(); + +// Register middlewares +services.AddSingleton(); +``` + +--- + +## Extension Points + +### Custom Event Dispatcher + +Implement `IEventDispatcher` to customize how events are dispatched: + +```csharp +public class MyCustomDispatcher : IEventDispatcher +{ + private readonly IResolver _resolver; + private readonly IEventQueue _queue; + private readonly IEnumerable _middlewares; + private readonly ILogger _logger; + + public MyCustomDispatcher( + IResolver resolver, + IEventQueue queue = null, + IEnumerable middlewares = null, + ILogger logger = null) + { + _resolver = resolver; + _queue = queue ?? new InMemoryEventQueue(); + _middlewares = middlewares ?? Enumerable.Empty(); + _logger = logger; + } + + public void Dispatch(object @event) + { + // Custom synchronous dispatch logic + var context = new EventContext(@event); + DispatchWithMiddlewareAsync(context).GetAwaiter().GetResult(); + } + + public async Task DispatchAsync(object @event) + { + var context = new EventContext(@event); + await DispatchWithMiddlewareAsync(context); + } + + private async Task DispatchWithMiddlewareAsync(EventContext context) + { + // Run dispatch middleware (before) + foreach (var middleware in _middlewares) + { + if (!await middleware.OnDispatchingAsync(context)) + { + _logger?.LogDebug("Middleware skipped dispatching"); + return; + } + } + + // Process event + await ProcessEventAsync(context); + + context.IsDispatched = true; + + // Run dispatch middleware (after) + foreach (var middleware in _middlewares) + { + await middleware.OnDispatchedAsync(context); + } + } + + private async Task ProcessEventAsync(EventContext context) + { + var handlers = await _resolver.ResolveAsync(context.EventType); + foreach (var handler in handlers) + { + // Run handling middleware (before) + foreach (var middleware in _middlewares) + { + if (!await middleware.OnHandlingAsync(context)) + continue; + } + + // Invoke handler + // ... + + // Run handling middleware (after) + foreach (var middleware in _middlewares) + { + await middleware.OnHandledAsync(context); + } + } + } + + public IEventQueue Queue => _queue; + + public async Task ProcessQueueAsync() + { + while (_queue.Count > 0) + { + var context = await _queue.DequeueAsync(); + if (context != null) + { + await ProcessEventAsync(context); + } + } + } +} +``` + +**Registration:** + +```csharp +services.AddDomainEventsWithDispatcher(assembly); + +// Or with instance +var dispatcher = new MyCustomDispatcher(resolver); +services.AddDomainEventsWithDispatcher(dispatcher, assembly); +``` + +--- + +### Custom Event Queue + +Implement `IEventQueue` to create a custom queue (e.g., persistent queue, distributed queue): + +```csharp +public class MyCustomQueue : IEventQueue +{ + private readonly Queue _queue = new Queue(); + + public Task EnqueueAsync(EventContext context) + { + lock (_queue) + { + _queue.Enqueue(context); + } + return Task.CompletedTask; + } + +#if NET8_0_OR_GREATER + public Task DequeueAsync() +#else + public Task DequeueAsync() +#endif + { + lock (_queue) + { + if (_queue.Count > 0) + { +#if NET8_0_OR_GREATER + return Task.FromResult(_queue.Dequeue()); +#else + return Task.FromResult(_queue.Dequeue()); +#endif + } + } +#if NET8_0_OR_GREATER + return Task.FromResult(null); +#else + throw new InvalidOperationException("Queue is empty"); +#endif + } + + public IReadOnlyList PeekAll() + { + lock (_queue) + { + return _queue.ToArray(); + } + } + + public void Clear() + { + lock (_queue) + { + _queue.Clear(); + } + } + + public int Count + { + get + { + lock (_queue) + { + return _queue.Count; + } + } + } +} +``` + +**Registration:** + +```csharp +services.AddDomainEvents(assembly); +services.AddSingleton(); +``` + +--- + +### Custom Event Interceptor + +Implement `IEventInterceptor` to customize how aggregate methods are intercepted: + +```csharp +public class MyCustomInterceptor : IEventInterceptor +{ + private readonly IEventDispatcher _dispatcher; + private readonly ILogger _logger; + + public MyCustomInterceptor( + IEventDispatcher dispatcher, + ILogger logger = null) + { + _dispatcher = dispatcher; + _logger = logger; + } + + public void Intercept(IInvocation invocation) + { + var method = invocation.Method; + + // Check if it's a Raise or RaiseAsync method + if (!IsRaiseMethod(method)) + { + invocation.Proceed(); + return; + } + + var @event = invocation.Arguments[0]; + var eventType = @event.GetType(); + var methodName = method.Name; + var isAsync = methodName == "RaiseAsync"; + + _logger?.LogDebug("Intercepted {MethodName} for {EventType}", methodName, eventType.Name); + + try + { + // Proceed with the original method (executes Raise body) + invocation.Proceed(); + + // Dispatch the event + if (isAsync) + { + _dispatcher.DispatchAsync(@event).GetAwaiter().GetResult(); + } + else + { + _dispatcher.Dispatch(@event); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error dispatching event {EventType}", eventType.Name); + throw; + } + } + + private static bool IsRaiseMethod(MethodInfo method) + { + return method.Name == "Raise" || method.Name == "RaiseAsync"; + } +} +``` + +**Registration:** + +```csharp +services.AddDomainEvents(assembly); +services.AddSingleton(); +``` + +--- + +### Custom Handler Resolver + +Implement `IResolver` to customize how handlers are resolved: + +```csharp +public class MyCustomResolver : IResolver +{ + private readonly IEnumerable _handlers; + private readonly Dictionary> _handlerCache; + + public MyCustomResolver(IEnumerable handlers) + { + _handlers = handlers; + _handlerCache = new Dictionary>(); + + // Build handler cache + foreach (var handler in _handlers) + { + var handlerType = handler.GetType(); + var interfaces = handlerType.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IHandler<>)); + + foreach (var iface in interfaces) + { + var eventType = iface.GetGenericArguments()[0]; + if (!_handlerCache.ContainsKey(eventType)) + { + _handlerCache[eventType] = new List(); + } + _handlerCache[eventType].Add(handler); + } + } + } + + public Task>> ResolveAsync() where T : IDomainEvent + { + var eventType = typeof(T); + + if (_handlerCache.TryGetValue(eventType, out var handlers)) + { + var typedHandlers = handlers.Cast>(); + return Task.FromResult>>(typedHandlers); + } + + return Task.FromResult>>(Enumerable.Empty>()); + } +} +``` + +**Registration:** + +```csharp +services.AddSingleton(); +``` + +--- + +### Event Middleware + +Middleware allows you to hook into the event pipeline at various points: + +```csharp +public class MyMiddleware : IEventMiddleware +{ + private readonly ILogger _logger; + + public MyMiddleware(ILogger logger) + { + _logger = logger; + } + + // Called before event is dispatched to handlers + public Task OnDispatchingAsync(EventContext context) + { + _logger.LogInformation("About to dispatch event: {EventType}", context.EventType.Name); + + // Return false to skip dispatching + // Return true to continue + return Task.FromResult(true); + } + + // Called after event has been dispatched to all handlers + public Task OnDispatchedAsync(EventContext context) + { + _logger.LogInformation("Event dispatched: {EventType}", context.EventType.Name); + return Task.CompletedTask; + } + + // Called before each handler processes the event + public Task OnHandlingAsync(EventContext context) + { + _logger.LogDebug("About to handle event: {EventType}", context.EventType.Name); + return Task.FromResult(true); + } + + // Called after each handler processes the event + public Task OnHandledAsync(EventContext context) + { + _logger.LogDebug("Event handled: {EventType}", context.EventType.Name); + return Task.CompletedTask; + } +} +``` + +**Using the Base Class:** + +```csharp +public class LoggingMiddleware : EventMiddlewareBase +{ + private readonly ILogger _logger; + + public LoggingMiddleware(ILogger logger) + { + _logger = logger; + } + + public override Task OnDispatchingAsync(EventContext context) + { + _logger.LogInformation("Event dispatching: {EventType}", context.EventType.Name); + return base.OnDispatchingAsync(context); + } + + public override Task OnDispatchedAsync(EventContext context) + { + _logger.LogInformation("Event dispatched: {EventType}", context.EventType.Name); + return base.OnDispatchedAsync(context); + } +} +``` + +**Registration:** + +```csharp +// Manual registration +services.AddDomainEvents(assembly); +services.AddSingleton(); + +// Or auto-registration (requires parameterless constructor) +services.AddDomainEvents(assembly); +// Middlewares with parameterless constructors are auto-registered +``` + +**Middleware with Dependencies:** + +If your middleware requires dependencies, register it manually (not auto-registered): + +```csharp +services.AddSingleton(sp => + new MyMiddleware(sp.GetRequiredService>())); +``` + +--- + +### Custom Aggregate Factory + +Implement `IAggregateFactory` to customize how aggregates are created: + +```csharp +public class MyAggregateFactory : IAggregateFactory +{ + private readonly ProxyGenerator _proxyGenerator; + private readonly IServiceProvider _serviceProvider; + + public MyAggregateFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _proxyGenerator = new ProxyGenerator(); + } + + public Task CreateAsync(params object[] constructorArguments) where T : Aggregate + { + var interceptor = _serviceProvider.GetService(); + + if (interceptor == null) + { + throw new InvalidOperationException("IEventInterceptor not registered"); + } + + var proxy = _proxyGenerator.CreateClassProxy(interceptor); + return Task.FromResult(proxy); + } + + public Task CreateAsync(Type aggregateType, params object[] constructorArguments) + { + var interceptor = _serviceProvider.GetService(); + + if (interceptor == null) + { + throw new InvalidOperationException("IEventInterceptor not registered"); + } + + var proxy = (IDomainAggregate)_proxyGenerator.CreateClassProxy(aggregateType, interceptor); + return Task.FromResult(proxy); + } +} +``` + +**Registration:** + +```csharp +services.AddSingleton(); +``` + +--- + +## Auto-Registration + +The library automatically discovers and registers components from specified assemblies: + +### What Gets Auto-Registered + +| Component | Requirement | Behavior | +|-----------|-------------|----------| +| Event Handlers (`IHandler`) | Parameterless constructor | Singleton | +| Event Middleware (`IEventMiddleware`) | Parameterless constructor | Singleton | + +### Auto-Registration Behavior + +1. **Handlers**: All types implementing `IHandler` with parameterless constructors are registered +2. **Middlewares**: All types implementing `IEventMiddleware` with parameterless constructors are registered +3. **Manual Override**: If you manually register a service before calling `AddDomainEvents`, the auto-registration skips that specific type + +### Example: Auto-Registration + +```csharp +// This will auto-register all handlers and middlewares +services.AddDomainEvents(typeof(MyHandler).Assembly); +``` + +### Example: Preventing Auto-Registration + +To prevent a middleware from being auto-registered, add a constructor with parameters: + +```csharp +// Won't be auto-registered (has constructor parameter) +public class MyMiddleware : IEventMiddleware +{ + public MyMiddleware(string name) { } // Requires parameter + + // ... interface implementations +} + +// Will be auto-registered (parameterless constructor) +public class AnotherMiddleware : IEventMiddleware +{ + public AnotherMiddleware() { } // Parameterless + + // ... interface implementations +} +``` + +--- + +## API Reference + +### Interfaces + +| Interface | Description | +|-----------|-------------| +| `IDomainEvent` | Marker interface for domain events | +| `IHandler` | Async handler interface for specific event type | +| `IHandler` | Marker interface for handlers | +| `IPublisher` | Interface for manually raising events | +| `IResolver` | Interface for resolving handlers | +| `IEventDispatcher` | Interface for dispatching events | +| `IEventInterceptor` | Interceptor for aggregate Raise/RaiseAsync methods | +| `IEventMiddleware` | Middleware for event pipeline | +| `IEventQueue` | Queue for in-flight events | +| `IEventListener` | Listener for processing queued events | +| `IAggregateFactory` | Factory for creating proxied aggregates | + +### Classes + +| Class | Description | +|-------|-------------| +| `Aggregate` | Base class for domain aggregates | +| `EventContext` | Context passed to middleware | +| `Publisher` | Default implementation of IPublisher | +| `Resolver` | Default implementation of IResolver | +| `EventDispatcher` | Default implementation of IEventDispatcher | +| `EventInterceptor` | Default interceptor with telemetry | +| `AggregateFactory` | Default factory for proxied aggregates | +| `InMemoryEventQueue` | Default in-memory queue implementation | +| `EventMiddlewareBase` | Base class for middleware | +| `LoggingMiddleware` | Built-in logging middleware | + +### ServiceCollectionExtensions + +| Method | Description | +|--------|-------------| +| `AddDomainEvents(assemblies)` | Register with default configuration | +| `AddDomainEvents()` | Register for calling assembly | +| `AddDomainEventsWithDispatcher(assemblies)` | Register with custom dispatcher type | +| `AddDomainEventsWithDispatcher(dispatcher, assemblies)` | Register with custom dispatcher instance | +| `AddDomainEventsWithTelemetry(assemblies)` | Register with OpenTelemetry support | + +--- + +## Best Practices + +### 1. Keep Handlers Focused + +Each handler should do one thing: + +```csharp +// Good +public class OrderConfirmationHandler : IHandler +{ + public Task HandleAsync(OrderPlaced @event) => + SendEmailAsync(@event.CustomerId, "Order confirmed"); +} + +public class InventoryHandler : IHandler +{ + public Task HandleAsync(OrderPlaced @event) => + ReserveInventoryAsync(@event.Items); +} + +// Avoid - handlers doing too much +public class OrderPlacedHandler : IHandler +{ + public Task HandleAsync(OrderPlaced @event) + { + // Don't do email, inventory, analytics, etc. all here + } +} +``` + +### 2. Use Middleware for Cross-Cutting Concerns + +```csharp +public class AuditMiddleware : EventMiddlewareBase +{ + private readonly IAuditService _auditService; + + public AuditMiddleware(IAuditService auditService) + { + _auditService = auditService; + } + + public override async Task OnDispatchedAsync(EventContext context) + { + await _auditService.LogAsync(context.Event, context.EventType.Name); + } +} +``` + +### 3. Handle Errors in Middleware + +```csharp +public class ErrorHandlingMiddleware : EventMiddlewareBase +{ + private readonly ILogger _logger; + + public ErrorHandlingMiddleware(ILogger logger) + { + _logger = logger; + } + + public override async Task OnDispatchedAsync(EventContext context) + { + if (context.IsDispatched) + { + _logger.LogInformation("Successfully handled {EventType}", context.EventType.Name); + } + } +} +``` + +### 4. Use EventContext.Items for State Sharing + +```csharp +public class TrackingMiddleware : EventMiddlewareBase +{ + public override Task OnDispatchingAsync(EventContext context) + { + context.Items["CorrelationId"] = Guid.NewGuid(); + return base.OnDispatchingAsync(context); + } +} + +public class AnotherMiddleware : EventMiddlewareBase +{ + public override Task OnHandledAsync(EventContext context) + { + var correlationId = context.Items["CorrelationId"]; + // Use correlation ID for logging/tracing + return base.OnHandledAsync(context); + } +} +``` + +### 5. Don't Block in Middleware + +```csharp +// Bad - blocks the thread +public Task OnDispatchingAsync(EventContext context) +{ + Thread.Sleep(1000); // Don't do this + return Task.FromResult(true); +} + +// Good - async/await +public async Task OnDispatchingAsync(EventContext context) +{ + await Task.Delay(1000); // Non-blocking + return true; +} +``` + +--- + +## Troubleshooting + +### Events Not Being Dispatched + +1. **Check if aggregate is proxied**: + ```csharp + // Use IAggregateFactory to create aggregates + var order = await aggregateFactory.CreateAsync(); + order.PlaceOrder(100); // This will dispatch events + + // Direct instantiation won't dispatch + var order2 = new OrderAggregate(); + order2.PlaceOrder(100); // Events won't be dispatched + ``` + +2. **Check handler registration**: + ```csharp + var handlers = serviceProvider.GetServices(); + // Should contain your handlers + ``` + +3. **Check middleware returning false**: + ```csharp + // If any middleware returns false in OnDispatchingAsync, events won't be dispatched + public Task OnDispatchingAsync(EventContext context) + { + return Task.FromResult(false); // This blocks dispatch + } + ``` + +### Middleware Not Called + +1. **Check registration**: + ```csharp + // Make sure middleware is registered + services.AddSingleton(); + ``` + +2. **Check constructor**: + ```csharp + // Middleware must have parameterless constructor OR be manually registered + public class MyMiddleware : IEventMiddleware + { + // This requires manual registration + public MyMiddleware(ILogger logger) { } + } + ``` + +### Handlers Not Found + +1. **Check assembly scanning**: + ```csharp + // Make sure the assembly contains handlers + services.AddDomainEvents(typeof(MyHandler).Assembly); + ``` + +2. **Check handler interface**: + ```csharp + // Must implement IHandler where T : IDomainEvent + public class MyHandler : IHandler // Correct + { + public Task HandleAsync(MyEvent e) => Task.CompletedTask; + } + ``` + +### Queue Not Processing + +1. **Call ProcessQueueAsync**: + ```csharp + var dispatcher = serviceProvider.GetRequiredService(); + await dispatcher.ProcessQueueAsync(); + ``` + +2. **Check queue is registered**: + ```csharp + services.AddSingleton(); + ``` + +--- + +## Migration Guide + +### From v4 to v5 + +v5 introduces breaking changes: + +1. **Event Dispatcher now receives middlewares**: + ```csharp + // v4 + services.AddSingleton(sp => + new EventDispatcher(sp.GetRequiredService())); + + // v5 + services.AddSingleton(sp => + new EventDispatcher( + sp.GetRequiredService(), + sp.GetService(), + sp.GetServices(), + sp.GetService>())); + ``` + +2. **Use AddDomainEvents for full setup**: + ```csharp + // Recommended + services.AddDomainEvents(assembly); + + // Manual registration is still supported for advanced scenarios + ``` + +### Adding to Existing Project + +1. Install the package: + ```bash + dotnet add package Dormito.DomainEvents + ``` + +2. Update registration: + ```csharp + services.AddDomainEvents(typeof(YourHandler).Assembly); + ``` + +3. Use IAggregateFactory: + ```csharp + public class OrderService + { + private readonly IAggregateFactory _factory; + + public OrderService(IAggregateFactory factory) + { + _factory = factory; + } + + public async Task PlaceOrder() + { + var order = await _factory.CreateAsync(); + order.Place(100); + } + } + ``` + +--- + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/src/DomainEvents/DomainEvents.csproj b/src/DomainEvents/DomainEvents.csproj index 602560f..8a6b15d 100644 --- a/src/DomainEvents/DomainEvents.csproj +++ b/src/DomainEvents/DomainEvents.csproj @@ -60,6 +60,7 @@ + diff --git a/src/DomainEvents/EventMiddlewareBase.cs b/src/DomainEvents/EventMiddlewareBase.cs new file mode 100644 index 0000000..7fb7765 --- /dev/null +++ b/src/DomainEvents/EventMiddlewareBase.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace DomainEvents +{ + /// + /// Base class for event middleware with no-op implementations. + /// + public abstract class EventMiddlewareBase : IEventMiddleware + { + public virtual Task OnDispatchingAsync(EventContext context) + { + return Task.FromResult(true); + } + + public virtual Task OnDispatchedAsync(EventContext context) + { + return Task.CompletedTask; + } + + public virtual Task OnHandlingAsync(EventContext context) + { + return Task.FromResult(true); + } + + public virtual Task OnHandledAsync(EventContext context) + { + return Task.CompletedTask; + } + } + + /// + /// Logging middleware for events. + /// + public class LoggingMiddleware : EventMiddlewareBase + { + private readonly ILogger _logger; + + public LoggingMiddleware(ILogger logger) + { + _logger = logger; + } + + public override Task OnDispatchingAsync(EventContext context) + { + _logger.LogInformation("Event dispatching: {EventType}", context.EventType.Name); + return base.OnDispatchingAsync(context); + } + + public override Task OnDispatchedAsync(EventContext context) + { + _logger.LogInformation("Event dispatched: {EventType}", context.EventType.Name); + return base.OnDispatchedAsync(context); + } + + public override Task OnHandlingAsync(EventContext context) + { + _logger.LogDebug("Event handling: {EventType}", context.EventType.Name); + return base.OnHandlingAsync(context); + } + + public override Task OnHandledAsync(EventContext context) + { + _logger.LogDebug("Event handled: {EventType}", context.EventType.Name); + return base.OnHandledAsync(context); + } + } +} diff --git a/src/DomainEvents/IEventListener.cs b/src/DomainEvents/IEventListener.cs new file mode 100644 index 0000000..6c817f3 --- /dev/null +++ b/src/DomainEvents/IEventListener.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace DomainEvents +{ + /// + /// Listener that processes events from the queue via subscription. + /// + public interface IEventListener + { + /// + /// Starts listening to the event queue. + /// + /// Cancellation token. + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Stops listening to the event queue. + /// + Task StopAsync(); + } + + /// + /// Default implementation of IEventListener that subscribes to the queue. + /// Processes events immediately when they are enqueued. + /// + public class EventListener : IEventListener + { + private readonly IEventQueue _queue; + private readonly IResolver _resolver; + private readonly IEnumerable _middlewares; + private readonly ILogger _logger; + private CancellationTokenSource _cts; + + public EventListener( + IEventQueue queue, + IResolver resolver, + IEnumerable middlewares = null, + ILogger logger = null) + { + _queue = queue; + _resolver = resolver; + _middlewares = middlewares ?? Enumerable.Empty(); + _logger = logger; + + _queue.Subscribe(OnEventEnqueued); + _logger?.LogInformation("EventListener created and subscribed to queue"); + } + + private Task OnEventEnqueued(EventContext context) + { + return ProcessEventAsync(context); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _logger?.LogInformation("Event listener started"); + return Task.CompletedTask; + } + + public async Task StopAsync() + { + _cts?.Cancel(); + _logger?.LogInformation("Event listener stopped"); + } + + public async Task ProcessEventAsync(EventContext context) + { + var eventType = context.EventType; + + if (!(_resolver is Impl.Resolver resolver)) + { + _logger?.LogWarning("Resolver is not of type Resolver, cannot dispatch event"); + return; + } + + var handlers = await resolver.ResolveAsync(eventType); + var handlerList = handlers.ToList(); + + _logger?.LogDebug("Found {HandlerCount} handlers for event {EventType}", handlerList.Count, eventType.Name); + + var exceptions = new List(); + + foreach (var handler in handlerList) + { + var handlerType = handler.GetType(); + + var activity = DomainEventsActivitySource.Source.StartActivity( + DomainEventsActivitySource.HandleEventActivityName, + ActivityKind.Internal); + + if (activity != null) + { + activity.SetTag(DomainEventsTags.EventType, eventType.Name); + activity.SetTag(DomainEventsTags.HandlerType, handlerType.Name); + } + + try + { + foreach (var middleware in _middlewares) + { + if (!await middleware.OnHandlingAsync(context)) + { + _logger?.LogDebug("Middleware skipped handling for {EventType}", eventType.Name); + continue; + } + } + + var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(eventType); + var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); + handleMethod?.Invoke(handler, new[] { context.Event }); + + context.IsHandled = true; + + foreach (var middleware in _middlewares) + { + await middleware.OnHandledAsync(context); + } + + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Ok); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error in handler {HandlerType} for event {EventType}", + handlerType.Name, eventType.Name); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); + activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); + } + exceptions.Add(ex); + } + finally + { + activity?.Dispose(); + } + } + + if (exceptions.Count > 0) + { + throw new AggregateException($"Errors occurred while dispatching event {eventType.Name}", exceptions); + } + } + } +} diff --git a/src/DomainEvents/IEventMiddleware.cs b/src/DomainEvents/IEventMiddleware.cs new file mode 100644 index 0000000..99b549d --- /dev/null +++ b/src/DomainEvents/IEventMiddleware.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DomainEvents +{ + /// + /// Middleware interface for processing events before dispatch and before handling. + /// + public interface IEventMiddleware + { + /// + /// Called before an event is dispatched to handlers. + /// + /// The event context. + /// True to continue, false to skip dispatch. + Task OnDispatchingAsync(EventContext context); + + /// + /// Called after an event has been dispatched to handlers. + /// + /// The event context. + Task OnDispatchedAsync(EventContext context); + + /// + /// Called before an event is handled by a handler. + /// + /// The event context. + /// True to continue, false to skip handling. + Task OnHandlingAsync(EventContext context); + + /// + /// Called after an event has been handled by a handler. + /// + /// The event context. + Task OnHandledAsync(EventContext context); + } + + /// + /// Context passed to middleware containing event and metadata. + /// + public class EventContext + { + public object Event { get; } + public Type EventType { get; } + public DateTime Timestamp { get; } + public bool IsHandled { get; set; } + public bool IsDispatched { get; set; } + public Dictionary Items { get; } + + public EventContext(object @event) + { + Event = @event; + EventType = @event.GetType(); + Timestamp = DateTime.UtcNow; + Items = new Dictionary(); + } + } +} diff --git a/src/DomainEvents/IEventQueue.cs b/src/DomainEvents/IEventQueue.cs new file mode 100644 index 0000000..2cf17f6 --- /dev/null +++ b/src/DomainEvents/IEventQueue.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace DomainEvents +{ + /// + /// Delegate type for processing dequeued events. + /// + /// The event context to process. + /// A task representing the asynchronous operation. + public delegate Task EventDequeuedHandler(EventContext context); + + /// + /// In-flight non-persistent queue for events with subscription support. + /// + public interface IEventQueue + { + /// + /// Enqueues an event for async processing. + /// + /// The event context. + Task EnqueueAsync(EventContext context); + + /// + /// Dequeues an event for processing. + /// + /// The event context or null if queue is empty. +#if NET8_0_OR_GREATER + Task DequeueAsync(); +#else + Task DequeueAsync(); +#endif + + /// + /// Gets all queued events. + /// + IReadOnlyList PeekAll(); + + /// + /// Clears all queued events. + /// + void Clear(); + + /// + /// Gets the count of queued events. + /// + int Count { get; } + + /// + /// Registers a handler that will be called when an event is enqueued. + /// Only one handler can be registered. Calling this again replaces the previous handler. + /// + /// The handler to call when an event is enqueued. + void Subscribe(EventDequeuedHandler handler); + } + + /// + /// Default in-memory implementation of IEventQueue with subscription support. + /// + public class InMemoryEventQueue : IEventQueue + { + private readonly Queue _queue = new Queue(); + private EventDequeuedHandler _handler; + private readonly object _lock = new object(); + + public Task EnqueueAsync(EventContext context) + { + lock (_lock) + { + _queue.Enqueue(context); + } + + _handler?.Invoke(context); + + return Task.CompletedTask; + } + +#if NET8_0_OR_GREATER + public Task DequeueAsync() +#else + public Task DequeueAsync() +#endif + { + lock (_lock) + { + if (_queue.Count > 0) + { +#if NET8_0_OR_GREATER + return Task.FromResult(_queue.Dequeue()); +#else + return Task.FromResult(_queue.Dequeue()); +#endif + } + } +#if NET8_0_OR_GREATER + return Task.FromResult(null); +#else + throw new InvalidOperationException("Queue is empty"); +#endif + } + + public IReadOnlyList PeekAll() + { + lock (_lock) + { + return _queue.ToArray(); + } + } + + public void Clear() + { + lock (_lock) + { + _queue.Clear(); + } + } + + public int Count + { + get + { + lock (_lock) + { + return _queue.Count; + } + } + } + + public void Subscribe(EventDequeuedHandler handler) + { + _handler = handler; + } + } +} diff --git a/src/DomainEvents/Impl/EventDispatcher.cs b/src/DomainEvents/Impl/EventDispatcher.cs index 4497199..a3e2ac1 100644 --- a/src/DomainEvents/Impl/EventDispatcher.cs +++ b/src/DomainEvents/Impl/EventDispatcher.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using DomainEvents.Impl; using Microsoft.Extensions.Logging; @@ -10,152 +11,104 @@ namespace DomainEvents { /// /// Default implementation of IEventDispatcher that dispatches events to registered handlers. + /// Events are enqueued and the dispatcher completes immediately. Listeners process via queue subscription. /// public class EventDispatcher : IEventDispatcher { private readonly IResolver _resolver; + private readonly IEventQueue _queue; + private readonly IEnumerable _middlewares; private readonly ILogger _logger; - public EventDispatcher(IResolver resolver, ILogger logger = null) + public EventDispatcher( + IResolver resolver, + IEventQueue queue = null, + IEnumerable middlewares = null, + ILogger logger = null) { _resolver = resolver; + _queue = queue ?? new InMemoryEventQueue(); + _middlewares = middlewares ?? Enumerable.Empty(); _logger = logger; } + public IEventQueue Queue => _queue; + public void Dispatch(object @event) { if (@event == null) return; - var eventType = @event.GetType(); - _logger?.LogDebug("Dispatching event {EventType}", eventType.Name); - - if (!(_resolver is Resolver resolver)) - { - _logger?.LogWarning("Resolver is not of type Resolver, cannot dispatch event"); - return; - } - - var handlers = resolver.ResolveAsync(eventType).GetAwaiter().GetResult(); - var handlerList = handlers.ToList(); - - _logger?.LogDebug("Found {HandlerCount} handlers for event {EventType}", handlerList.Count, eventType.Name); - - var exceptions = new List(); - - foreach (var handler in handlerList) - { - var handlerType = handler.GetType(); - - var activity = DomainEventsActivitySource.Source.StartActivity( - DomainEventsActivitySource.HandleEventActivityName, - ActivityKind.Internal); - - if (activity != null) - { - activity.SetTag(DomainEventsTags.EventType, eventType.Name); - activity.SetTag(DomainEventsTags.HandlerType, handlerType.Name); - } - - try - { - var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(eventType); - var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); - handleMethod?.Invoke(handler, new[] { @event }); - if (activity != null) - { - activity.SetStatus(ActivityStatusCode.Ok); - } - } - catch (Exception ex) - { - _logger?.LogError(ex, "Error in handler {HandlerType} for event {EventType}", - handlerType.Name, eventType.Name); - if (activity != null) - { - activity.SetStatus(ActivityStatusCode.Error, ex.Message); - activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); - activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); - } - exceptions.Add(ex); - } - finally - { - activity?.Dispose(); - } - } - - if (exceptions.Count > 0) - { - throw new AggregateException($"Errors occurred while dispatching event {eventType.Name}", exceptions); - } + var context = new EventContext(@event); + + DispatchWithMiddlewareAsync(context).GetAwaiter().GetResult(); } public async Task DispatchAsync(object @event) { if (@event == null) return; - var eventType = @event.GetType(); - _logger?.LogDebug("Dispatching event async {EventType}", eventType.Name); - - if (!(_resolver is Resolver resolver)) - { - _logger?.LogWarning("Resolver is not of type Resolver, cannot dispatch event"); - return; - } - - var handlers = await resolver.ResolveAsync(eventType); - var handlerList = handlers.ToList(); + var context = new EventContext(@event); + await DispatchWithMiddlewareAsync(context); + } - _logger?.LogDebug("Found {HandlerCount} handlers for event {EventType}", handlerList.Count, eventType.Name); + private async Task DispatchWithMiddlewareAsync(EventContext context) + { + var eventType = context.EventType; + _logger?.LogDebug("Dispatching event {EventType}", eventType.Name); - var exceptions = new List(); + var activity = DomainEventsActivitySource.Source.StartActivity( + DomainEventsActivitySource.PublishEventActivityName, + ActivityKind.Internal); - foreach (var handler in handlerList) + if (activity != null) { - var handlerType = handler.GetType(); - - var activity = DomainEventsActivitySource.Source.StartActivity( - DomainEventsActivitySource.HandleEventActivityName, - ActivityKind.Internal); + activity.SetTag(DomainEventsTags.EventType, eventType.Name); + } - if (activity != null) + try + { + foreach (var middleware in _middlewares) { - activity.SetTag(DomainEventsTags.EventType, eventType.Name); - activity.SetTag(DomainEventsTags.HandlerType, handlerType.Name); + if (!await middleware.OnDispatchingAsync(context)) + { + _logger?.LogDebug("Middleware {Middleware} skipped dispatching for {EventType}", + middleware.GetType().Name, eventType.Name); + return; + } } - try + await _queue.EnqueueAsync(context); + _logger?.LogDebug("Event {EventType} enqueued", eventType.Name); + + context.IsDispatched = true; + + foreach (var middleware in _middlewares) { - var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(eventType); - var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); - handleMethod?.Invoke(handler, new[] { @event }); - if (activity != null) - { - activity.SetStatus(ActivityStatusCode.Ok); - } + await middleware.OnDispatchedAsync(context); } - catch (Exception ex) + + _logger?.LogDebug("Successfully dispatched event {EventType}", eventType.Name); + + if (activity != null) { - _logger?.LogError(ex, "Error in handler {HandlerType} for event {EventType}", - handlerType.Name, eventType.Name); - if (activity != null) - { - activity.SetStatus(ActivityStatusCode.Error, ex.Message); - activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); - activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); - } - exceptions.Add(ex); + activity.SetStatus(ActivityStatusCode.Ok); } - finally + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error dispatching event {EventType}", eventType.Name); + if (activity != null) { - activity?.Dispose(); + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); + activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); } + throw; } - - if (exceptions.Count > 0) + finally { - throw new AggregateException($"Errors occurred while dispatching event {eventType.Name}", exceptions); + activity?.Dispose(); } } } -} \ No newline at end of file +} diff --git a/src/DomainEvents/Impl/EventInterceptor.cs b/src/DomainEvents/Impl/EventInterceptor.cs index f7e0fb2..4702140 100644 --- a/src/DomainEvents/Impl/EventInterceptor.cs +++ b/src/DomainEvents/Impl/EventInterceptor.cs @@ -28,6 +28,14 @@ public EventInterceptor(IEventDispatcher dispatcher, ILogger l _logger = logger; } + /// + /// Constructor that also resolves IEventListener to ensure subscription is registered. + /// + public EventInterceptor(IEventDispatcher dispatcher, IEventListener eventListener, ILogger logger = null) + : this(dispatcher, logger) + { + } + /// /// Intercepts the Raise/RaiseAsync method call and dispatches the event to handlers. /// diff --git a/src/DomainEvents/ServiceCollectionExtensions.cs b/src/DomainEvents/ServiceCollectionExtensions.cs index d66b147..f9cf5af 100644 --- a/src/DomainEvents/ServiceCollectionExtensions.cs +++ b/src/DomainEvents/ServiceCollectionExtensions.cs @@ -28,6 +28,9 @@ public static IServiceCollection AddDomainEvents(this IServiceCollection service throw new ArgumentException("At least one assembly must be specified", nameof(assemblies)); } + // Register the event queue + services.AddSingleton(); + // Register the publisher services.AddSingleton(); @@ -42,16 +45,29 @@ public static IServiceCollection AddDomainEvents(this IServiceCollection service services.AddSingleton(sp => { var resolver = sp.GetRequiredService(); + var queue = sp.GetRequiredService(); + var middlewares = sp.GetServices(); var logger = sp.GetService>(); - return new EventDispatcher(resolver, logger); + return new EventDispatcher(resolver, queue, middlewares, logger); + }); + + // Register EventListener to handle queue subscription + services.AddSingleton(sp => + { + var queue = sp.GetRequiredService(); + var resolver = sp.GetRequiredService(); + var middlewares = sp.GetServices(); + var logger = sp.GetService>(); + return new EventListener(queue, resolver, middlewares, logger); }); // Register the default event interceptor services.AddSingleton(sp => { var dispatcher = sp.GetRequiredService(); + var listener = sp.GetRequiredService(); // Required to trigger subscription var logger = sp.GetService>(); - return new EventInterceptor(dispatcher, logger); + return new EventInterceptor(dispatcher, listener, logger); }); // Register the aggregate factory @@ -63,13 +79,34 @@ public static IServiceCollection AddDomainEvents(this IServiceCollection service var handlerTypes = assembly.GetTypes() .Where(t => !t.IsAbstract && !t.IsInterface) .Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IHandler<>))) - .Where(t => t.GetConstructor(Type.EmptyTypes) != null); // Only parameterless constructors + .Where(t => t.GetConstructor(Type.EmptyTypes) != null); foreach (var handlerType in handlerTypes) { services.AddSingleton(typeof(IHandler), handlerType); services.AddSingleton(handlerType); } + + // Auto-register IEventMiddleware implementations with parameterless constructors + var middlewareTypes = assembly.GetTypes() + .Where(t => !t.IsAbstract && !t.IsInterface) + .Where(t => typeof(IEventMiddleware).IsAssignableFrom(t)) + .Where(t => t.GetConstructor(Type.EmptyTypes) != null); + + foreach (var middlewareType in middlewareTypes) + { + bool isAlreadyRegistered = services.Any(s => + (s.ServiceType == typeof(IEventMiddleware) && + (s.ImplementationType == middlewareType || + (s.ImplementationInstance != null && s.ImplementationInstance.GetType() == middlewareType))) || + s.ServiceType == middlewareType); + + if (!isAlreadyRegistered) + { + services.AddSingleton(typeof(IEventMiddleware), middlewareType); + services.AddSingleton(middlewareType); + } + } } return services; @@ -149,6 +186,9 @@ public static IServiceCollection AddDomainEventsWithTelemetry(this IServiceColle /// private static IServiceCollection AddDomainEventsCore(this IServiceCollection services, Assembly[] assemblies) { + // Register the event queue + services.AddSingleton(); + // Register the publisher services.AddSingleton(); @@ -175,6 +215,27 @@ private static IServiceCollection AddDomainEventsCore(this IServiceCollection se services.AddSingleton(typeof(IHandler), handlerType); services.AddSingleton(handlerType); } + + // Auto-register IEventMiddleware implementations with parameterless constructors + var middlewareTypes = assembly.GetTypes() + .Where(t => !t.IsAbstract && !t.IsInterface) + .Where(t => typeof(IEventMiddleware).IsAssignableFrom(t)) + .Where(t => t.GetConstructor(Type.EmptyTypes) != null); + + foreach (var middlewareType in middlewareTypes) + { + bool isAlreadyRegistered = services.Any(s => + (s.ServiceType == typeof(IEventMiddleware) && + (s.ImplementationType == middlewareType || + (s.ImplementationInstance != null && s.ImplementationInstance.GetType() == middlewareType))) || + s.ServiceType == middlewareType); + + if (!isAlreadyRegistered) + { + services.AddSingleton(typeof(IEventMiddleware), middlewareType); + services.AddSingleton(middlewareType); + } + } } return services; diff --git a/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs b/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs index 34c795b..a59c29f 100644 --- a/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs +++ b/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs @@ -64,12 +64,14 @@ public async Task CreateAsync_CustomerAggregate_RaiseShouldDispatchEvents() // Arrange var handlerResult = new Dictionary(); var services = new ServiceCollection(); + services.AddSingleton(); services.AddSingleton(_ => { var handlers = new List { new CustomerCreatedHandler(handlerResult) }; return new Resolver(handlers); }); - services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new EventListener(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); @@ -90,12 +92,14 @@ public async Task CreateAsync_WarehouseAggregate_RaiseShouldDispatchEvents() // Arrange var handlerResult = new Dictionary(); var services = new ServiceCollection(); + services.AddSingleton(); services.AddSingleton(_ => { var handlers = new List { new OrderReceivedHandler(handlerResult) }; return new Resolver(handlers); }); - services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new EventListener(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); @@ -103,7 +107,7 @@ public async Task CreateAsync_WarehouseAggregate_RaiseShouldDispatchEvents() // Act var warehouse = await factory.CreateAsync(); - warehouse.ProcessOrder("ORD-456"); + warehouse.ProcessOrder("ORD-123"); // Assert Assert.That(handlerResult.Count, Is.EqualTo(1)); @@ -116,6 +120,7 @@ public async Task CreateAsync_WithMultipleHandlers_ShouldDispatchToAll() // Arrange var handlerResult = new Dictionary(); var services = new ServiceCollection(); + services.AddSingleton(); services.AddSingleton(_ => { var handlers = new List @@ -125,7 +130,8 @@ public async Task CreateAsync_WithMultipleHandlers_ShouldDispatchToAll() }; return new Resolver(handlers); }); - services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new EventListener(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); @@ -170,12 +176,14 @@ public async Task RaiseAsync_OnProxiedAggregate_ShouldInterceptAndDispatch() // Arrange var handlerResult = new Dictionary(); var services = new ServiceCollection(); + services.AddSingleton(); services.AddSingleton(_ => { var handlers = new List { new CustomerCreatedHandler(handlerResult) }; return new Resolver(handlers); }); - services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new EventListener(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); diff --git a/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs b/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs index 3e752e4..fea13bd 100644 --- a/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs +++ b/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs @@ -21,6 +21,7 @@ public void Setup() { _handlerResult = new Dictionary(); var services = new ServiceCollection(); + services.AddSingleton(); services.AddSingleton(_ => { var handlers = new List @@ -30,7 +31,8 @@ public void Setup() }; return new Resolver(handlers); }); - services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new EventListener(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); _serviceProvider = services.BuildServiceProvider(); diff --git a/test/DomainEvents.Tests/Run/AggregateTests.cs b/test/DomainEvents.Tests/Run/AggregateTests.cs index 403f797..8852795 100644 --- a/test/DomainEvents.Tests/Run/AggregateTests.cs +++ b/test/DomainEvents.Tests/Run/AggregateTests.cs @@ -29,9 +29,11 @@ public void Setup() }; var services = new ServiceCollection(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(_ => new Resolver(handlers)); - services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService())); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new EventListener(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); diff --git a/test/DomainEvents.Tests/Run/CustomDispatcherTests.cs b/test/DomainEvents.Tests/Run/CustomDispatcherTests.cs deleted file mode 100644 index 084df8a..0000000 --- a/test/DomainEvents.Tests/Run/CustomDispatcherTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using DomainEvents.Impl; -using DomainEvents.Tests.Aggregates; -using DomainEvents.Tests.Events; -using DomainEvents.Tests.Handlers; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; - -namespace DomainEvents.Tests.Run -{ - /// - /// Tests for custom event dispatcher support. - /// - public class CustomDispatcherTests - { - [SetUp] - public void Setup() - { - TestEventDispatcher.DispatchedEvents.Clear(); - SimpleCustomerCreatedHandler.HandleCount = 0; - SimpleOrderReceivedHandler.HandleCount = 0; - } - - [Test] - public void AddDomainEvents_ShouldRegisterDefaultDispatcher() - { - // Arrange - var services = new ServiceCollection(); - services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); - var serviceProvider = services.BuildServiceProvider(); - - // Act - var dispatcher = serviceProvider.GetService(); - - // Assert - Assert.That(dispatcher, Is.Not.Null); - Assert.That(dispatcher, Is.InstanceOf()); - } - - [Test] - public void AddDomainEventsWithDispatcher_Type_ShouldRegisterCustomDispatcher() - { - // Arrange - var services = new ServiceCollection(); - services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); - var serviceProvider = services.BuildServiceProvider(); - - // Act - var dispatcher = serviceProvider.GetService(); - - // Assert - Assert.That(dispatcher, Is.Not.Null); - Assert.That(dispatcher, Is.InstanceOf()); - } - - [Test] - public void AddDomainEventsWithDispatcher_Instance_ShouldRegisterCustomDispatcher() - { - // Arrange - var services = new ServiceCollection(); - var customDispatcher = new TestEventDispatcher(); - services.AddDomainEventsWithDispatcher(customDispatcher, typeof(SimpleCustomerCreatedHandler).Assembly); - var serviceProvider = services.BuildServiceProvider(); - - // Act - var dispatcher = serviceProvider.GetService(); - - // Assert - Assert.That(dispatcher, Is.Not.Null); - Assert.That(dispatcher, Is.EqualTo(customDispatcher)); - } - - [Test] - public async Task CustomDispatcher_ShouldDispatchEvents() - { - // Arrange - var services = new ServiceCollection(); - services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); - var serviceProvider = services.BuildServiceProvider(); - var factory = serviceProvider.GetRequiredService(); - - var customer = await factory.CreateAsync(); - - // Act - customer.RegisterCustomer("Test Customer"); - - // Assert - custom dispatcher should have received the event - Assert.That(TestEventDispatcher.DispatchedEvents.Count, Is.EqualTo(1)); - Assert.That(TestEventDispatcher.DispatchedEvents[0], Is.InstanceOf()); - } - - [Test] - public async Task DefaultInterceptor_ShouldWorkWithCustomDispatcher() - { - // Arrange - custom dispatcher only tracks, doesn't forward to handlers - // This tests that the interceptor correctly uses the custom dispatcher - var services = new ServiceCollection(); - services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); - var serviceProvider = services.BuildServiceProvider(); - var factory = serviceProvider.GetRequiredService(); - - var customer = await factory.CreateAsync(); - - // Act - customer.RegisterCustomer("Test Customer"); - - // Assert - custom dispatcher should have received the event - Assert.That(TestEventDispatcher.DispatchedEvents.Count, Is.EqualTo(1)); - } - - [Test] - public async Task DefaultDispatcher_ShouldWorkWithoutCustomDispatcher() - { - // Arrange - var services = new ServiceCollection(); - services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); - var serviceProvider = services.BuildServiceProvider(); - var factory = serviceProvider.GetRequiredService(); - - var customer = await factory.CreateAsync(); - - // Act - customer.RegisterCustomer("Test Customer"); - - // Assert - Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(1)); - } - - [Test] - public void AggregateFactory_WithServiceProvider_ShouldResolveDispatcher() - { - // Arrange - var services = new ServiceCollection(); - services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); - var serviceProvider = services.BuildServiceProvider(); - - // Act - var factory = serviceProvider.GetRequiredService(); - - // Assert - Assert.That(factory, Is.Not.Null); - Assert.That(factory, Is.InstanceOf()); - } - } - - /// - /// Test event dispatcher that tracks dispatched events. - /// - public class TestEventDispatcher : IEventDispatcher - { - public static List DispatchedEvents { get; } = new(); - - public void Dispatch(object @event) - { - if (@event != null) - { - DispatchedEvents.Add(@event); - } - } - - public Task DispatchAsync(object @event) - { - Dispatch(@event); - return Task.CompletedTask; - } - } -} diff --git a/test/DomainEvents.Tests/Run/IntegrationTests.cs b/test/DomainEvents.Tests/Run/IntegrationTests.cs new file mode 100644 index 0000000..9933205 --- /dev/null +++ b/test/DomainEvents.Tests/Run/IntegrationTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Tests for Event Queue functionality. + /// + public class QueueTests + { + [Test] + public void InMemoryQueue_Enqueue_ShouldAddToQueue() + { + var queue = new InMemoryEventQueue(); + var context = new EventContext(new CustomerCreated { Name = "Test" }); + + queue.EnqueueAsync(context).Wait(); + + Assert.That(queue.Count, Is.EqualTo(1)); + } + + [Test] + public void InMemoryQueue_Dequeue_ShouldRemoveFromQueue() + { + var queue = new InMemoryEventQueue(); + var context = new EventContext(new CustomerCreated { Name = "Test" }); + queue.EnqueueAsync(context).Wait(); + + var dequeued = queue.DequeueAsync().Result; + + Assert.That(queue.Count, Is.EqualTo(0)); + Assert.That(dequeued, Is.Not.Null); + Assert.That(((CustomerCreated)dequeued.Event).Name, Is.EqualTo("Test")); + } + + [Test] + public void InMemoryQueue_DequeueEmpty_ShouldReturnNull() + { + var queue = new InMemoryEventQueue(); + + var dequeued = queue.DequeueAsync().Result; + + Assert.That(dequeued, Is.Null); + } + + [Test] + public void InMemoryQueue_PeekAll_ShouldReturnAll() + { + var queue = new InMemoryEventQueue(); + queue.EnqueueAsync(new EventContext(new CustomerCreated { Name = "Test1" })).Wait(); + queue.EnqueueAsync(new EventContext(new CustomerCreated { Name = "Test2" })).Wait(); + + var all = queue.PeekAll(); + + Assert.That(all.Count, Is.EqualTo(2)); + } + + [Test] + public void InMemoryQueue_Clear_ShouldRemoveAll() + { + var queue = new InMemoryEventQueue(); + queue.EnqueueAsync(new EventContext(new CustomerCreated { Name = "Test1" })).Wait(); + queue.EnqueueAsync(new EventContext(new CustomerCreated { Name = "Test2" })).Wait(); + + queue.Clear(); + + Assert.That(queue.Count, Is.EqualTo(0)); + } + } + + /// + /// Tests for EventContext. + /// + public class EventContextTests + { + [Test] + public void EventContext_ShouldStoreEvent() + { + var @event = new CustomerCreated { Name = "Test" }; + var context = new EventContext(@event); + + Assert.That(context.Event, Is.EqualTo(@event)); + Assert.That(context.EventType, Is.EqualTo(typeof(CustomerCreated))); + Assert.That(context.Timestamp, Is.Not.EqualTo(default(DateTime))); + Assert.That(context.Items, Is.Not.Null); + Assert.That(context.IsHandled, Is.False); + Assert.That(context.IsDispatched, Is.False); + } + + [Test] + public void EventContext_ShouldAllowSettingProperties() + { + var context = new EventContext(new CustomerCreated { Name = "Test" }); + + context.IsHandled = true; + context.IsDispatched = true; + context.Items["Key"] = "Value"; + + Assert.That(context.IsHandled, Is.True); + Assert.That(context.IsDispatched, Is.True); + Assert.That(context.Items["Key"], Is.EqualTo("Value")); + } + } + + /// + /// Tests for custom dispatcher. + /// + public class DispatcherRegistrationTests + { + [Test] + public void AddDomainEventsWithDispatcher_ShouldRegisterCustomDispatcher() + { + var services = new ServiceCollection(); + services.AddDomainEventsWithDispatcher(typeof(SimpleCustomerCreatedHandler).Assembly); + var provider = services.BuildServiceProvider(); + + var dispatcher = provider.GetService(); + + Assert.That(dispatcher, Is.InstanceOf()); + } + + [Test] + public void AddDomainEventsWithDispatcherInstance_ShouldRegisterInstance() + { + var services = new ServiceCollection(); + var customDispatcher = new CustomTestDispatcher(); + services.AddDomainEventsWithDispatcher(customDispatcher, typeof(SimpleCustomerCreatedHandler).Assembly); + var provider = services.BuildServiceProvider(); + + var dispatcher = provider.GetService(); + + Assert.That(dispatcher, Is.EqualTo(customDispatcher)); + } + + public class CustomTestDispatcher : IEventDispatcher + { + public void Dispatch(object @event) { } + public Task DispatchAsync(object @event) => Task.CompletedTask; + } + } + + /// + /// Integration tests for full flow. + /// + public class IntegrationTests + { + private IServiceProvider _serviceProvider; + + [SetUp] + public void Setup() + { + SimpleCustomerCreatedHandler.HandleCount = 0; + SimpleOrderReceivedHandler.HandleCount = 0; + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [Test] + public async Task FullFlow_WithQueue_ShouldWork() + { + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + + var factory = _serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + customer.RegisterCustomer("Integration Test"); + + Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(1)); + } + + [Test] + public async Task Publisher_WithoutProxy_ShouldDispatchDirectly() + { + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + + var publisher = _serviceProvider.GetRequiredService(); + + await publisher.RaiseAsync(new CustomerCreated { Name = "Direct Publish" }); + + Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(1)); + } + + [Test] + public async Task MultipleEvents_ShouldProcessAll() + { + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + + var publisher = _serviceProvider.GetRequiredService(); + + await publisher.RaiseAsync(new CustomerCreated { Name = "Test1" }); + await publisher.RaiseAsync(new CustomerCreated { Name = "Test2" }); + + Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(2)); + } + + [Test] + public void Resolver_ShouldResolveMultipleHandlers() + { + var services = new ServiceCollection(); + services.AddSingleton(new FirstHandler()); + services.AddSingleton(new SecondHandler()); + services.AddSingleton(sp => new Resolver(sp.GetServices())); + _serviceProvider = services.BuildServiceProvider(); + + var resolver = _serviceProvider.GetRequiredService(); + + var handlers = resolver.ResolveAsync().Result.ToList(); + + Assert.That(handlers.Count, Is.EqualTo(2)); + } + + public class MultiEvent : IDomainEvent { } + + public class FirstHandler : IHandler + { + public Task HandleAsync(MultiEvent @event) => Task.CompletedTask; + } + + public class SecondHandler : IHandler + { + public Task HandleAsync(MultiEvent @event) => Task.CompletedTask; + } + } +} diff --git a/test/DomainEvents.Tests/Run/MiddlewareTests.cs b/test/DomainEvents.Tests/Run/MiddlewareTests.cs new file mode 100644 index 0000000..04c28d8 --- /dev/null +++ b/test/DomainEvents.Tests/Run/MiddlewareTests.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DomainEvents.Impl; +using DomainEvents.Tests.Aggregates; +using DomainEvents.Tests.Events; +using DomainEvents.Tests.Handlers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DomainEvents.Tests.Run +{ + /// + /// Tests for middleware functionality. + /// + public class MiddlewareTests + { + private IServiceProvider _serviceProvider; + + [SetUp] + public void Setup() + { + TestMiddleware.BeforeDispatchCount = 0; + TestMiddleware.AfterDispatchCount = 0; + TestMiddleware.BeforeHandleCount = 0; + TestMiddleware.AfterHandleCount = 0; + SimpleCustomerCreatedHandler.HandleCount = 0; + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [Test] + public void AddDomainEvents_ShouldRegisterMiddleware() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + + // Act + var middlewares = _serviceProvider.GetServices().ToList(); + + // Assert - TestMiddleware manually registered, SkippingMiddleware has constructor param so not auto-registered + Assert.That(middlewares.Count, Is.EqualTo(1)); + Assert.That(middlewares.Any(m => m is TestMiddleware), Is.True); + } + + [Test] + public void AddDomainEvents_ShouldAutoRegisterMiddleware() + { + // Arrange + var services = new ServiceCollection(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + + // Act + var middlewares = _serviceProvider.GetServices().ToList(); + + // Assert - Only TestMiddleware is auto-registered (SkippingMiddleware has constructor param) + Assert.That(middlewares.Count, Is.EqualTo(1)); + Assert.That(middlewares.Any(m => m is TestMiddleware), Is.True); + } + + [Test] + public async Task Middleware_ShouldCallOnDispatching() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + var factory = _serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test"); + + // Assert + Assert.That(TestMiddleware.BeforeDispatchCount, Is.EqualTo(1)); + } + + [Test] + public async Task Middleware_ShouldCallOnDispatched() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new Resolver(sp.GetServices())); + services.AddSingleton(); + services.AddSingleton(sp => + new EventListener( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetServices())); + services.AddSingleton(sp => new EventInterceptor(sp.GetRequiredService())); + services.AddSingleton(); + _serviceProvider = services.BuildServiceProvider(); + _serviceProvider.GetService(); // Resolve to trigger subscription + var factory = _serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test"); + + // Assert + Assert.That(TestMiddleware.AfterDispatchCount, Is.EqualTo(1)); + } + + [Test] + public async Task Middleware_ShouldCallOnHandling() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new Resolver(sp.GetServices())); + services.AddSingleton(); + services.AddSingleton(sp => + new EventListener( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetServices())); + services.AddSingleton(sp => new EventInterceptor(sp.GetRequiredService())); + services.AddSingleton(); + _serviceProvider = services.BuildServiceProvider(); + _serviceProvider.GetService(); // Resolve to trigger subscription + var factory = _serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test"); + + // Assert + Assert.That(TestMiddleware.BeforeHandleCount, Is.EqualTo(1)); + } + + [Test] + public async Task Middleware_ShouldCallOnHandled() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new Resolver(sp.GetServices())); + services.AddSingleton(); + services.AddSingleton(sp => + new EventListener( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetServices())); + services.AddSingleton(sp => new EventInterceptor(sp.GetRequiredService())); + services.AddSingleton(); + _serviceProvider = services.BuildServiceProvider(); + _serviceProvider.GetService(); // Resolve to trigger subscription + var factory = _serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test"); + + // Assert + Assert.That(TestMiddleware.AfterHandleCount, Is.EqualTo(1)); + } + + [Test] + public async Task Middleware_OnDispatchingFalse_ShouldSkipDispatch() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new SkippingMiddleware("test")); + services.AddDomainEvents(typeof(SimpleCustomerCreatedHandler).Assembly); + _serviceProvider = services.BuildServiceProvider(); + var factory = _serviceProvider.GetRequiredService(); + + var customer = await factory.CreateAsync(); + + // Act + customer.RegisterCustomer("Test"); + + // Assert - handler should not be called + Assert.That(SimpleCustomerCreatedHandler.HandleCount, Is.EqualTo(0)); + } + } + + /// + /// Test middleware that tracks method calls. + /// + public class TestMiddleware : IEventMiddleware + { + public static int BeforeDispatchCount = 0; + public static int AfterDispatchCount = 0; + public static int BeforeHandleCount = 0; + public static int AfterHandleCount = 0; + + public Task OnDispatchingAsync(EventContext context) + { + BeforeDispatchCount++; + return Task.FromResult(true); + } + + public Task OnDispatchedAsync(EventContext context) + { + AfterDispatchCount++; + return Task.CompletedTask; + } + + public Task OnHandlingAsync(EventContext context) + { + BeforeHandleCount++; + return Task.FromResult(true); + } + + public Task OnHandledAsync(EventContext context) + { + AfterHandleCount++; + return Task.CompletedTask; + } + } + + /// + /// Test middleware that skips dispatching. + /// Has a constructor with parameter to prevent auto-registration. + /// + public class SkippingMiddleware : IEventMiddleware + { + public SkippingMiddleware(string name) + { + // Constructor with parameter to prevent auto-registration + } + + public Task OnDispatchingAsync(EventContext context) + { + return Task.FromResult(false); + } + + public Task OnDispatchedAsync(EventContext context) => Task.CompletedTask; + public Task OnHandlingAsync(EventContext context) => Task.FromResult(true); + public Task OnHandledAsync(EventContext context) => Task.CompletedTask; + } +} From 4dcf1b8df3a75507f582a8a00eba99bf52e2a986 Mon Sep 17 00:00:00 2001 From: Ninja Date: Sat, 14 Mar 2026 14:57:21 +0000 Subject: [PATCH 3/8] - update documentation --- WIKI.md | 211 +++++++++++++++--- src/DomainEvents/IEventListener.cs | 128 ----------- src/DomainEvents/IEventQueue.cs | 78 ------- src/DomainEvents/Impl/EventListener.cs | 138 ++++++++++++ src/DomainEvents/Impl/InMemoryEventQueue.cs | 84 +++++++ .../ServiceCollectionExtensions.cs | 1 - 6 files changed, 396 insertions(+), 244 deletions(-) create mode 100644 src/DomainEvents/Impl/EventListener.cs create mode 100644 src/DomainEvents/Impl/InMemoryEventQueue.cs diff --git a/WIKI.md b/WIKI.md index a2156ea..d69e6c9 100644 --- a/WIKI.md +++ b/WIKI.md @@ -10,6 +10,7 @@ 6. [Extension Points](#extension-points) - [Custom Event Dispatcher](#custom-event-dispatcher) - [Custom Event Queue](#custom-event-queue) + - [Event Listener](#event-listener) - [Custom Event Interceptor](#custom-event-interceptor) - [Custom Handler Resolver](#custom-handler-resolver) - [Event Middleware](#event-middleware) @@ -50,18 +51,18 @@ DomainEvents is a library for implementing transactional domain events in domain │ ┌─────────────────────────────────────────────┐ │ │ │ EventInterceptor (Proxy) │ │ │ │ - Castle DynamicProxy interception │ │ -│ │ - OpenTelemetry tracking │ │ +│ │ - OpenTelemetry tracking │ │ │ └─────────────────────┬───────────────────────┘ │ │ │ │ └────────────────────────┼──────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ -│ Middleware Pipeline │ +│ Middleware Pipeline (Dispatch) │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ Middleware 1 │ │ Middleware 2 │ │ Middleware N │ │ │ │ OnDispatching │ │ OnDispatching │ │ OnDispatching │ │ -│ │ OnDispatched │ │ OnDispatched │ │ OnDispatched │ │ +│ │ OnDispatched │ │ OnDispatched │ │ OnDispatched │ │ │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ │ │ │ │ └────────────────────┼────────────────────┘ │ @@ -70,32 +71,67 @@ DomainEvents is a library for implementing transactional domain events in domain │ ▼ ┌───────────────────────────────────────────────────────────────────────────────┐ -│ Dispatcher Layer │ +│ Event Queue │ │ ┌─────────────────────────────────────────────────────┐ │ -│ │ EventDispatcher │ │ -│ │ - Checks Event Queue │ │ -│ │ - Processes events │ │ +│ │ InMemoryEventQueue │ │ +│ │ - Enqueue events │ │ +│ │ - Invoke subscription delegate on enqueue │ │ │ └─────────────────────────┬───────────────────────────┘ │ │ │ │ +│ (delegate callback) │ └────────────────────────────┼──────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────────────────────┐ +│ EventListener │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ EventListener.ProcessEventAsync │ │ +│ │ - Subscribes to queue via delegate │ │ +│ │ - Processes events from queue │ │ +│ └─────────────────────────┬───────────────────────────┘ │ +│ │ │ +└────────────────────────────┼──────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Middleware Pipeline (Handle) │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Middleware 1 │ │ Middleware 2 │ │ Middleware N │ │ +│ │ OnHandling │ │ OnHandling │ │ OnHandling │ │ +│ │ OnHandled │ │ OnHandled │ │ OnHandled │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └────────────────────┼────────────────────┘ │ +│ │ │ +└────────────────────────────────┼──────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────────┐ │ Handler Layer │ │ ┌─────────────────────────────────────────────────────┐ │ -│ │ Resolver │ │ -│ │ - Resolves handlers for event type │ │ +│ │ Resolver │ │ +│ │ - Resolves handlers for event type │ │ │ └─────────────────────────┬───────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Handler 1 │ │ Handler 2 │ │ Handler N │ │ -│ │ OnHandling │ │ OnHandling │ │ OnHandling │ │ -│ │ OnHandled │ │ OnHandled │ │ OnHandled │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └───────────────────────────────────────────────────────────────────────────────┘ ``` +### Event Flow + +1. **Aggregate.Raise()** - Aggregate raises an event +2. **EventInterceptor** - Intercepts the call, proceeds with Raise, then dispatches event +3. **EventDispatcher.DispatchAsync()** - Runs dispatch middleware, enqueues event +4. **InMemoryEventQueue** - Stores event, invokes subscribed delegate immediately +5. **EventListener** - Receives callback, processes event through handle middleware +6. **Resolver** - Resolves handlers for the event type +7. **Handler** - Processes the event + +**Note**: The dispatcher returns immediately after enqueueing (fire-and-forget). Event processing happens asynchronously via the queue subscription delegate. + --- ## Core Concepts @@ -310,7 +346,7 @@ services.AddSingleton(); ### Custom Event Dispatcher -Implement `IEventDispatcher` to customize how events are dispatched: +Implement `IEventDispatcher` to customize how events are dispatched. The dispatcher runs dispatch middleware and enqueues events. Event processing is handled by the EventListener via queue subscription. ```csharp public class MyCustomDispatcher : IEventDispatcher @@ -357,8 +393,8 @@ public class MyCustomDispatcher : IEventDispatcher } } - // Process event - await ProcessEventAsync(context); + // Enqueue event - EventListener will process via subscription + await _queue.EnqueueAsync(context); context.IsDispatched = true; @@ -369,23 +405,12 @@ public class MyCustomDispatcher : IEventDispatcher } } - private async Task ProcessEventAsync(EventContext context) - { - var handlers = await _resolver.ResolveAsync(context.EventType); - foreach (var handler in handlers) - { - // Run handling middleware (before) - foreach (var middleware in _middlewares) - { - if (!await middleware.OnHandlingAsync(context)) - continue; - } + public IEventQueue Queue => _queue; +} - // Invoke handler - // ... +--- - // Run handling middleware (after) - foreach (var middleware in _middlewares) +### Custom Event Queue { await middleware.OnHandledAsync(context); } @@ -428,13 +453,19 @@ Implement `IEventQueue` to create a custom queue (e.g., persistent queue, distri public class MyCustomQueue : IEventQueue { private readonly Queue _queue = new Queue(); + private EventDequeuedHandler _handler; + private readonly object _lock = new object(); public Task EnqueueAsync(EventContext context) { - lock (_queue) + lock (_lock) { _queue.Enqueue(context); } + + // Immediately invoke the subscribed handler (fire-and-forget) + _handler?.Invoke(context); + return Task.CompletedTask; } @@ -444,7 +475,7 @@ public class MyCustomQueue : IEventQueue public Task DequeueAsync() #endif { - lock (_queue) + lock (_lock) { if (_queue.Count > 0) { @@ -464,7 +495,7 @@ public class MyCustomQueue : IEventQueue public IReadOnlyList PeekAll() { - lock (_queue) + lock (_lock) { return _queue.ToArray(); } @@ -472,7 +503,7 @@ public class MyCustomQueue : IEventQueue public void Clear() { - lock (_queue) + lock (_lock) { _queue.Clear(); } @@ -482,15 +513,25 @@ public class MyCustomQueue : IEventQueue { get { - lock (_queue) + lock (_lock) { return _queue.Count; } } } + + public void Subscribe(EventDequeuedHandler handler) + { + _handler = handler; + } } ``` +**Key Points:** +- The `Subscribe` method registers a delegate that gets called when events are enqueued +- The delegate is invoked immediately in `EnqueueAsync` (synchronous callback) +- This enables fire-and-forget event processing + **Registration:** ```csharp @@ -727,6 +768,95 @@ services.AddSingleton(sp => --- +### Event Listener + +Implement `IEventListener` to customize how events are processed from the queue: + +```csharp +public class MyEventListener : IEventListener +{ + private readonly IEventQueue _queue; + private readonly IResolver _resolver; + private readonly IEnumerable _middlewares; + private readonly ILogger _logger; + + public MyEventListener( + IEventQueue queue, + IResolver resolver, + IEnumerable middlewares = null, + ILogger logger = null) + { + _queue = queue; + _resolver = resolver; + _middlewares = middlewares ?? Enumerable.Empty(); + _logger = logger; + + // Subscribe to queue - this is called when events are enqueued + _queue.Subscribe(OnEventEnqueued); + } + + private Task OnEventEnqueued(EventContext context) + { + return ProcessEventAsync(context); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + _logger?.LogInformation("Event listener started"); + return Task.CompletedTask; + } + + public async Task StopAsync() + { + _logger?.LogInformation("Event listener stopped"); + } + + public async Task ProcessEventAsync(EventContext context) + { + // Process event through middleware and handlers + var handlers = await _resolver.ResolveAsync(context.EventType); + + foreach (var handler in handlers) + { + // Run handling middleware (before) + foreach (var middleware in _middlewares) + { + if (!await middleware.OnHandlingAsync(context)) + continue; + } + + // Invoke handler + var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(context.EventType); + var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); + handleMethod?.Invoke(handler, new[] { context.Event }); + + context.IsHandled = true; + + // Run handling middleware (after) + foreach (var middleware in _middlewares) + { + await middleware.OnHandledAsync(context); + } + } + } +} +``` + +**Key Points:** +- The listener subscribes to the queue via `_queue.Subscribe(OnEventEnqueued)` +- When an event is enqueued, the delegate is invoked immediately +- The listener handles the processing pipeline: middleware -> resolver -> handler +- The EventListener is automatically registered when using `AddDomainEvents` + +**Registration:** + +```csharp +services.AddDomainEvents(assembly); +// EventListener is auto-registered and subscribes automatically +``` + +--- + ### Custom Aggregate Factory Implement `IAggregateFactory` to customize how aggregates are created: @@ -841,10 +971,16 @@ public class AnotherMiddleware : IEventMiddleware | `IEventDispatcher` | Interface for dispatching events | | `IEventInterceptor` | Interceptor for aggregate Raise/RaiseAsync methods | | `IEventMiddleware` | Middleware for event pipeline | -| `IEventQueue` | Queue for in-flight events | -| `IEventListener` | Listener for processing queued events | +| `IEventQueue` | Queue for in-flight events with subscription support | +| `IEventListener` | Listener for processing queued events via subscription | | `IAggregateFactory` | Factory for creating proxied aggregates | +### Delegates + +| Delegate | Description | +|----------|-------------| +| `EventDequeuedHandler` | Delegate for processing dequeued events (signature: `Task Handler(EventContext context)`) | + ### Classes | Class | Description | @@ -854,9 +990,10 @@ public class AnotherMiddleware : IEventMiddleware | `Publisher` | Default implementation of IPublisher | | `Resolver` | Default implementation of IResolver | | `EventDispatcher` | Default implementation of IEventDispatcher | +| `EventListener` | Default implementation of IEventListener - subscribes to queue and processes events | | `EventInterceptor` | Default interceptor with telemetry | | `AggregateFactory` | Default factory for proxied aggregates | -| `InMemoryEventQueue` | Default in-memory queue implementation | +| `InMemoryEventQueue` | Default in-memory queue with subscription support | | `EventMiddlewareBase` | Base class for middleware | | `LoggingMiddleware` | Built-in logging middleware | diff --git a/src/DomainEvents/IEventListener.cs b/src/DomainEvents/IEventListener.cs index 6c817f3..f806274 100644 --- a/src/DomainEvents/IEventListener.cs +++ b/src/DomainEvents/IEventListener.cs @@ -24,132 +24,4 @@ public interface IEventListener /// Task StopAsync(); } - - /// - /// Default implementation of IEventListener that subscribes to the queue. - /// Processes events immediately when they are enqueued. - /// - public class EventListener : IEventListener - { - private readonly IEventQueue _queue; - private readonly IResolver _resolver; - private readonly IEnumerable _middlewares; - private readonly ILogger _logger; - private CancellationTokenSource _cts; - - public EventListener( - IEventQueue queue, - IResolver resolver, - IEnumerable middlewares = null, - ILogger logger = null) - { - _queue = queue; - _resolver = resolver; - _middlewares = middlewares ?? Enumerable.Empty(); - _logger = logger; - - _queue.Subscribe(OnEventEnqueued); - _logger?.LogInformation("EventListener created and subscribed to queue"); - } - - private Task OnEventEnqueued(EventContext context) - { - return ProcessEventAsync(context); - } - - public Task StartAsync(CancellationToken cancellationToken = default) - { - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _logger?.LogInformation("Event listener started"); - return Task.CompletedTask; - } - - public async Task StopAsync() - { - _cts?.Cancel(); - _logger?.LogInformation("Event listener stopped"); - } - - public async Task ProcessEventAsync(EventContext context) - { - var eventType = context.EventType; - - if (!(_resolver is Impl.Resolver resolver)) - { - _logger?.LogWarning("Resolver is not of type Resolver, cannot dispatch event"); - return; - } - - var handlers = await resolver.ResolveAsync(eventType); - var handlerList = handlers.ToList(); - - _logger?.LogDebug("Found {HandlerCount} handlers for event {EventType}", handlerList.Count, eventType.Name); - - var exceptions = new List(); - - foreach (var handler in handlerList) - { - var handlerType = handler.GetType(); - - var activity = DomainEventsActivitySource.Source.StartActivity( - DomainEventsActivitySource.HandleEventActivityName, - ActivityKind.Internal); - - if (activity != null) - { - activity.SetTag(DomainEventsTags.EventType, eventType.Name); - activity.SetTag(DomainEventsTags.HandlerType, handlerType.Name); - } - - try - { - foreach (var middleware in _middlewares) - { - if (!await middleware.OnHandlingAsync(context)) - { - _logger?.LogDebug("Middleware skipped handling for {EventType}", eventType.Name); - continue; - } - } - - var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(eventType); - var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); - handleMethod?.Invoke(handler, new[] { context.Event }); - - context.IsHandled = true; - - foreach (var middleware in _middlewares) - { - await middleware.OnHandledAsync(context); - } - - if (activity != null) - { - activity.SetStatus(ActivityStatusCode.Ok); - } - } - catch (Exception ex) - { - _logger?.LogError(ex, "Error in handler {HandlerType} for event {EventType}", - handlerType.Name, eventType.Name); - if (activity != null) - { - activity.SetStatus(ActivityStatusCode.Error, ex.Message); - activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); - activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); - } - exceptions.Add(ex); - } - finally - { - activity?.Dispose(); - } - } - - if (exceptions.Count > 0) - { - throw new AggregateException($"Errors occurred while dispatching event {eventType.Name}", exceptions); - } - } - } } diff --git a/src/DomainEvents/IEventQueue.cs b/src/DomainEvents/IEventQueue.cs index 2cf17f6..fc2bfed 100644 --- a/src/DomainEvents/IEventQueue.cs +++ b/src/DomainEvents/IEventQueue.cs @@ -55,82 +55,4 @@ public interface IEventQueue /// The handler to call when an event is enqueued. void Subscribe(EventDequeuedHandler handler); } - - /// - /// Default in-memory implementation of IEventQueue with subscription support. - /// - public class InMemoryEventQueue : IEventQueue - { - private readonly Queue _queue = new Queue(); - private EventDequeuedHandler _handler; - private readonly object _lock = new object(); - - public Task EnqueueAsync(EventContext context) - { - lock (_lock) - { - _queue.Enqueue(context); - } - - _handler?.Invoke(context); - - return Task.CompletedTask; - } - -#if NET8_0_OR_GREATER - public Task DequeueAsync() -#else - public Task DequeueAsync() -#endif - { - lock (_lock) - { - if (_queue.Count > 0) - { -#if NET8_0_OR_GREATER - return Task.FromResult(_queue.Dequeue()); -#else - return Task.FromResult(_queue.Dequeue()); -#endif - } - } -#if NET8_0_OR_GREATER - return Task.FromResult(null); -#else - throw new InvalidOperationException("Queue is empty"); -#endif - } - - public IReadOnlyList PeekAll() - { - lock (_lock) - { - return _queue.ToArray(); - } - } - - public void Clear() - { - lock (_lock) - { - _queue.Clear(); - } - } - - public int Count - { - get - { - lock (_lock) - { - return _queue.Count; - } - } - } - - public void Subscribe(EventDequeuedHandler handler) - { - _handler = handler; - } - } } diff --git a/src/DomainEvents/Impl/EventListener.cs b/src/DomainEvents/Impl/EventListener.cs new file mode 100644 index 0000000..0038399 --- /dev/null +++ b/src/DomainEvents/Impl/EventListener.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace DomainEvents.Impl +{ + /// + /// Default implementation of IEventListener that subscribes to the queue. + /// Processes events immediately when they are enqueued. + /// + public class EventListener : IEventListener + { + private readonly IEventQueue _queue; + private readonly IResolver _resolver; + private readonly IEnumerable _middlewares; + private readonly ILogger _logger; + private CancellationTokenSource _cts; + + public EventListener( + IEventQueue queue, + IResolver resolver, + IEnumerable middlewares = null, + ILogger logger = null) + { + _queue = queue; + _resolver = resolver; + _middlewares = middlewares ?? Enumerable.Empty(); + _logger = logger; + + _queue.Subscribe(OnEventEnqueued); + _logger?.LogInformation("EventListener created and subscribed to queue"); + } + + private Task OnEventEnqueued(EventContext context) + { + return ProcessEventAsync(context); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _logger?.LogInformation("Event listener started"); + return Task.CompletedTask; + } + + public async Task StopAsync() + { + _cts?.Cancel(); + _logger?.LogInformation("Event listener stopped"); + } + + public async Task ProcessEventAsync(EventContext context) + { + var eventType = context.EventType; + + if (!(_resolver is Resolver resolver)) + { + _logger?.LogWarning("Resolver is not of type Resolver, cannot dispatch event"); + return; + } + + var handlers = await resolver.ResolveAsync(eventType); + var handlerList = handlers.ToList(); + + _logger?.LogDebug("Found {HandlerCount} handlers for event {EventType}", handlerList.Count, eventType.Name); + + var exceptions = new List(); + + foreach (var handler in handlerList) + { + var handlerType = handler.GetType(); + + var activity = DomainEventsActivitySource.Source.StartActivity( + DomainEventsActivitySource.HandleEventActivityName, + ActivityKind.Internal); + + if (activity != null) + { + activity.SetTag(DomainEventsTags.EventType, eventType.Name); + activity.SetTag(DomainEventsTags.HandlerType, handlerType.Name); + } + + try + { + foreach (var middleware in _middlewares) + { + if (!await middleware.OnHandlingAsync(context)) + { + _logger?.LogDebug("Middleware skipped handling for {EventType}", eventType.Name); + continue; + } + } + + var handlerInterfaceType = typeof(IHandler<>).MakeGenericType(eventType); + var handleMethod = handlerInterfaceType.GetMethod("HandleAsync"); + handleMethod?.Invoke(handler, new[] { context.Event }); + + context.IsHandled = true; + + foreach (var middleware in _middlewares) + { + await middleware.OnHandledAsync(context); + } + + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Ok); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error in handler {HandlerType} for event {EventType}", + handlerType.Name, eventType.Name); + if (activity != null) + { + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag(DomainEventsTags.ErrorType, ex.GetType().FullName); + activity.SetTag(DomainEventsTags.ErrorMessage, ex.Message); + } + exceptions.Add(ex); + } + finally + { + activity?.Dispose(); + } + } + + if (exceptions.Count > 0) + { + throw new AggregateException($"Errors occurred while dispatching event {eventType.Name}", exceptions); + } + } + } +} diff --git a/src/DomainEvents/Impl/InMemoryEventQueue.cs b/src/DomainEvents/Impl/InMemoryEventQueue.cs new file mode 100644 index 0000000..3b629c0 --- /dev/null +++ b/src/DomainEvents/Impl/InMemoryEventQueue.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DomainEvents.Impl +{ + /// + /// Default in-memory implementation of IEventQueue with subscription support. + /// + public class InMemoryEventQueue : IEventQueue + { + private readonly Queue _queue = new Queue(); + private EventDequeuedHandler _handler; + private readonly object _lock = new object(); + + public Task EnqueueAsync(EventContext context) + { + lock (_lock) + { + _queue.Enqueue(context); + } + + _handler?.Invoke(context); + + return Task.CompletedTask; + } + +#if NET8_0_OR_GREATER + public Task DequeueAsync() +#else + public Task DequeueAsync() +#endif + { + lock (_lock) + { + if (_queue.Count > 0) + { +#if NET8_0_OR_GREATER + return Task.FromResult(_queue.Dequeue()); +#else + return Task.FromResult(_queue.Dequeue()); +#endif + } + } +#if NET8_0_OR_GREATER + return Task.FromResult(null); +#else + throw new InvalidOperationException("Queue is empty"); +#endif + } + + public IReadOnlyList PeekAll() + { + lock (_lock) + { + return _queue.ToArray(); + } + } + + public void Clear() + { + lock (_lock) + { + _queue.Clear(); + } + } + + public int Count + { + get + { + lock (_lock) + { + return _queue.Count; + } + } + } + + public void Subscribe(EventDequeuedHandler handler) + { + _handler = handler; + } + } +} diff --git a/src/DomainEvents/ServiceCollectionExtensions.cs b/src/DomainEvents/ServiceCollectionExtensions.cs index f9cf5af..c1b7461 100644 --- a/src/DomainEvents/ServiceCollectionExtensions.cs +++ b/src/DomainEvents/ServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using System.Reflection; -using DomainEvents; using DomainEvents.Impl; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; From c38a14d1340a83cc0a0810829c2b37604bc0d57d Mon Sep 17 00:00:00 2001 From: Ninja Date: Sat, 14 Mar 2026 18:20:26 +0000 Subject: [PATCH 4/8] - update documentation --- README.md | 234 ++++++++++++++++++++++-------------------------------- WIKI.md | 58 +++++++++++++- 2 files changed, 150 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 463b896..a658009 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![.Net Framework 4.6.4](https://img.shields.io/badge/.Net-4.6.4-blue)](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net46) ## Library to help implement transactional events in domain bounded context. -Use domain events to explicitly implement side effects of changes within your domain. In other words, and using DDD terminology, use domain events to explicitly implement side effects across multiple aggregates. +Use domain events to explicitly implement side effects of changes within your domain. In other words, and using DDD terminology, use domain events to explicitly implement side effects across multiple aggregates. ### What is a Domain Event? @@ -21,173 +21,145 @@ It's important to ensure that, just like a database transaction, either all the --- -## Architecture Flow - -``` -Aggregate -> Interceptor -> Middleware -> Dispatcher -> Queue <- Listener -> Middleware -> Resolver -> Handler -``` - -### Components: +## Two Approaches to Use DomainEvents -1. **Aggregate** - Domain aggregate that raises events -2. **Interceptor** - Castle DynamicProxy interceptor (standard, with telemetry) -3. **Middleware** - Custom plugins that run before/after dispatch and handling -4. **Dispatcher** - Dispatches events to handlers (customizable) -5. **Queue** - In-flight non-persistent queue for events (optional) -6. **Listener** - Listens to queue and triggers handling -7. **Resolver** - Resolves handlers for events -8. **Handler** - Handles the events - ---- +### Approach 1: Using Publisher and Handler Directly -## v5.x - New Features - -### 1. Event Middleware - -Custom plugins that run at various points in the event pipeline: +Define, publish, and subscribe to events using `IPublisher` and `IHandler`. +**1. Define an Event** ```csharp -public class MyMiddleware : IEventMiddleware +public class CustomerCreated : IDomainEvent { - public Task OnDispatchingAsync(EventContext context) - { - // Runs before event is dispatched - Console.WriteLine($"Before dispatch: {context.EventType.Name}"); - return Task.FromResult(true); // Return false to skip - } - - public Task OnDispatchedAsync(EventContext context) - { - // Runs after event is dispatched - return Task.CompletedTask; - } - - public Task OnHandlingAsync(EventContext context) - { - // Runs before each handler processes the event - return Task.FromResult(true); - } + public string Name { get; set; } +} +``` - public Task OnHandledAsync(EventContext context) +**2. Create a Handler** +```csharp +public class CustomerCreatedHandler : IHandler +{ + public Task HandleAsync(CustomerCreated @event) { - // Runs after each handler processes the event + Console.WriteLine($"Customer created: {@event.Name}"); return Task.CompletedTask; } } ``` -Register middleware: - +**3. Register Services** ```csharp -services.AddDomainEvents(assembly); -services.AddSingleton(); +services.AddDomainEvents(typeof(CustomerCreatedHandler).Assembly); ``` -### 2. Event Queue - -In-flight non-persistent queue for events: - +**4. Publish Events** ```csharp -// Use default in-memory queue -services.AddDomainEvents(assembly); - -// Or use custom queue -services.AddSingleton(); +var publisher = serviceProvider.GetRequiredService(); +await publisher.RaiseAsync(new CustomerCreated { Name = "John Doe" }); ``` -Process queue: +--- -```csharp -var dispatcher = serviceProvider.GetRequiredService(); -await dispatcher.ProcessQueueAsync(); -``` +### Approach 2: Using Interception (Aggregate + Factory) -### 3. Custom Dispatcher +Raise events automatically from domain aggregates using Castle DynamicProxy interception. -Customize how events are dispatched: +**1. Define an Event** +```csharp +public class OrderPlaced : IDomainEvent +{ + public string OrderId { get; set; } + public decimal Amount { get; set; } +} +``` +**2. Create an Aggregate** ```csharp -public class MyCustomDispatcher : IEventDispatcher +public class OrderAggregate : Aggregate { - private readonly IEventDispatcher _innerDispatcher; - - public MyCustomDispatcher(IEventDispatcher innerDispatcher) - { - _innerDispatcher = innerDispatcher; - } - - public void Dispatch(object @event) + public void PlaceOrder(decimal amount) { - // Custom logic - _innerDispatcher.Dispatch(@event); - } - - public Task DispatchAsync(object @event) - { - Dispatch(@event); - return Task.CompletedTask; + // Business logic here... + + var @event = new OrderPlaced + { + OrderId = Guid.NewGuid().ToString(), + Amount = amount + }; + Raise(@event); } } ``` -**Registration:** +**3. Register Services** ```csharp -services.AddDomainEventsWithDispatcher(assembly); +services.AddDomainEvents(typeof(OrderPlacedHandler).Assembly); ``` -### 4. Standard EventInterceptor with Telemetry - -The `EventInterceptor` provides standard interception with: -- OpenTelemetry activity tracking -- Logging -- Error handling - -### 5. Async Handler Interface (IHandler) - +**4. Create Aggregate and Raise Event** ```csharp -public class CustomerCreatedHandler : IHandler -{ - public Task HandleAsync(CustomerCreated @event) - { - Console.WriteLine($"Customer created: {@event.Name}"); - return Task.CompletedTask; - } -} +var factory = serviceProvider.GetRequiredService(); +var order = await factory.CreateAsync(); +order.PlaceOrder(100.00m); // Event is automatically dispatched to handlers ``` --- -## Usage Patterns - -### Pattern 1: Basic Usage +## Architecture Flow -```csharp -services.AddDomainEvents(typeof(CustomerCreatedHandler).Assembly); +``` +Aggregate -> Interceptor -> Middleware -> Dispatcher -> Queue <- Listener -> Middleware -> Resolver -> Handler ``` -### Pattern 2: With Custom Middleware +**Components:** +- **Aggregate** - Domain aggregate that raises events via `Raise()` or `RaiseAsync()` +- **Interceptor** - Castle DynamicProxy interceptor (automatically dispatches events) +- **Middleware** - Custom plugins that run before/after dispatch and handling +- **Dispatcher** - Dispatches events (enqueues to queue) +- **Queue** - In-flight non-persistent queue (fire-and-forget) +- **Listener** - Processes events from queue via subscription delegate +- **Resolver** - Resolves handlers for events +- **Handler** - Handles the events -```csharp -services.AddDomainEvents(assembly); -services.AddSingleton(); -services.AddSingleton(); -``` +--- + +## Event Middleware -### Pattern 3: With Custom Queue +Custom plugins that run at various points in the event pipeline: ```csharp -services.AddDomainEvents(assembly); -services.AddSingleton(); +public class MyMiddleware : IEventMiddleware +{ + public Task OnDispatchingAsync(EventContext context) + { + // Runs before event is dispatched + return Task.FromResult(true); + } -// Process events from queue -var dispatcher = serviceProvider.GetRequiredService(); -await dispatcher.ProcessQueueAsync(); -``` + public Task OnDispatchedAsync(EventContext context) + { + // Runs after event is dispatched + return Task.CompletedTask; + } + + public Task OnHandlingAsync(EventContext context) + { + // Runs before each handler processes the event + return Task.FromResult(true); + } -### Pattern 4: With Custom Dispatcher + public Task OnHandledAsync(EventContext context) + { + // Runs after each handler processes the event + return Task.CompletedTask; + } +} +``` +**Registration:** ```csharp -services.AddDomainEventsWithDispatcher(assembly); +services.AddDomainEvents(assembly); +services.AddSingleton(); ``` --- @@ -198,28 +170,12 @@ services.AddDomainEventsWithDispatcher(assembly); |-----------|---------| | `IDomainEvent` | Marker interface for domain events | | `IHandler` | Async handler interface | -| `IHandler` | Marker interface for handlers | | `IPublisher` | Interface for raising events | -| `IResolver` | Interface for resolving handlers | -| `IEventInterceptor` | Interceptor for Raise/RaiseAsync | -| `IEventDispatcher` | Dispatches events to handlers | +| `IAggregateFactory` | Factory for creating proxied aggregates | | `IEventMiddleware` | Plugin for event pipeline | | `IEventQueue` | In-flight event queue | -| `IEventListener` | Queue listener | - -## Implementation Classes - -| Class | Purpose | -|-------|---------| -| `Aggregate` | Base class for domain aggregates | -| `Publisher` | IPublisher implementation | -| `Resolver` | IResolver implementation | -| `AggregateFactory` | Creates proxied aggregates | -| `EventInterceptor` | Default interceptor with telemetry | -| `EventDispatcher` | Default dispatcher | -| `InMemoryEventQueue` | Default in-memory queue | -| `EventMiddlewareBase` | Base class for middleware | -| `LoggingMiddleware` | Built-in logging middleware | + +--- ## Package Information diff --git a/WIKI.md b/WIKI.md index d69e6c9..5d85ba6 100644 --- a/WIKI.md +++ b/WIKI.md @@ -911,7 +911,7 @@ services.AddSingleton(); ## Auto-Registration -The library automatically discovers and registers components from specified assemblies: +The library automatically discovers and registers components from specified assemblies. Only types with **parameterless constructors** are auto-registered. Types with constructor parameters must be registered explicitly. ### What Gets Auto-Registered @@ -926,16 +926,68 @@ The library automatically discovers and registers components from specified asse 2. **Middlewares**: All types implementing `IEventMiddleware` with parameterless constructors are registered 3. **Manual Override**: If you manually register a service before calling `AddDomainEvents`, the auto-registration skips that specific type +### Types with Parameters - Must Register Explicitly + +If a handler or middleware has constructor parameters, it will **not** be auto-registered. You must register it explicitly: + +**Handler with dependencies (must register manually):** +```csharp +public class OrderHandler : IHandler +{ + private readonly IOrderService _orderService; + + // Has constructor parameter - won't be auto-registered + public OrderHandler(IOrderService orderService) + { + _orderService = orderService; + } + + public Task HandleAsync(OrderPlaced @event) + { + return _orderService.ProcessAsync(@event); + } +} + +// Must register explicitly: +services.AddSingleton(); +services.AddSingleton(); +``` + +**Middleware with dependencies (must register manually):** +```csharp +public class AuditMiddleware : IEventMiddleware +{ + private readonly IAuditService _auditService; + + // Has constructor parameter - won't be auto-registered + public AuditMiddleware(IAuditService auditService) + { + _auditService = auditService; + } + + public Task OnDispatchingAsync(EventContext context) + { + return _auditService.LogAsync(context.Event); + } + + // ... other interface implementations +} + +// Must register explicitly: +services.AddSingleton(); +services.AddSingleton(); +``` + ### Example: Auto-Registration ```csharp -// This will auto-register all handlers and middlewares +// This will auto-register all handlers and middlewares with parameterless constructors services.AddDomainEvents(typeof(MyHandler).Assembly); ``` ### Example: Preventing Auto-Registration -To prevent a middleware from being auto-registered, add a constructor with parameters: +To prevent auto-registration, add a constructor with parameters: ```csharp // Won't be auto-registered (has constructor parameter) From ae3084f5911489bebe8fa3611d4d5ffcc667d039 Mon Sep 17 00:00:00 2001 From: Ninja Date: Sat, 14 Mar 2026 23:28:09 +0000 Subject: [PATCH 5/8] - Fix AggregateFactory methods --- README.md | 17 ++- src/DomainEvents/IAggregateFactory.cs | 39 +++++ src/DomainEvents/ISubscribes.cs | 13 ++ src/DomainEvents/Impl/AggregateFactory.cs | 142 ++++++++++++++++-- .../Aggregates/IOrderService.cs | 18 +++ .../Aggregates/TestAggregates.cs | 21 ++- .../Run/AggregateFactoryIntegrationTests.cs | 46 +++++- .../Run/AggregateFactoryTests.cs | 112 ++++++++++++++ 8 files changed, 394 insertions(+), 14 deletions(-) create mode 100644 src/DomainEvents/ISubscribes.cs create mode 100644 test/DomainEvents.Tests/Aggregates/IOrderService.cs diff --git a/README.md b/README.md index a658009..ceac0c4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ It's important to ensure that, just like a database transaction, either all the --- +Figure below shows how consistency between aggregates is achieved by domain events. When the user initiates an order, the `Order Aggregate` sends an `OrderStarted` domain event. The OrderStarted domain event is handled by the `Buyer Aggregate` to create a Buyer object in the ordering microservice (bounded context). Please read [Domain Events](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation) for more details. + +![image](https://user-images.githubusercontent.com/6259981/204060193-d2f5241e-c1d2-46ab-a16d-1c3047bc151b.png) + + ## Two Approaches to Use DomainEvents ### Approach 1: Using Publisher and Handler Directly @@ -73,7 +78,7 @@ public class OrderPlaced : IDomainEvent } ``` -**2. Create an Aggregate** +**2. Create an Aggregate (Publisher)** ```csharp public class OrderAggregate : Aggregate { @@ -89,6 +94,16 @@ public class OrderAggregate : Aggregate Raise(@event); } } + +public class WarehouseAggregate : Aggregate, ISubscribes +{ + public Task HandleAsync(OrderPlaced @event) + { + Console.WriteLine($"Order created: {@event.OrderId}"); + return Task.CompletedTask; + } +} + ``` **3. Register Services** diff --git a/src/DomainEvents/IAggregateFactory.cs b/src/DomainEvents/IAggregateFactory.cs index 0c76fb4..6dfa037 100644 --- a/src/DomainEvents/IAggregateFactory.cs +++ b/src/DomainEvents/IAggregateFactory.cs @@ -19,6 +19,14 @@ public interface IAggregateFactory /// A proxied instance of the aggregate implementing IDomainAggregate. Task CreateAsync(params object[] constructorArguments) where T : Aggregate; + /// + /// Creates a proxied instance of the specified aggregate type using the default constructor. + /// The proxy will intercept Raise/RaiseAsync method calls and dispatch events to handlers. + /// + /// The aggregate type. + /// A proxied instance of the aggregate implementing IDomainAggregate. + Task CreateAsync() where T : Aggregate; + /// /// Creates a proxied instance of the specified aggregate type. /// @@ -26,5 +34,36 @@ public interface IAggregateFactory /// Constructor arguments for the aggregate. /// A proxied instance of the aggregate implementing IDomainAggregate. Task CreateAsync(Type aggregateType, params object[] constructorArguments); + + /// + /// Creates a proxied aggregate from an existing aggregate instance resolved via service locator. + /// NOTE: All aggregate types (implementing IAggregate) must be pre-registered with the IoC container + /// before using this method. The resolved aggregate instance will be wrapped in a proxy that intercepts + /// Raise/RaiseAsync calls to dispatch events to registered handlers. + /// + /// The aggregate type. + /// The aggregate instance resolved from the service provider. + /// A proxied instance of the aggregate implementing IDomainAggregate. + Task CreateFromInstanceAsync(T aggregate) where T : Aggregate; + + /// + /// Creates a proxied aggregate resolved from the service provider. + /// NOTE: All aggregate types (implementing Aggregate) must be pre-registered with the IoC container + /// before using this method. The resolved aggregate instance will be wrapped in a proxy that intercepts + /// Raise/RaiseAsync calls to dispatch events to registered handlers. + /// + /// The aggregate type to resolve from the service provider. + /// A proxied instance of the aggregate implementing IDomainAggregate. + Task CreateFromServiceProviderAsync() where T : Aggregate; + + /// + /// Creates a proxied aggregate resolved from the service provider. + /// NOTE: All aggregate types (implementing Aggregate) must be pre-registered with the IoC container + /// before using this method. The resolved aggregate instance will be wrapped in a proxy that intercepts + /// Raise/RaiseAsync calls to dispatch events to registered handlers. + /// + /// The aggregate type to resolve from the service provider. + /// A proxied instance of the aggregate implementing IDomainAggregate. + Task CreateFromServiceProviderAsync(Type aggregateType); } } diff --git a/src/DomainEvents/ISubscribes.cs b/src/DomainEvents/ISubscribes.cs new file mode 100644 index 0000000..774a95f --- /dev/null +++ b/src/DomainEvents/ISubscribes.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace DomainEvents +{ + /// + /// Interface for domain event subscriptions implemented by aggregates. + /// Aggregates can implement this interface to explicitly subscribe to and handle domain events. + /// + /// The event type to subscribe to. + public interface ISubscribes : IHandler where TEvent : IDomainEvent + { + } +} diff --git a/src/DomainEvents/Impl/AggregateFactory.cs b/src/DomainEvents/Impl/AggregateFactory.cs index 20175e9..8de1ad3 100644 --- a/src/DomainEvents/Impl/AggregateFactory.cs +++ b/src/DomainEvents/Impl/AggregateFactory.cs @@ -1,4 +1,6 @@ using System; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Castle.DynamicProxy; using Microsoft.Extensions.DependencyInjection; @@ -33,15 +35,21 @@ public AggregateFactory(IServiceProvider serviceProvider) /// A proxied instance of the aggregate implementing IDomainAggregate. public Task CreateAsync(params object[] constructorArguments) where T : Aggregate { - var interceptor = _serviceProvider.GetService(); - - if (interceptor == null) - { - var dispatcher = _serviceProvider.GetService() - ?? new EventDispatcher(_serviceProvider.GetRequiredService()); - interceptor = new EventInterceptor(dispatcher); - } + var interceptor = GetInterceptor(); + var proxy = _proxyGenerator.CreateClassProxy(constructorArguments, interceptor); + return Task.FromResult(proxy); + } + + /// + /// Creates a proxied instance of the specified aggregate type using the default constructor. + /// The proxy will intercept Raise/RaiseAsync method calls and dispatch events to handlers. + /// + /// The aggregate type. + /// A proxied instance of the aggregate implementing IDomainAggregate. + public Task CreateAsync() where T : Aggregate + { + var interceptor = GetInterceptor(); var proxy = _proxyGenerator.CreateClassProxy(interceptor); return Task.FromResult(proxy); } @@ -53,6 +61,119 @@ public Task CreateAsync(params object[] constructorArguments) where T : Ag /// Constructor arguments for the aggregate. /// A proxied instance of the aggregate implementing IDomainAggregate. public Task CreateAsync(Type aggregateType, params object[] constructorArguments) + { + var interceptor = GetInterceptor(); + + var proxy = (IDomainAggregate)_proxyGenerator.CreateClassProxy(aggregateType, constructorArguments, interceptor); + return Task.FromResult(proxy); + } + + /// + /// Creates a proxied aggregate from an existing aggregate instance resolved via service locator. + /// NOTE: All aggregate types (implementing IAggregate) must be pre-registered with the IoC container + /// before using this method. The resolved aggregate instance will be wrapped in a proxy that intercepts + /// Raise/RaiseAsync calls to dispatch events to registered handlers. + /// + /// The aggregate type. + /// The aggregate instance resolved from the service provider. + /// A proxied instance of the aggregate implementing IDomainAggregate. + public Task CreateFromInstanceAsync(T aggregate) where T : Aggregate + { + var interceptor = GetInterceptor(); + var proxy = _proxyGenerator.CreateClassProxyWithTarget(aggregate, interceptor); + return Task.FromResult(proxy); + } + + /// + /// Creates a proxied aggregate resolved from the service provider using constructor resolution. + /// Uses reflection to find the constructor with the most parameters, resolves those parameters + /// from the service provider, and creates a proxy with the resolved instance. + /// NOTE: All dependencies required by the aggregate constructor must be registered with the IoC container. + /// + /// The aggregate type to resolve from the service provider. + /// A proxied instance of the aggregate implementing IDomainAggregate. + public Task CreateFromServiceProviderAsync() where T : Aggregate + { + return CreateFromServiceProviderAsync(typeof(T).GetTypeInfo()); + } + + /// + /// Creates a proxied aggregate resolved from the service provider using constructor resolution. + /// Uses reflection to find the constructor with the most parameters, resolves those parameters + /// from the service provider, and creates a proxy with the resolved instance. + /// NOTE: All dependencies required by the aggregate constructor must be registered with the IoC container. + /// + /// The aggregate type to resolve from the service provider. + /// A proxied instance of the aggregate implementing IDomainAggregate. + public Task CreateFromServiceProviderAsync(Type aggregateType) + { + return CreateFromServiceProviderAsync(aggregateType.GetTypeInfo()); + } + + private Task CreateFromServiceProviderAsync(TypeInfo aggregateTypeInfo) where T : class + { + var constructor = FindConstructor(aggregateTypeInfo); + var parameters = ResolveConstructorParameters(constructor); + var interceptor = GetInterceptor(); + + var proxy = (T)_proxyGenerator.CreateClassProxy(aggregateTypeInfo.AsType(), parameters, interceptor); + return Task.FromResult(proxy); + } + + private ConstructorInfo FindConstructor(TypeInfo aggregateTypeInfo) + { + var constructors = aggregateTypeInfo.DeclaredConstructors + .Where(c => !c.IsStatic) + .OrderByDescending(c => c.GetParameters().Length) + .ToList(); + + if (!constructors.Any()) + { + throw new InvalidOperationException($"No constructor found for type {aggregateTypeInfo.Name}"); + } + + foreach (var constructor in constructors) + { + var parameters = constructor.GetParameters(); + var canResolve = parameters.All(p => _serviceProvider.GetService(p.ParameterType) != null || !p.HasDefaultValue == false); + + if (canResolve) + { + return constructor; + } + } + + return constructors.First(); + } + + private object[] ResolveConstructorParameters(ConstructorInfo constructor) + { + var parameters = constructor.GetParameters(); + var resolved = new object[parameters.Length]; + + for (int i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var service = _serviceProvider.GetService(param.ParameterType); + + if (service != null) + { + resolved[i] = service; + } + else if (param.HasDefaultValue) + { + resolved[i] = param.DefaultValue; + } + else + { + resolved[i] = _serviceProvider.GetRequiredService(param.ParameterType); + } + } + + return resolved; + } + + private IEventInterceptor GetInterceptor() { var interceptor = _serviceProvider.GetService(); @@ -62,9 +183,8 @@ public Task CreateAsync(Type aggregateType, params object[] co ?? new EventDispatcher(_serviceProvider.GetRequiredService()); interceptor = new EventInterceptor(dispatcher); } - - var proxy = (IDomainAggregate)_proxyGenerator.CreateClassProxy(aggregateType, interceptor); - return Task.FromResult(proxy); + + return interceptor; } } } diff --git a/test/DomainEvents.Tests/Aggregates/IOrderService.cs b/test/DomainEvents.Tests/Aggregates/IOrderService.cs new file mode 100644 index 0000000..cf9ba93 --- /dev/null +++ b/test/DomainEvents.Tests/Aggregates/IOrderService.cs @@ -0,0 +1,18 @@ +namespace DomainEvents.Tests.Aggregates +{ + public interface IOrderService + { + void DoSomethingWithOrder(string orderNo); + int Counter { get; } + } + + public class OrderService : IOrderService + { + public int Counter { get; private set; } = 0; + public void DoSomethingWithOrder(string orderNo) + { + Counter++; + Console.WriteLine($"OrderService is doing something with order: {orderNo}"); + } + } +} \ No newline at end of file diff --git a/test/DomainEvents.Tests/Aggregates/TestAggregates.cs b/test/DomainEvents.Tests/Aggregates/TestAggregates.cs index f3ebbe4..59e85e7 100644 --- a/test/DomainEvents.Tests/Aggregates/TestAggregates.cs +++ b/test/DomainEvents.Tests/Aggregates/TestAggregates.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using DomainEvents.Tests.Events; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace DomainEvents.Tests.Aggregates { @@ -19,11 +20,29 @@ public void RegisterCustomer(string name) Raise(@event); } } + public class OrderAggregate : Aggregate + { + private IOrderService service; + + public OrderAggregate(IOrderService service) : base() + { + this.service = service; + } + + public void CreateOrder(string orderNo) + { + // Some business logic here... + service.DoSomethingWithOrder(orderNo); + + var @event = new OrderReceived { OrderNo = orderNo }; + Raise(@event); + } + } /// /// Test aggregate that handles OrderCreated events and raises OrderProcessed events. /// - public class WarehouseAggregate : Aggregate, IHandler + public class WarehouseAggregate : Aggregate, ISubscribes { private readonly List _receivedOrders = new(); diff --git a/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs b/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs index a59c29f..88e5fa1 100644 --- a/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs +++ b/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs @@ -95,7 +95,11 @@ public async Task CreateAsync_WarehouseAggregate_RaiseShouldDispatchEvents() services.AddSingleton(); services.AddSingleton(_ => { - var handlers = new List { new OrderReceivedHandler(handlerResult) }; + var handlers = new List + { + new CustomerCreatedHandler(handlerResult), + new OrderReceivedHandler(handlerResult) + }; return new Resolver(handlers); }); services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); @@ -218,5 +222,45 @@ public void Aggregate_WithoutProxy_ShouldNotDispatchEvents() // Assert - no handlers should be called since we're not using a proxy Assert.That(handlerResult.Count, Is.EqualTo(0)); } + + [Test] + public async Task RaiseAsync_OnProxiedAggregate_ShouldDispatch() + { + // Arrange + var handlerResult = new Dictionary(); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(_ => + { + var handlers = new List + { + new CustomerCreatedHandler(handlerResult), + new OrderReceivedHandler(handlerResult) + }; + return new Resolver(handlers); + }); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new EventListener(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + // Act + var order = await factory.CreateFromServiceProviderAsync(); + order.CreateOrder("O-1234"); + + // Wait for event processing + await Task.Delay(100); + + // Assert + Assert.That(handlerResult.Count, Is.GreaterThan(0)); + + var orderService = serviceProvider.GetRequiredService(); + + Assert.That(orderService.Counter, Is.EqualTo(1)); + } } } diff --git a/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs b/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs index fea13bd..b784f7c 100644 --- a/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs +++ b/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs @@ -128,5 +128,117 @@ public async Task CreateAsync_WithNullConstructorArgs_ShouldWork() // Act & Assert Assert.DoesNotThrowAsync(async () => await _factory.CreateAsync(null)); } + + [Test] + public async Task CreateFromInstanceAsync_ShouldReturnProxiedInstance() + { + // Arrange + var original = new CustomerAggregate(); + + // Act + var proxied = await _factory.CreateFromInstanceAsync(original); + + // Assert + Assert.That(proxied, Is.Not.Null); + Assert.That(proxied, Is.InstanceOf()); + Assert.That(proxied, Is.InstanceOf()); + } + + [Test] + public async Task CreateFromInstanceAsync_ProxiedInstance_ShouldDispatchEvents() + { + // Arrange + var original = new CustomerAggregate(); + var proxied = await _factory.CreateFromInstanceAsync(original); + + // Act + proxied.RegisterCustomer("FromInstance Test"); + + // Assert + Assert.That(_handlerResult.Count, Is.EqualTo(1)); + Assert.That(_handlerResult.Values.First(), Is.EqualTo(typeof(CustomerCreatedHandler))); + } + + [Test] + public async Task CreateFromInstanceAsync_NonGeneric_ShouldReturnProxiedInstance() + { + // Arrange + var original = new CustomerAggregate(); + + // Act + var proxied = await _factory.CreateFromInstanceAsync(original); + + // Assert + Assert.That(proxied, Is.Not.Null); + Assert.That(proxied, Is.InstanceOf()); + } + + [Test] + public async Task CreateFromInstanceAsync_ProxiedWarehouse_ShouldHandleEvents() + { + // Arrange + var original = new WarehouseAggregate(); + var proxied = await _factory.CreateFromInstanceAsync(original); + + // Act + proxied.ProcessOrder("ORD-INSTANCE"); + + // Assert + Assert.That(_handlerResult.Count, Is.GreaterThan(0)); + } + + [Test] + public async Task CreateFromInstanceAsync_MultipleProxiesFromSameInstance_ShouldBothDispatchEvents() + { + // Arrange + var original = new WarehouseAggregate(); + + // Act + var proxy1 = await _factory.CreateFromInstanceAsync(original); + var proxy2 = await _factory.CreateFromInstanceAsync(original); + + proxy1.ProcessOrder("PROXY-1"); + proxy2.ProcessOrder("PROXY-2"); + + // Assert - both proxies dispatch events to handlers + Assert.That(_handlerResult.Count, Is.EqualTo(2)); + } + + [Test] + public async Task CreateFromServiceProviderAsync_ShouldReturnProxiedInstance() + { + // Arrange - register aggregate with DI + var services = new ServiceCollection(); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(_ => + { + var handlers = new List(); + return new Resolver(handlers); + }); + services.AddSingleton(sp => new EventDispatcher(sp.GetRequiredService(), sp.GetRequiredService())); + var sp = services.BuildServiceProvider(); + + var factory = new AggregateFactory(sp); + + // Act + var proxied = await factory.CreateFromServiceProviderAsync(); + + // Assert + Assert.That(proxied, Is.Not.Null); + Assert.That(proxied, Is.InstanceOf()); + Assert.That(proxied, Is.InstanceOf()); + } + + [Test] + public async Task CreateAsync_DefaultConstructor_ShouldWork() + { + // Arrange & Act + var proxied = await _factory.CreateAsync(); + + // Assert + Assert.That(proxied, Is.Not.Null); + Assert.That(proxied, Is.InstanceOf()); + } } } From 9a9904dd5452d68490440f23485ac987ef331572 Mon Sep 17 00:00:00 2001 From: Ninja Date: Sun, 15 Mar 2026 00:29:30 +0000 Subject: [PATCH 6/8] - Update documentation --- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++------ WIKI.md | 79 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ceac0c4..542089f 100644 --- a/README.md +++ b/README.md @@ -122,19 +122,68 @@ order.PlaceOrder(100.00m); // Event is automatically dispatched to handlers ## Architecture Flow +### Event Processing Flow + ``` -Aggregate -> Interceptor -> Middleware -> Dispatcher -> Queue <- Listener -> Middleware -> Resolver -> Handler +┌────────────────────────────────────────────────────────────────────────────────┐ +│ PUBLISHING PHASE │ +│ (Aggregate.Raise() → Queue.Enqueue) │ +└────────────────────────────────────────────────────────────────────────────────┘ + + Aggregate.Raise() + │ + ▼ + ┌───────────┐ ┌───────────┐ ┌────────────┐ ┌────────────┐ + │ Aggregate │────▶│Interceptor│────▶│ Middleware│────▶│ Dispatcher │ + │ │ │ (Proxy) │ │(OnDispatch)│ │ │ + └───────────┘ └───────────┘ └────────────┘ └─────┬──────┘ + │ + ▼ + ┌───────────────┐ + │ Queue │ + │ (In-Memory) │ + └───────────────┘ + +┌────────────────────────────────────────────────────────────────────────────────┐ +│ SUBSCRIPTION PHASE │ +│ (Queue → Listener → Handler) │ +└────────────────────────────────────────────────────────────────────────────────┘ + + Queue notifies Listener + │ + ▼ + ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌───────────┐ + │ Listener │────▶│ Middleware │────▶│ Resolver │────▶│ Handler │ + │ │ │(OnHandling)│ │ │ │ │ + └───────────┘ └────────────┘ └─────┬─────┘ └───────────┘ + │ + ▼ + ┌──────────────────┐ + │ IHandler │ + │ ISubscribes │ + │ (includes │ + │ aggregates) │ + └──────────────────┘ ``` +### Flow Summary + +1. **PUBLISHING PHASE** - Aggregate.Raise() → Interceptor → Middleware.OnDispatching() → Dispatcher → Queue.Enqueue() → Middleware.OnDispatched() +2. **SUBSCRIPTION PHASE** - Queue notifies Listener → Middleware.OnHandling() → Resolver (finds handlers) → Handler.HandleAsync() (includes ISubscribes) → Middleware.OnHandled() + +**Note:** `ISubscribes` - Aggregates can implement ISubscribes to handle events they raise. The proxy ensures both business logic AND handler execute. + +--- + **Components:** -- **Aggregate** - Domain aggregate that raises events via `Raise()` or `RaiseAsync()` -- **Interceptor** - Castle DynamicProxy interceptor (automatically dispatches events) -- **Middleware** - Custom plugins that run before/after dispatch and handling -- **Dispatcher** - Dispatches events (enqueues to queue) -- **Queue** - In-flight non-persistent queue (fire-and-forget) -- **Listener** - Processes events from queue via subscription delegate -- **Resolver** - Resolves handlers for events -- **Handler** - Handles the events +- **Aggregate** - Domain aggregate that raises events via `Raise()` or `RaiseAsync()`. Can also implement `ISubscribes`. +- **Interceptor** - Castle DynamicProxy that intercepts `Raise()`/`RaiseAsync()` and dispatches events. +- **Middleware** - Custom plugins: `OnDispatching`, `OnDispatched`, `OnHandling`, `OnHandled`. +- **Dispatcher** - Enqueues events to the queue. +- **Queue** - In-memory queue (fire-and-forget). +- **Listener** - Processes events from queue asynchronously. +- **Resolver** - Resolves handlers for events. +- **Handler** - Handles events: `IHandler` or `ISubscribes`. --- @@ -173,10 +222,39 @@ public class MyMiddleware : IEventMiddleware **Registration:** ```csharp -services.AddDomainEvents(assembly); -services.AddSingleton(); +services.AddDomainEvents(assembly); // auto-registers handlers and middlewares which have parameter-less constructor. For types with parameterized constructor, you need to explicitly register as below. +services.AddSingleton(); +``` + +--- + +## AggregateFactory Methods + +The `IAggregateFactory` provides multiple methods to create proxied aggregates: + +| Method | Description | +|--------|-------------| +| `CreateAsync()` | Creates proxy using default constructor | +| `CreateAsync(params object[])` | Creates proxy with specified constructor arguments | +| `CreateAsync(Type, params object[])` | Non-generic version with constructor arguments | +| `CreateFromInstanceAsync(T aggregate)` | Wraps existing aggregate instance in proxy | +| `CreateFromServiceProviderAsync()` | Resolves from DI and wraps in proxy (auto-resolves constructor dependencies) | +| `CreateFromServiceProviderAsync(Type)` | Non-generic version resolving from DI | + +**Example - Using CreateFromServiceProviderAsync:** +```csharp +// Register aggregate with DI (constructor dependencies auto-resolved) +services.AddTransient(); + +var factory = serviceProvider.GetRequiredService(); + +// Creates proxy, resolves OrderAggregate from DI, wraps in proxy +var order = await factory.CreateFromServiceProviderAsync(); +order.PlaceOrder(100.00m); // Events dispatched automatically ``` +**Note:** When using `CreateFromServiceProviderAsync`, all constructor dependencies must be registered with the IoC container. The factory uses reflection to find the constructor with most parameters and resolves them from the service provider. + --- ## Interface Summary @@ -185,6 +263,7 @@ services.AddSingleton(); |-----------|---------| | `IDomainEvent` | Marker interface for domain events | | `IHandler` | Async handler interface | +| `ISubscribes` | Aggregate handler interface (implemented by aggregates to handle their own events) | | `IPublisher` | Interface for raising events | | `IAggregateFactory` | Factory for creating proxied aggregates | | `IEventMiddleware` | Plugin for event pipeline | diff --git a/WIKI.md b/WIKI.md index 5d85ba6..9081afa 100644 --- a/WIKI.md +++ b/WIKI.md @@ -127,11 +127,28 @@ DomainEvents is a library for implementing transactional domain events in domain 3. **EventDispatcher.DispatchAsync()** - Runs dispatch middleware, enqueues event 4. **InMemoryEventQueue** - Stores event, invokes subscribed delegate immediately 5. **EventListener** - Receives callback, processes event through handle middleware -6. **Resolver** - Resolves handlers for the event type -7. **Handler** - Processes the event +6. **Resolver** - Resolves handlers for the event type (includes `ISubscribes` implementations on aggregates) +7. **Handler** - Processes the event (either standalone `IHandler` or aggregate's `ISubscribes.HandleAsync()`) **Note**: The dispatcher returns immediately after enqueueing (fire-and-forget). Event processing happens asynchronously via the queue subscription delegate. +### Two-Phase Event Processing + +1. **Synchronous Phase** (Aggregate.Raise → Queue.Enqueue): + - Aggregate raises event via `Raise()` or `RaiseAsync()` + - EventInterceptor intercepts and calls EventDispatcher + - Dispatch middleware runs (`OnDispatchingAsync`) + - Event is enqueued to queue + - Dispatched middleware runs (`OnDispatchedAsync`) + - Returns to caller (aggregate business logic completes) + +2. **Asynchronous Phase** (Queue → Handler): + - Queue notifies subscribed listener + - Listener processes through handle middleware (`OnHandlingAsync`) + - Resolver finds all handlers (including `ISubscribes` implementations) + - Each handler's `HandleAsync()` is called + - Handle middleware runs (`OnHandledAsync`) + --- ## Core Concepts @@ -244,6 +261,34 @@ public class OrderAggregate : Aggregate } ``` +### 4a. Aggregate with ISubscribes (Self-Handling) + +Aggregates can implement `ISubscribes` to handle events they raise themselves: + +```csharp +public class OrderAggregate : Aggregate, ISubscribes +{ + public Task HandleAsync(OrderPlaced @event) + { + // Handle the event within the same aggregate + Console.WriteLine($"Order placed: {@event.OrderId}"); + return Task.CompletedTask; + } + + public void PlaceOrder(decimal amount) + { + var @event = new OrderPlaced + { + OrderId = Guid.NewGuid().ToString(), + Amount = amount + }; + Raise(@event); + } +} +``` + +**Note**: When an aggregate implements `ISubscribes`, the handler is called via the `Resolver` during the asynchronous event processing phase. This happens after the `Raise()` call completes (fire-and-forget pattern). + ### 5. Register Services ```csharp @@ -1017,6 +1062,7 @@ public class AnotherMiddleware : IEventMiddleware |-----------|-------------| | `IDomainEvent` | Marker interface for domain events | | `IHandler` | Async handler interface for specific event type | +| `ISubscribes` | Aggregate handler interface - implemented by aggregates to handle their own events | | `IHandler` | Marker interface for handlers | | `IPublisher` | Interface for manually raising events | | `IResolver` | Interface for resolving handlers | @@ -1059,6 +1105,35 @@ public class AnotherMiddleware : IEventMiddleware | `AddDomainEventsWithDispatcher(dispatcher, assemblies)` | Register with custom dispatcher instance | | `AddDomainEventsWithTelemetry(assemblies)` | Register with OpenTelemetry support | +### IAggregateFactory Methods + +The `IAggregateFactory` provides multiple methods to create proxied aggregates: + +| Method | Description | +|--------|-------------| +| `CreateAsync()` | Creates proxy using default constructor | +| `CreateAsync(params object[])` | Creates proxy with specified constructor arguments | +| `CreateAsync(Type, params object[])` | Non-generic version with constructor arguments | +| `CreateFromInstanceAsync(T aggregate)` | Wraps existing aggregate instance in proxy | +| `CreateFromServiceProviderAsync()` | Resolves from DI and wraps in proxy (auto-resolves constructor deps) | +| `CreateFromServiceProviderAsync(Type)` | Non-generic version resolving from DI | + +**Example - Using CreateFromServiceProviderAsync:** + +```csharp +// Register aggregate with DI (constructor dependencies auto-resolved) +services.AddTransient(); +services.AddTransient(); + +var factory = serviceProvider.GetRequiredService(); + +// Creates proxy, resolves OrderAggregate from DI, wraps in proxy +var order = await factory.CreateFromServiceProviderAsync(); +order.PlaceOrder(100.00m); // Events dispatched automatically +``` + +**Note:** When using `CreateFromServiceProviderAsync`, all constructor dependencies must be registered with the IoC container. The factory uses reflection to find the constructor with most parameters and resolves them from the service provider. + --- ## Best Practices From df4610550ece907ad0f8282fbdae4e0e87aff885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=98DE=20N!NJ=CE=94?= Date: Sun, 15 Mar 2026 00:36:33 +0000 Subject: [PATCH 7/8] Update README.md --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 542089f..768dfc5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ -# events DomainEvents v4.0.1 + +# events DomainEvents v5.0.0 [![NuGet version](https://badge.fury.io/nu/Dormito.DomainEvents.svg)](https://badge.fury.io/nu/Dormito.DomainEvents) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/CodeShayk/DomainEvents/blob/master/License.md) [![Build](https://github.com/CodeShayk/DomainEvents/actions/workflows/master-build.yml/badge.svg)](https://github.com/CodeShayk/DomainEvents/actions/workflows/master-build.yml) [![CodeQL](https://github.com/CodeShayk/DomainEvents/actions/workflows/master-codeQL.yml/badge.svg)](https://github.com/CodeShayk/DomainEvents/actions/workflows/master-codeQL.yml) [![GitHub Release](https://img.shields.io/github/v/release/CodeShayk/DomainEvents?logo=github&sort=semver)](https://github.com/CodeShayk/DomainEvents/releases/latest) +[![.Net 10.0](https://img.shields.io/badge/.Net-10.0-green)](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) [![.Net 9.0](https://img.shields.io/badge/.Net-9.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) -[![.Net Standard 2.0](https://img.shields.io/badge/.NetStandard-2.0-green)](https://github.com/dotnet/standard/blob/v2.0.0/docs/versions/netstandard2.0.md) -[![.Net Framework 4.6.4](https://img.shields.io/badge/.Net-4.6.4-blue)](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net46) +[![.Net 8.0](https://img.shields.io/badge/.Net-8.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +[![.Net Standard 2.1](https://img.shields.io/badge/.NetStandard-2.1-green)](https://github.com/dotnet/standard/blob/v2.0.0/docs/versions/netstandard2.1.md) +[![.Net Standard 2.0](https://img.shields.io/badge/.NetStandard-2.0-blue)](https://github.com/dotnet/standard/blob/v2.0.0/docs/versions/netstandard2.0.md) + ## Library to help implement transactional events in domain bounded context. Use domain events to explicitly implement side effects of changes within your domain. In other words, and using DDD terminology, use domain events to explicitly implement side effects across multiple aggregates. From 5bdf92adb2c2da4d6bc47feae8815a493079c0bd Mon Sep 17 00:00:00 2001 From: Ninja Date: Sun, 15 Mar 2026 00:58:54 +0000 Subject: [PATCH 8/8] - release planning --- License.md | 2 +- RELEASE.md | 78 ++++++++++------------------ src/DomainEvents/DomainEvents.csproj | 18 +++---- src/DomainEvents/assemblyinfo.cs | 6 +-- version.json | 2 +- 5 files changed, 38 insertions(+), 68 deletions(-) diff --git a/License.md b/License.md index c1fcb07..6868373 100644 --- a/License.md +++ b/License.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2025 Code Shayk +Copyright (c) 2026 Code Shayk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/RELEASE.md b/RELEASE.md index 6b94c3a..2c7641a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,82 +1,56 @@ # Release Notes - v5.0.0 ## New Architecture - ``` -Aggregate -> Interceptor -> Middleware -> Dispatcher -> Queue <- Listener -> Middleware -> Resolver -> Handler +Aggregate → Interceptor → Middleware → Dispatcher → Queue ← Listener → Resolver → Handler ``` -### Components: - -1. **IEventMiddleware** - Custom plugins that run before/after dispatch and handling -2. **IEventQueue** - In-flight non-persistent queue for events -3. **IEventListener** - Listens to queue and triggers handling - ## New Features -### 1. Event Middleware - -Custom plugins that run at various points in the event pipeline: - +### 1. ISubscribes +Aggregates can handle their own events: ```csharp -public class MyMiddleware : IEventMiddleware +public class OrderAggregate : Aggregate, ISubscribes { - public Task OnDispatchingAsync(EventContext context) { ... } - public Task OnDispatchedAsync(EventContext context) { ... } - public Task OnHandlingAsync(EventContext context) { ... } - public Task OnHandledAsync(EventContext context) { ... } + public Task HandleAsync(OrderPlaced @event) => ...; + public void PlaceOrder(decimal amount) => Raise(new OrderPlaced()); } ``` -**Registration:** +### 2. AggregateFactory +Multiple methods to create proxied aggregates: ```csharp -services.AddDomainEvents(assembly); -services.AddSingleton(); -``` +// Default constructor +var order = await factory.CreateAsync(); -### 2. Event Queue +// With constructor arguments +var order = await factory.CreateAsync(logger); -In-flight non-persistent queue for events: - -```csharp -// Use default in-memory queue -services.AddDomainEvents(assembly); +// From service provider (auto-resolves deps) +var order = await factory.CreateFromServiceProviderAsync(); -// Or use custom queue -services.AddSingleton(); - -// Process queue -var dispatcher = serviceProvider.GetRequiredService(); -await dispatcher.ProcessQueueAsync(); +// Wrap existing instance +var order = await factory.CreateFromInstanceAsync(existingOrder); ``` -### 3. Custom Dispatcher - -```csharp -services.AddDomainEventsWithDispatcher(assembly); -``` +### 3. Event Middleware (IEventMiddleware) +Pipeline hooks: `OnDispatchingAsync`, `OnDispatchedAsync`, `OnHandlingAsync`, `OnHandledAsync` -### 4. Standard EventInterceptor with Telemetry -The `EventInterceptor` remains standard with OpenTelemetry and logging. +### 4. Event Queue (IEventQueue) +In-flight non-persistent queue with subscription support -### 5. Async Handler Interface (IHandler) -Changed from `IHandle` to `IHandler` with async `HandleAsync()`. +### 5. Event Listener (IEventListener) +Processes queued events asynchronously ## Breaking Changes +- `IHandle` → `IHandler` +- `Handle()` → `HandleAsync()` returning `Task` -1. `IHandle` -> `IHandler` -2. `Handle()` -> `HandleAsync()` returning `Task` -3. `EventInterceptor` requires `IEventDispatcher` - -## Migration Guide - +## Migration ```csharp // Before public class Handler : IHandle { void Handle(Event e) { } } // After -public class Handler : IHandler -{ - Task HandleAsync(Event e) => Task.CompletedTask; -} +public class Handler : IHandler { Task HandleAsync(Event e) => Task.CompletedTask; } ``` diff --git a/src/DomainEvents/DomainEvents.csproj b/src/DomainEvents/DomainEvents.csproj index 8a6b15d..e34ebc2 100644 --- a/src/DomainEvents/DomainEvents.csproj +++ b/src/DomainEvents/DomainEvents.csproj @@ -16,7 +16,7 @@ License.md False snupkg - DomainEvents + Domain Events CodeShayk CodeShayk DomainEvents @@ -26,21 +26,17 @@ https://github.com/CodeShayk/DomainEvents git domain-events; domain-model, events; event-handling; c#; transactional-events; domain-pub/sub; event-pub-sub; ddd; aggregates; castle-dynamicproxy - 4.1.0 + 5.0.0 pub-sub-icon.png True https://github.com/CodeShayk/DomainEvents/wiki - Release v4.2.0 - Updated all dependencies to latest stable versions - - OpenTelemetry.Api 1.11.2 -> 1.15.0 (security fix for CVE-2025-27513) - - Castle.Core 5.1.1 -> 5.2.1 - - Microsoft.Extensions 9.0.0 -> 10.0.5 - - Targets .Net 10.0 - - Target .Net 9.0 - - Target .Net 8.0 - - Target .Net Standard 2.1 - - Target .Net Standard 2.0 + Release v5.0.0 - Major update with new features, improvements, and bug fixes. + This release includes enhanced aggregate support, improved event handling performance, and better integration with Castle DynamicProxy. + For more details, see the release notes on GitHub: https://github.com/CodeShayk/DomainEvents/releases/tag/v5.0.0 + 5.0.0 + 5.0.0 diff --git a/src/DomainEvents/assemblyinfo.cs b/src/DomainEvents/assemblyinfo.cs index 6715fbe..f0d22c6 100644 --- a/src/DomainEvents/assemblyinfo.cs +++ b/src/DomainEvents/assemblyinfo.cs @@ -15,11 +15,11 @@ [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyCopyrightAttribute("2026")] [assembly: System.Reflection.AssemblyDescriptionAttribute(".Net Library to implement transactional events in domain model.")] -[assembly: System.Reflection.AssemblyFileVersionAttribute("4.1.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("4.1.0")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("5.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("5.0.0")] [assembly: System.Reflection.AssemblyProductAttribute("Dormito")] [assembly: System.Reflection.AssemblyTitleAttribute("DomainEvents")] -[assembly: System.Reflection.AssemblyVersionAttribute("4.1.0")] +[assembly: System.Reflection.AssemblyVersionAttribute("5.0.0")] [assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/Codeshayk/DomainEvents")] // Generated by the MSBuild WriteCodeFragment class. diff --git a/version.json b/version.json index 9c336c0..af7169d 100644 --- a/version.json +++ b/version.json @@ -17,5 +17,5 @@ }, "inherit": false, "publicReleaseRefSpec": [ "^refs/heads/master$" ], - "version": "4.1.0" + "version": "5.0.0" } \ No newline at end of file