Dispatcher Generator
Overview
The Dispatcher Generator creates a standalone Mediator pattern implementation at compile time. It generates a complete message dispatcher with support for commands, notifications, streams, and pipelines—all with zero PatternKit runtime dependencies.
When to Use
Use the Dispatcher generator when you need to:
- Decouple components: Communicate through messages instead of direct calls
- Implement CQRS: Separate commands (write) from queries (read)
- Build event-driven systems: Fan-out notifications to multiple handlers
- Add cross-cutting concerns: Logging, validation, caching via pipelines
- Stream data: Async enumerable results for large datasets
Installation
The generator is included in the PatternKit.Generators package:
dotnet add package PatternKit.Generators
Quick Start
Add the assembly attribute to generate a dispatcher:
using PatternKit.Generators.Messaging;
[assembly: GenerateDispatcher(Namespace = "MyApp.Messaging", Name = "AppDispatcher")]
This generates:
AppDispatcher— Main dispatcher classAppDispatcher.Builder— Fluent builder for registrationIDispatcherBuilder— Interface for modular registrationIModule— Interface for handler modulesICommandHandler<TRequest, TResponse>— Command handler contractINotificationHandler<TNotification>— Notification handler contractIStreamHandler<TRequest, TItem>— Stream handler contract
Message Types
Commands (Request/Response)
Commands are one-to-one messages that return a response:
public record CreateUserCommand(string Name, string Email);
public record CreateUserResponse(int UserId);
var dispatcher = AppDispatcher.Create()
.Command<CreateUserCommand, CreateUserResponse>(async (cmd, ct) =>
{
var user = await userService.CreateAsync(cmd.Name, cmd.Email, ct);
return new CreateUserResponse(user.Id);
})
.Build();
var response = await dispatcher.Send<CreateUserCommand, CreateUserResponse>(
new CreateUserCommand("Alice", "alice@example.com"));
Notifications (Fan-Out)
Notifications are one-to-many messages with no response:
public record UserCreatedNotification(int UserId, string Name);
var dispatcher = AppDispatcher.Create()
.Notification<UserCreatedNotification>(async (n, ct) =>
{
await emailService.SendWelcomeAsync(n.UserId, ct);
})
.Notification<UserCreatedNotification>(async (n, ct) =>
{
await analyticsService.TrackUserCreatedAsync(n.UserId, ct);
})
.Build();
// All handlers are invoked
await dispatcher.Publish(new UserCreatedNotification(123, "Alice"));
Streams (Async Enumerable)
Streams return async sequences:
public record GetLogsQuery(DateTime Since);
public record LogEntry(DateTime Timestamp, string Message);
var dispatcher = AppDispatcher.Create()
.Stream<GetLogsQuery, LogEntry>(async (query, ct) =>
{
return logService.GetLogsAsync(query.Since, ct); // IAsyncEnumerable<LogEntry>
})
.Build();
await foreach (var log in dispatcher.Stream<GetLogsQuery, LogEntry>(
new GetLogsQuery(DateTime.UtcNow.AddHours(-1))))
{
Console.WriteLine($"{log.Timestamp}: {log.Message}");
}
Pipelines
Pipelines add cross-cutting concerns to command handling:
Pre Hooks
Execute before the handler:
.Pre<CreateUserCommand>(async (cmd, ct) =>
{
_logger.LogInformation("Creating user: {Name}", cmd.Name);
})
Post Hooks
Execute after the handler (with access to response):
.Post<CreateUserCommand, CreateUserResponse>(async (cmd, response, ct) =>
{
_logger.LogInformation("Created user {UserId}", response.UserId);
})
Around Hooks
Wrap the handler (middleware pattern):
.Around<CreateUserCommand, CreateUserResponse>(async (cmd, ct, next) =>
{
var sw = Stopwatch.StartNew();
try
{
return await next();
}
finally
{
_logger.LogInformation("Command took {Elapsed}ms", sw.ElapsedMilliseconds);
}
})
OnError Hooks
Execute when an exception occurs:
.OnError<CreateUserCommand, CreateUserResponse>(async (cmd, ex, ct) =>
{
_logger.LogError(ex, "Failed to create user: {Name}", cmd.Name);
})
Pipeline Ordering
Hooks are ordered by the order parameter:
.Pre<CreateUserCommand>(LoggingHook, order: 0) // First
.Pre<CreateUserCommand>(ValidationHook, order: 10) // Second
.Around<CreateUserCommand, CreateUserResponse>(TimingHook, order: 0)
Modules
Organize handlers into reusable modules:
public class UserModule : IModule
{
private readonly IUserService _userService;
public UserModule(IUserService userService) => _userService = userService;
public void Register(IDispatcherBuilder builder)
{
builder
.Command<CreateUserCommand, CreateUserResponse>(CreateUserAsync)
.Command<GetUserQuery, UserDto?>(GetUserAsync)
.Notification<UserCreatedNotification>(OnUserCreatedAsync);
}
private async ValueTask<CreateUserResponse> CreateUserAsync(
CreateUserCommand cmd, CancellationToken ct)
{
var user = await _userService.CreateAsync(cmd.Name, cmd.Email, ct);
return new CreateUserResponse(user.Id);
}
// ... other handlers
}
// Register module
var dispatcher = AppDispatcher.Create()
.AddModule(new UserModule(userService))
.AddModule(new OrderModule(orderService))
.Build();
Attributes
[GenerateDispatcher]
Assembly-level attribute for generating a dispatcher.
| Property | Type | Default | Description |
|---|---|---|---|
Namespace |
string? |
"Generated.Messaging" |
Namespace for generated types |
Name |
string? |
"AppDispatcher" |
Name of generated dispatcher class |
IncludeObjectOverloads |
bool |
false |
Generate object-based overloads (uses reflection) |
IncludeStreaming |
bool |
true |
Generate streaming support |
Visibility |
GeneratedVisibility |
Public |
Visibility of generated types |
Generated Interfaces
ICommandHandler<TRequest, TResponse>
public interface ICommandHandler<TRequest, TResponse>
{
ValueTask<TResponse> Handle(TRequest request, CancellationToken ct);
}
INotificationHandler
public interface INotificationHandler<TNotification>
{
ValueTask Handle(TNotification notification, CancellationToken ct);
}
IStreamHandler<TRequest, TItem>
public interface IStreamHandler<TRequest, TItem>
{
IAsyncEnumerable<TItem> Handle(TRequest request, CancellationToken ct);
}
Diagnostics
| ID | Severity | Description |
|---|---|---|
| PKD006 | Error | Invalid GenerateDispatcher configuration |
Best Practices
1. Use Records for Messages
// ✅ Immutable, value equality, concise
public record CreateUserCommand(string Name, string Email);
// ❌ Mutable class
public class CreateUserCommand { public string Name { get; set; } }
2. One Handler Per Command
Commands should have exactly one handler:
// ✅ Single handler
.Command<CreateUserCommand, CreateUserResponse>(HandleCreateUser)
// ❌ Multiple handlers for same command (throws)
.Command<CreateUserCommand, CreateUserResponse>(Handler1)
.Command<CreateUserCommand, CreateUserResponse>(Handler2) // Error!
3. Use Notifications for Events
When multiple components need to react:
// ✅ Multiple handlers OK for notifications
.Notification<OrderPlacedNotification>(SendConfirmationEmail)
.Notification<OrderPlacedNotification>(UpdateInventory)
.Notification<OrderPlacedNotification>(NotifyAnalytics)
4. Prefer Generic Send Over Object Overloads
// ✅ Type-safe, no reflection
await dispatcher.Send<CreateUserCommand, CreateUserResponse>(cmd);
// ⚠️ Uses reflection (only when IncludeObjectOverloads = true)
await dispatcher.Send(cmd);
5. Use Pipelines for Cross-Cutting Concerns
var dispatcher = AppDispatcher.Create()
// Global validation
.Pre<CreateUserCommand>(ValidateCommand)
// Global timing
.Around<CreateUserCommand, CreateUserResponse>(TimingMiddleware)
// Global error logging
.OnError<CreateUserCommand, CreateUserResponse>(LogError)
// Handler
.Command<CreateUserCommand, CreateUserResponse>(HandleCommand)
.Build();
Examples
CQRS Pattern
// Commands (write)
public record CreateOrderCommand(string CustomerId, List<OrderItem> Items);
public record CreateOrderResponse(string OrderId);
// Queries (read)
public record GetOrderQuery(string OrderId);
public record OrderDto(string OrderId, string Status, List<OrderItem> Items);
var dispatcher = AppDispatcher.Create()
// Commands
.Command<CreateOrderCommand, CreateOrderResponse>(async (cmd, ct) =>
{
var order = await orderService.CreateAsync(cmd, ct);
return new CreateOrderResponse(order.Id);
})
// Queries
.Command<GetOrderQuery, OrderDto?>(async (query, ct) =>
{
return await orderRepository.GetAsync(query.OrderId, ct);
})
.Build();
Event Sourcing Integration
public record OrderPlacedEvent(string OrderId, DateTime PlacedAt);
var dispatcher = AppDispatcher.Create()
.Command<PlaceOrderCommand, PlaceOrderResponse>(async (cmd, ct) =>
{
var order = await orderService.PlaceAsync(cmd, ct);
// Publish domain event
await dispatcher.Publish(new OrderPlacedEvent(order.Id, DateTime.UtcNow), ct);
return new PlaceOrderResponse(order.Id);
})
.Notification<OrderPlacedEvent>(async (e, ct) =>
{
await eventStore.AppendAsync(e, ct);
})
.Build();
Streaming Large Datasets
public record ExportUsersQuery(string Role);
public record UserExportRow(int Id, string Name, string Email);
var dispatcher = AppDispatcher.Create()
.Stream<ExportUsersQuery, UserExportRow>((query, ct) =>
{
return dbContext.Users
.Where(u => u.Role == query.Role)
.Select(u => new UserExportRow(u.Id, u.Name, u.Email))
.AsAsyncEnumerable();
})
.Build();
// Stream to CSV
await using var writer = new StreamWriter("export.csv");
await foreach (var row in dispatcher.Stream<ExportUsersQuery, UserExportRow>(
new ExportUsersQuery("Admin")))
{
await writer.WriteLineAsync($"{row.Id},{row.Name},{row.Email}");
}
Validation Pipeline
public class ValidationModule : IModule
{
public void Register(IDispatcherBuilder builder)
{
builder.Pre<CreateUserCommand>(async (cmd, ct) =>
{
if (string.IsNullOrWhiteSpace(cmd.Name))
throw new ValidationException("Name is required");
if (!IsValidEmail(cmd.Email))
throw new ValidationException("Invalid email format");
});
}
private static bool IsValidEmail(string email) => email.Contains('@');
}
var dispatcher = AppDispatcher.Create()
.AddModule(new ValidationModule())
.Command<CreateUserCommand, CreateUserResponse>(HandleCreateUser)
.Build();
Troubleshooting
PKD006: Invalid configuration
Cause: GenerateDispatcher attribute has invalid values.
Fix: Ensure valid namespace and name:
// ✅ Valid
[assembly: GenerateDispatcher(Namespace = "MyApp.Messaging", Name = "AppDispatcher")]
// ❌ Invalid (empty namespace)
[assembly: GenerateDispatcher(Namespace = "", Name = "")]
Handler already registered
Cause: Attempting to register multiple handlers for the same command type.
Fix: Use notifications for fan-out, or merge handlers:
// ❌ Throws at build time
.Command<MyCommand, MyResponse>(Handler1)
.Command<MyCommand, MyResponse>(Handler2)
// ✅ Use notification for multiple handlers
.Notification<MyEvent>(Handler1)
.Notification<MyEvent>(Handler2)
Missing handler
Cause: Sending a command with no registered handler.
Fix: Register a handler before building:
var dispatcher = AppDispatcher.Create()
.Command<MyCommand, MyResponse>(HandleMyCommand) // ✅ Register handler
.Build();
See Also
- Patterns: Mediator
- Patterns: Messaging
- Strategy Generator — For predicate-based dispatch