custom dispatcher
custom dispatcher
CustomDispatcher
A small CQRS-first command/query dispatcher for .NET with dispatch middleware support.
What is CustomDispatcher?
CustomDispatcher is a lightweight, explicit CQRS-style dispatcher library for .NET applications. It separates commands and queries, supports dispatch middleware for cross-cutting concerns, and integrates seamlessly with `Microsoft.Extensions.DependencyInjection`.
Installation
dotnet add package CustomDispatcherQuick Start
1. Register CustomDispatcher
builder.Services.AddCustomDispatcher(options =>
{
options.RegisterServicesFromAssembly(typeof(Program).Assembly);
});2. Define Commands and Queries
// Command with result
public record CreateUserCommand(string Name, string Email) : ICommand<UserId>;
public record UserId(Guid Value);
// Command without result
public record DeleteUserCommand(Guid Id) : ICommand;
// Query
public record GetUserByIdQuery(Guid Id) : IQuery<UserDto>;
public record UserDto(Guid Id, string Name, string Email);3. Implement Handlers
public class CreateUserCommandHandler : ICommandProcessor<CreateUserCommand, UserId>
{
public Task<UserId> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken = default)
{
// Create user and return ID
return Task.FromResult(new UserId(Guid.NewGuid()));
}
}
public class DeleteUserCommandHandler : ICommandProcessor<DeleteUserCommand>
{
public Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken = default)
{
// Delete user
return Task.CompletedTask;
}
}
public class GetUserByIdQueryHandler : IQueryProcessor<GetUserByIdQuery, UserDto>
{
public Task<UserDto> HandleAsync(GetUserByIdQuery query, CancellationToken cancellationToken = default)
{
// Fetch and return user
return Task.FromResult(new UserDto(query.Id, "John", "john@example.com"));
}
}4. Dispatch from Your Endpoints
app.MapPost("/users", async (CreateUserCommand command, ICommandDispatcher dispatcher) =>
{
var userId = await dispatcher.DispatchAsync<CreateUserCommand, UserId>(command);
return Results.Created($"/users/{userId.Value}", userId);
});
app.MapDelete("/users/{id}", async (Guid id, ICommandDispatcher dispatcher) =>
{
await dispatcher.DispatchAsync(new DeleteUserCommand(id));
return Results.NoContent();
});
app.MapGet("/users/{id}", async (Guid id, IQueryDispatcher dispatcher) =>
{
var user = await dispatcher.DispatchAsync<GetUserByIdQuery, UserDto>(new GetUserByIdQuery(id));
return Results.Ok(user);
});Dispatch Middleware
Pipeline middleware allow cross-cutting concerns around handlers.
1. Create Middleware
public class ValidationMiddleware<TRequest, TResult> : IDispatchMiddleware<TRequest, TResult>
{
public async Task<TResult> HandleAsync(
TRequest request,
DispatchContinuation<TResult> next,
CancellationToken cancellationToken = default)
{
// Pre-processing (e.g., validation)
Console.WriteLine($"Before handling: {request}");
var result = await next();
// Post-processing
Console.WriteLine($"After handling: {result}");
return result;
}
}2. Register Middleware
builder.Services.AddCustomDispatcher(options =>
{
options.RegisterServicesFromAssembly(typeof(Program).Assembly);
options.AddDispatchMiddleware(typeof(ValidationMiddleware<,>));
options.AddDispatchMiddleware(typeof(LoggingMiddleware<,>));
});Middleware execute in registration order:
ValidationMiddleware -> LoggingMiddleware -> HandlerFirst registered is outermost, last registered is closest to the handler.
FluentValidation Integration
An optional package provides seamless FluentValidation integration.
1. Install the Package
dotnet add package CustomDispatcher.FluentValidation2. Create Validators
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required.");
RuleFor(x => x.Email).NotEmpty().WithMessage("Email is required.");
RuleFor(x => x.Email).EmailAddress().WithMessage("Email is not valid.");
}
}3. Register Validation
builder.Services.AddCustomDispatcher(options =>
{
options.RegisterServicesFromAssembly(typeof(Program).Assembly);
options.AddDispatchMiddleware(typeof(ValidationMiddleware<,>));
});
builder.Services.AddCustomDispatcherValidation(options =>
{
options.RegisterValidatorsFromAssembly(typeof(Program).Assembly);
});Validation runs automatically before the handler. If validation fails, a `ValidationException` is thrown with all failures.
Dependency Injection Registration
Basic Registration
services.AddCustomDispatcher();With Assembly Scanning
services.AddCustomDispatcher(options =>
{
options.RegisterServicesFromAssembly(typeof(Program).Assembly);
});Multiple Assemblies
services.AddCustomDispatcher(options =>
{
options.RegisterServicesFromAssembly(
typeof(ApplicationAssembly).Assembly,
typeof(DomainAssembly).Assembly);
});With Dispatch Middleware
services.AddCustomDispatcher(options =>
{
options.RegisterServicesFromAssembly(typeof(Program).Assembly);
options.AddDispatchMiddleware(typeof(ValidationMiddleware<,>));
options.AddDispatchMiddleware(typeof(LoggingMiddleware<,>));
options.AddDispatchMiddleware(typeof(TransactionMiddleware<,>));
});Error Handling
The library throws clear, actionable exceptions for infrastructure and configuration errors:
| Exception | When |
|-----------|------|
| `DispatchTargetNotFoundException` | No handler registered for a command or query |
| `MultipleDispatchTargetsFoundException` | Multiple handlers registered for the same command or query |
| `InvalidMiddlewareRegistrationException` | Invalid dispatch middleware registration |
| `CustomDispatcherException` | Base exception type for all CustomDispatcher errors |
All exceptions bubble up from handlers unless a user-defined middleware handles them.
Public API
Commands
public interface ICommand { }
public interface ICommand<TResult> { }Queries
public interface IQuery<TResult> { }Handlers
public interface ICommandProcessor<in TCommand>
where TCommand : ICommand
{
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
public interface ICommandProcessor<in TCommand, TResult>
where TCommand : ICommand<TResult>
{
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
public interface IQueryProcessor<in TQuery, TResult>
where TQuery : IQuery<TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}Dispatchers
public interface ICommandDispatcher
{
Task DispatchAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
where TCommand : ICommand;
Task<TResult> DispatchAsync<TCommand, TResult>(TCommand command, CancellationToken cancellationToken = default)
where TCommand : ICommand<TResult>;
}
public interface IQueryDispatcher
{
Task<TResult> DispatchAsync<TQuery, TResult>(TQuery query, CancellationToken cancellationToken = default)
where TQuery : IQuery<TResult>;
}Pipeline
public delegate Task<TResult> DispatchContinuation<TResult>();
public interface IDispatchMiddleware<in TRequest, TResult>
{
Task<TResult> HandleAsync(
TRequest request,
DispatchContinuation<TResult> next,
CancellationToken cancellationToken = default);
}Non-Goals (V1)
These are intentionally not included in V1:
- Notifications / domain events
- Event sourcing
- Outbox pattern
- Built-in Result/Error types
- Built-in validation
- Built-in logging
- Built-in transaction management
- Source generators
- AOT-specific support
- OpenTelemetry integration
- Retry policies
- Background job dispatching
- Message bus integration
Versioning Policy
CustomDispatcher follows semantic versioning. Before 1.0, the API may change but will be documented. After 1.0, breaking changes require a major version bump.
License
MIT License. See LICENSE for details.