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/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/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..768dfc5 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,282 @@ -# 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. + ### 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) -### How to Define, Publish and Subscribe to an Event using DomainEvents library? +## Two Approaches to Use DomainEvents + +### Approach 1: Using Publisher and Handler Directly -1. Define - To implement a domain event, simply derive the event class from `IDomainEvent` interface. +Define, publish, and subscribe to events using `IPublisher` and `IHandler`. + +**1. Define an Event** +```csharp +public class CustomerCreated : IDomainEvent +{ + public string Name { get; set; } +} ``` -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; + } } - ``` -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); + +**3. Register Services** +```csharp +services.AddDomainEvents(typeof(CustomerCreatedHandler).Assembly); ``` -3. Subscribe - To listen to a domain event, implement `IHandler` interface where T is the event type you intend to handle. + +**4. Publish Events** +```csharp +var publisher = serviceProvider.GetRequiredService(); +await publisher.RaiseAsync(new CustomerCreated { Name = "John Doe" }); ``` -public class CustomerCreatedHandler : IHandler + +--- + +### Approach 2: Using Interception (Aggregate + Factory) + +Raise events automatically from domain aggregates using Castle DynamicProxy interception. + +**1. Define an Event** +```csharp +public class OrderPlaced : IDomainEvent +{ + public string OrderId { get; set; } + public decimal Amount { get; set; } +} +``` + +**2. Create an Aggregate (Publisher)** +```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); + } +} + +public class WarehouseAggregate : Aggregate, ISubscribes { - public Task HandleAsync(CustomerCreated @event) - { - Console.WriteLine($"Customer created: {@event.Name}"); - ..... - } + public Task HandleAsync(OrderPlaced @event) + { + Console.WriteLine($"Order created: {@event.OrderId}"); + return Task.CompletedTask; + } } + +``` + +**3. Register Services** +```csharp +services.AddDomainEvents(typeof(OrderPlacedHandler).Assembly); +``` + +**4. Create Aggregate and Raise Event** +```csharp +var factory = serviceProvider.GetRequiredService(); +var order = await factory.CreateAsync(); +order.PlaceOrder(100.00m); // Event is automatically dispatched to handlers +``` + +--- + +## Architecture Flow + +### Event Processing Flow + ``` -4. Example - IoC Container Registrations +┌────────────────────────────────────────────────────────────────────────────────┐ +│ 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) │ + └──────────────────┘ ``` -public void ConfigureServices(IServiceCollection services) -{ - // register publisher with required lifetime. - services.AddTransient(); - - // register all implemented event handlers. - services.AddTransient(); - services.AddTransient(); + +### 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()`. 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`. + +--- + +## Event Middleware + +Custom plugins that run at various points in the event pipeline: + +```csharp +public class MyMiddleware : IEventMiddleware +{ + public Task OnDispatchingAsync(EventContext context) + { + // Runs before event is dispatched + return Task.FromResult(true); + } + + 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 Task OnHandledAsync(EventContext context) + { + // Runs after each handler processes the event + return Task.CompletedTask; + } } ``` + +**Registration:** +```csharp +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 + +| Interface | Purpose | +|-----------|---------| +| `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 | +| `IEventQueue` | In-flight event queue | + +--- + +## Package Information + +- **Package ID**: `Dormito.DomainEvents` +- **Target Frameworks**: netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0 +- **License**: MIT diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..2c7641a --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,56 @@ +# Release Notes - v5.0.0 + +## New Architecture +``` +Aggregate → Interceptor → Middleware → Dispatcher → Queue ← Listener → Resolver → Handler +``` + +## New Features + +### 1. ISubscribes +Aggregates can handle their own events: +```csharp +public class OrderAggregate : Aggregate, ISubscribes +{ + public Task HandleAsync(OrderPlaced @event) => ...; + public void PlaceOrder(decimal amount) => Raise(new OrderPlaced()); +} +``` + +### 2. AggregateFactory +Multiple methods to create proxied aggregates: +```csharp +// Default constructor +var order = await factory.CreateAsync(); + +// With constructor arguments +var order = await factory.CreateAsync(logger); + +// From service provider (auto-resolves deps) +var order = await factory.CreateFromServiceProviderAsync(); + +// Wrap existing instance +var order = await factory.CreateFromInstanceAsync(existingOrder); +``` + +### 3. Event Middleware (IEventMiddleware) +Pipeline hooks: `OnDispatchingAsync`, `OnDispatchedAsync`, `OnHandlingAsync`, `OnHandledAsync` + +### 4. Event Queue (IEventQueue) +In-flight non-persistent queue with subscription support + +### 5. Event Listener (IEventListener) +Processes queued events asynchronously + +## Breaking Changes +- `IHandle` → `IHandler` +- `Handle()` → `HandleAsync()` returning `Task` + +## Migration +```csharp +// Before +public class Handler : IHandle { void Handle(Event e) { } } + +// After +public class Handler : IHandler { Task HandleAsync(Event e) => Task.CompletedTask; } +``` diff --git a/WIKI.md b/WIKI.md new file mode 100644 index 0000000..9081afa --- /dev/null +++ b/WIKI.md @@ -0,0 +1,1397 @@ +# 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) + - [Event Listener](#event-listener) + - [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 (Dispatch) │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Middleware 1 │ │ Middleware 2 │ │ Middleware N │ │ +│ │ OnDispatching │ │ OnDispatching │ │ OnDispatching │ │ +│ │ OnDispatched │ │ OnDispatched │ │ OnDispatched │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └────────────────────┼────────────────────┘ │ +│ │ │ +└────────────────────────────────┼──────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Event Queue │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 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 │ │ +│ └─────────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Handler 1 │ │ Handler 2 │ │ Handler N │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└───────────────────────────────────────────────────────────────────────────────┘ +``` + +### 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 (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 + +### 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); + } +} +``` + +### 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 +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. The dispatcher runs dispatch middleware and enqueues events. Event processing is handled by the EventListener via queue subscription. + +```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; + } + } + + // Enqueue event - EventListener will process via subscription + await _queue.EnqueueAsync(context); + + context.IsDispatched = true; + + // Run dispatch middleware (after) + foreach (var middleware in _middlewares) + { + await middleware.OnDispatchedAsync(context); + } + } + + public IEventQueue Queue => _queue; +} + +--- + +### Custom Event Queue + { + 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(); + private EventDequeuedHandler _handler; + private readonly object _lock = new object(); + + public Task EnqueueAsync(EventContext context) + { + lock (_lock) + { + _queue.Enqueue(context); + } + + // Immediately invoke the subscribed handler (fire-and-forget) + _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; + } +} +``` + +**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 +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>())); +``` + +--- + +### 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: + +```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. Only types with **parameterless constructors** are auto-registered. Types with constructor parameters must be registered explicitly. + +### 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 + +### 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 with parameterless constructors +services.AddDomainEvents(typeof(MyHandler).Assembly); +``` + +### Example: Preventing Auto-Registration + +To prevent auto-registration, 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 | +| `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 | +| `IEventDispatcher` | Interface for dispatching events | +| `IEventInterceptor` | Interceptor for aggregate Raise/RaiseAsync methods | +| `IEventMiddleware` | Middleware for event pipeline | +| `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 | +|-------|-------------| +| `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 | +| `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 with subscription support | +| `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 | + +### 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 + +### 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/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..e34ebc2 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 @@ -16,26 +16,31 @@ License.md False snupkg - DomainEvents - Code Shayk - Code Shayk + Domain Events + 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 + 5.0.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 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 - + True \ @@ -49,4 +54,12 @@ + + + + + + + + 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/IAggregateFactory.cs b/src/DomainEvents/IAggregateFactory.cs new file mode 100644 index 0000000..6dfa037 --- /dev/null +++ b/src/DomainEvents/IAggregateFactory.cs @@ -0,0 +1,69 @@ +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 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. + /// + /// The aggregate type. + /// 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/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/IEventListener.cs b/src/DomainEvents/IEventListener.cs new file mode 100644 index 0000000..f806274 --- /dev/null +++ b/src/DomainEvents/IEventListener.cs @@ -0,0 +1,27 @@ +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(); + } +} 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..fc2bfed --- /dev/null +++ b/src/DomainEvents/IEventQueue.cs @@ -0,0 +1,58 @@ +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); + } +} 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/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 new file mode 100644 index 0000000..8de1ad3 --- /dev/null +++ b/src/DomainEvents/Impl/AggregateFactory.cs @@ -0,0 +1,190 @@ +using System; +using System.Linq; +using System.Reflection; +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 = 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); + } + + /// + /// 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 = 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(); + + if (interceptor == null) + { + var dispatcher = _serviceProvider.GetService() + ?? new EventDispatcher(_serviceProvider.GetRequiredService()); + interceptor = new EventInterceptor(dispatcher); + } + + return interceptor; + } + } +} diff --git a/src/DomainEvents/Impl/EventDispatcher.cs b/src/DomainEvents/Impl/EventDispatcher.cs new file mode 100644 index 0000000..a3e2ac1 --- /dev/null +++ b/src/DomainEvents/Impl/EventDispatcher.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DomainEvents.Impl; +using Microsoft.Extensions.Logging; + +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, + 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 context = new EventContext(@event); + + DispatchWithMiddlewareAsync(context).GetAwaiter().GetResult(); + } + + public async Task DispatchAsync(object @event) + { + if (@event == null) return; + + var context = new EventContext(@event); + await DispatchWithMiddlewareAsync(context); + } + + private async Task DispatchWithMiddlewareAsync(EventContext context) + { + var eventType = context.EventType; + _logger?.LogDebug("Dispatching event {EventType}", eventType.Name); + + var activity = DomainEventsActivitySource.Source.StartActivity( + DomainEventsActivitySource.PublishEventActivityName, + ActivityKind.Internal); + + if (activity != null) + { + activity.SetTag(DomainEventsTags.EventType, eventType.Name); + } + + try + { + foreach (var middleware in _middlewares) + { + if (!await middleware.OnDispatchingAsync(context)) + { + _logger?.LogDebug("Middleware {Middleware} skipped dispatching for {EventType}", + middleware.GetType().Name, eventType.Name); + return; + } + } + + await _queue.EnqueueAsync(context); + _logger?.LogDebug("Event {EventType} enqueued", eventType.Name); + + context.IsDispatched = true; + + foreach (var middleware in _middlewares) + { + await middleware.OnDispatchedAsync(context); + } + + _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(); + } + } + } +} diff --git a/src/DomainEvents/Impl/EventInterceptor.cs b/src/DomainEvents/Impl/EventInterceptor.cs new file mode 100644 index 0000000..4702140 --- /dev/null +++ b/src/DomainEvents/Impl/EventInterceptor.cs @@ -0,0 +1,110 @@ +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; + } + + /// + /// 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. + /// + /// 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/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/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..c1b7461 --- /dev/null +++ b/src/DomainEvents/ServiceCollectionExtensions.cs @@ -0,0 +1,243 @@ +using System; +using System.Linq; +using System.Reflection; +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 event queue + services.AddSingleton(); + + // 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 queue = sp.GetRequiredService(); + var middlewares = sp.GetServices(); + var logger = sp.GetService>(); + 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, listener, 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); + + 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; + } + + /// + /// 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 event queue + services.AddSingleton(); + + // 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); + } + + // 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/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..f0d22c6 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("5.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("5.0.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("5.0.0")] +[assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/Codeshayk/DomainEvents")] // Generated by the MSBuild WriteCodeFragment class. 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 new file mode 100644 index 0000000..59e85e7 --- /dev/null +++ b/test/DomainEvents.Tests/Aggregates/TestAggregates.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using DomainEvents.Tests.Events; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; + +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); + } + } + 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, ISubscribes + { + 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..88e5fa1 --- /dev/null +++ b/test/DomainEvents.Tests/Run/AggregateFactoryIntegrationTests.cs @@ -0,0 +1,266 @@ +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(); + services.AddSingleton(_ => + { + var handlers = new List { new CustomerCreatedHandler(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(); + 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(); + 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(); + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + // Act + var warehouse = await factory.CreateAsync(); + warehouse.ProcessOrder("ORD-123"); + + // 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(); + services.AddSingleton(_ => + { + var handlers = new List + { + new CustomerCreatedHandler(handlerResult), + new SimpleCustomerCreatedHandler() + }; + 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(); + 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(); + services.AddSingleton(_ => + { + var handlers = new List { new CustomerCreatedHandler(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(); + 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)); + } + + [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 new file mode 100644 index 0000000..b784f7c --- /dev/null +++ b/test/DomainEvents.Tests/Run/AggregateFactoryTests.cs @@ -0,0 +1,244 @@ +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(); + 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(); + _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)); + } + + [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()); + } + } +} diff --git a/test/DomainEvents.Tests/Run/AggregateTests.cs b/test/DomainEvents.Tests/Run/AggregateTests.cs new file mode 100644 index 0000000..8852795 --- /dev/null +++ b/test/DomainEvents.Tests/Run/AggregateTests.cs @@ -0,0 +1,107 @@ +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(); + services.AddSingleton(_ => 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(); + + _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/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/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; + } +} 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..af7169d 100644 --- a/version.json +++ b/version.json @@ -17,5 +17,5 @@ }, "inherit": false, "publicReleaseRefSpec": [ "^refs/heads/master$" ], - "version": "3.0.0" + "version": "5.0.0" } \ No newline at end of file