RA.Utilities.Dispatcher
10.1.2
Prefix Reserved
dotnet add package RA.Utilities.Dispatcher --version 10.1.2
NuGet\Install-Package RA.Utilities.Dispatcher -Version 10.1.2
<PackageReference Include="RA.Utilities.Dispatcher" Version="10.1.2" />
<PackageVersion Include="RA.Utilities.Dispatcher" Version="10.1.2" />
<PackageReference Include="RA.Utilities.Dispatcher" />
paket add RA.Utilities.Dispatcher --version 10.1.2
#r "nuget: RA.Utilities.Dispatcher, 10.1.2"
#:package RA.Utilities.Dispatcher@10.1.2
#addin nuget:?package=RA.Utilities.Dispatcher&version=10.1.2
#tool nuget:?package=RA.Utilities.Dispatcher&version=10.1.2
RA.Utilities.Dispatcher
RA.Utilities.Dispatcher provides a foundational toolkit for implementing the Vertical Slice Architecture pattern using CQRS (Command Query Responsibility Segregation).
It includes a custom dispatcher, base handlers, pipeline behaviors for cross-cutting concerns, and a notification system to streamline feature development and promote clean, maintainable code.
Building applications with a traditional layered architecture can lead to wide, coupled classes and scattered logic. The Vertical Slice pattern, combined with CQRS, addresses this by organizing code around features. This package provides the essential building blocks to support that pattern.
Result-based dispatch redesign: Handlers and
IDispatchernow returnValueTask<Result<T>>/ValueTask<Result>usingRA.Utilities.Core.Results. Expected failures are returned asResult.Failureinstead of being thrown —ValidationBehaviorreturns a failed result containing aBadRequestException. Handlers are still registered automatically by a source generator shipped in this package — plain handlers need no explicit registration at all.
Getting started
dotnet add package RA.Utilities.Dispatcher
🔗 Dependencies
RA.Utilities.Core.ResultsRA.Utilities.Core.ExceptionsRA.Utilities.Application.ValidationFluentValidationMicrosoft.Extensions.DependencyInjection.AbstractionsMicrosoft.Extensions.Logging.Abstractions
✨ Features
1. Source-Generated Dispatcher
The package provides its own lightweight dispatcher — no external MediatR dependency. The IDispatcher implementation is source-generated into your assembly by the package's generator and registered by AddDispatcher(). It supports:
- Request/Response dispatch with a composable pipeline of behaviors
- Notification publishing to zero or more handlers, each wrapped in its own behavior pipeline
- Behavior ordering: behaviors execute in registration order, outermost first; auto-registered behaviors can pin their position with
[PipelineBehaviorOrder] - Bulk behavior registration:
AddDispatcher(options => options.Behaviors.Add(...))registers open generic and closed behaviors without per-request builder calls - Bulk validator registration:
AddDispatcher(options => options.Validators.Add(typeof(TValidator), ServiceLifetime.Transient))registers the validator and itsValidationBehaviortogether
Send returns ValueTask<Result<TResponse>> (ValueTask<Result> for void requests). Handlers, behaviors, and the generated dispatcher all speak Result; expected failures are returned as Result.Failure(exception) instead of being thrown. Result comes from RA.Utilities.Core.Results.
Result<Pong> result = await dispatcher.Send(new Ping("hello"));
if (result.IsFailure)
{
Exception error = result.Exception;
}
else
{
Pong pong = result.Value;
}
Register the dispatcher once at startup:
builder.Services.AddDispatcher();
2. Zero-Configuration Handler Registration
As of v11, plain handlers register themselves. A source generator shipped inside the RA.Utilities.Dispatcher NuGet package discovers every non-abstract class implementing IRequestHandler<TRequest, TResponse>, IRequestHandler<TRequest>, or INotificationHandler<TNotification> and emits a module initializer that queues their DI registrations. Calling AddDispatcher() applies the queued registrations:
- Request handlers are registered scoped (
IRequestHandler<TRequest, TResponse>/IRequestHandler<TRequest>) - Notification handlers are registered transient (
INotificationHandler<TNotification>)
// No per-handler AddDispatcher<TRequest, THandler>/AddNotification calls needed —
// AddDispatcher() registers every handler in the assembly.
builder.Services.AddDispatcher();
Still registered explicitly (the generator cannot discover or must not auto-register these):
- Generic pipeline behaviors — closed, non-generic behaviors auto-register; generic ones register with
options.Behaviors.Add(typeof(LoggingBehavior<,>))(see Registering behaviors in bulk) or per request withAddDispatcher<TRequest, TResponse, THandler>().AddDecoration<TBehavior>() - Validators —
.AddValidator<TValidator>()(which also wires theValidationBehavior), oroptions.Validators.Add(typeof(TValidator), lifetime)to register the validator and its behavior in bulk - Generic notification behaviors — closed ones auto-register; generic ones register with
options.Behaviors.Add(typeof(NotificationLoggingBehavior<>))or per notification with.AddNotification<TNotification>().AddDecoration<TBehavior>() - Generic handlers — the generator reports
DSAG002and skips them - Any handler whose registration needs a custom lifetime or setup
Assembly-loading caveat: auto-registration covers the assembly that calls AddDispatcher() plus any assembly already loaded before that call (module initializers run when an assembly loads). Referenced class libraries load lazily on the CLR, so a handler in a library whose types are first touched at request time may be registered too late. Keep handlers in the entry/composition assembly, touch the handler assembly at startup (e.g. typeof(SomeHandler)), or register those handlers explicitly.
Filtering auto-registered handlers
AddDispatcher accepts an options callback that filters which discovered handlers — and the pipeline/notification behaviors related to the messages they handle — are registered:
builder.Services.AddDispatcher(options =>
{
// If null, ignored. If set, only these handlers are registered.
options.IncludeHandlers = [typeof(CreateOrderHandler)];
// If null, ignored. If set, these handlers are not registered.
options.ExcludeHandlers = [typeof(LegacyImportHandler)];
});
- Both lists take handler types; behavior types are also accepted (a listed behavior is kept or removed directly).
- Exclusions win over inclusions.
- A request behavior is removed together with the handler of its request; a notification behavior is removed only once no registered handler for its notification remains.
- The filter applies to source-generated registrations only — handlers registered explicitly with
AddDispatcher<TRequest, THandler>()/AddNotification<TNotification>(), behaviors added viaAddDecoration<TBehavior>(), and behaviors listed inoptions.Behaviorsor registered throughoptions.Validatorsare untouched.
Registering behaviors in bulk
DispatcherOptions.Behaviors registers pipeline behaviors explicitly, including open generic types that the generator cannot discover:
builder.Services.AddDispatcher(options =>
{
// Chain order = list order; both run around every matching request.
options.Behaviors.Add(typeof(MetricsBehavior<,>)); // outermost
options.Behaviors.Add(typeof(LoggingBehavior<,>)); // inside metrics
// Closed behavior for a single request.
options.Behaviors.Add(typeof(CachingBehavior<GetOrderQuery, OrderDto>));
// Optional; Transient is the default.
options.BehaviorLifetime = ServiceLifetime.Transient;
});
- Accepts
IPipelineBehavior<>,IPipelineBehavior<,>, andINotificationBehavior<>, closed or open generic. Open generics must pass their own type parameters in order. - Configured behaviors are registered before the generated block, so they wrap auto-registered closed behaviors;
[PipelineBehaviorOrder]is not read for them — the list order decides. - Registrations use
TryAddEnumerable: repeated calls and duplicate entries do not stack, and a closed behavior that is also auto-discovered runs once at its configured (outer) position. - Invalid types (non-class, abstract, or not a behavior) throw
ArgumentExceptionat startup.
Registering validators in bulk
DispatcherOptions.Validators maps each validator type to its ServiceLifetime. One entry registers the validator and its ValidationBehavior — the request type comes from IValidator<TRequest>, and the response type from IRequest<TResponse> when the request declares one:
builder.Services.AddDispatcher(options =>
{
options.Validators.Add(typeof(CreateOrderCommandValidator), ServiceLifetime.Transient);
});
That single entry is equivalent to:
builder.Services.AddTransient<IValidator<CreateOrderCommand>, CreateOrderCommandValidator>();
builder.Services.AddTransient<IPipelineBehavior<CreateOrderCommand, CreateOrderResult>, ValidationBehavior<CreateOrderCommand, CreateOrderResult>>();
- Validators must be non-abstract, closed classes implementing
IValidator<TRequest>; the request must implementIRequestorIRequest<TResponse>. - Several validators for one request are supported — they share the single validation behavior for that request.
- Registrations use
TryAddEnumerable: manual registrations, listing the sameValidationBehaviorinoptions.Behaviors, or callingAddDispatchertwice does not duplicate anything. - Validation behaviors are registered after
options.Behaviors, so they run inside explicitly configured behaviors. ListValidationBehaviorinoptions.Behaviorsto control its position.
Ordering auto-registered behaviors
Closed pipeline and notification behaviors are auto-registered in type-name order. Decorate a behavior with [PipelineBehaviorOrder] to choose its position instead:
using RA.Utilities.Dispatcher.Attributes;
[PipelineBehaviorOrder(-100)] // outermost: runs first
public sealed class MetricsBehavior : IPipelineBehavior<CreateOrderCommand, CreateOrderResult>
{ /* ... */ }
[PipelineBehaviorOrder(100)] // innermost: runs closest to the handler
public sealed class CachingBehavior : IPipelineBehavior<CreateOrderCommand, CreateOrderResult>
{ /* ... */ }
- Lower values run closer to the outside of the chain; behaviors with the same order fall back to type-name order, and behaviors without the attribute default to
0. - The attribute is read at compile time by the source generator (no runtime cost) and applies to auto-registered
IPipelineBehavior<>,IPipelineBehavior<,>, andINotificationBehavior<>implementations. - Explicit registrations keep
AddDecorationcall order: decorations added beforeAddDispatcher()wrap the auto-registered behaviors (outermost), decorations added after run inside them (innermost).
3. Generated Dispatcher Details
The generated Dispatcher is the only IDispatcher implementation in the package:
- Direct handler injection — the generator knows each message's handler, registers it under its interface and as itself, and the generated
Sendresolves the handler by concrete type and calls it directly — no interface resolution, and the call devirtualizes. - No delegate chain without behaviors — when a request has no pipeline behaviors, the handler is invoked directly. Benchmarks (MediatR handlers registered scoped, matching this package's lifetime) show 14-18% faster sends and 9-14% fewer allocations than MediatR across the send shapes.
- Closed behaviors auto-register — non-generic
IPipelineBehavior<,>/IPipelineBehavior<>/INotificationBehavior<>implementations are auto-registered under their interfaces (transient). Explicitly registered behaviors (DispatcherOptions.Behaviors,DispatcherOptions.Validators, orAddDecoration, including generic closures —DSAG010notes the generic ones aren't auto-registered) resolve through the same channel. - Fast dictionary lookups for object dispatch —
Send(object)/Publish(object)usetypeofchains in small projects and static dictionaries once the project defines more than 8 messages; unknown types throwHandlerNotFoundException. - Both
IDispatcherand the concreteDispatcherare usable — the concrete class is the fastest path. - Compile-time message diagnostics (
DSAG006–DSAG010) — ambiguous message contracts, missing handlers, unsupported message shapes, and generic behaviors are reported at build time.
// Inject the concrete generated dispatcher for monomorphized dispatch.
var dispatcher = provider.GetRequiredService<RA.Utilities.Dispatcher.Generated.Dispatcher>();
Result<Pong> pong = await dispatcher.Send(new Ping("hello"));
Result<object?> pongObject = await dispatcher.Send((object)new Ping("hello"));
See the generated dispatcher documentation for the dispatch matrix and diagnostics.
4. Base Handlers
Abstract base classes that implement the IRequestHandler interfaces. Inherit from these to focus on business logic without boilerplate.
| Base Class | Interface Implemented | Use Case |
|---|---|---|
RequestHandler<TRequest> |
IRequestHandler<TRequest> |
Commands with no return value |
RequestHandler<TRequest, TResponse> |
IRequestHandler<TRequest, TResponse> |
Commands/queries that return data |
Each base class exposes a public abstract HandleAsync method — ValueTask<Result> HandleAsync(TRequest, CancellationToken) or ValueTask<Result<TResponse>> HandleAsync(TRequest, CancellationToken) — plus a protected virtual context-aware overload that delegates to it. Return Result.Success(...) / Result.Failure(exception) instead of throwing for expected failures.
Namespace: RA.Utilities.Dispatcher.Handlers
5. Pipeline Behaviors
Pipeline behaviors wrap request handlers to add cross-cutting concerns. They implement IPipelineBehavior<TRequest> or IPipelineBehavior<TRequest, TResponse> and are composed into a chain via the dispatcher.
Built-in request pipeline behaviors:
| Behavior | Description |
|---|---|
LoggingBehavior<TRequest> / LoggingBehavior<TRequest, TResponse> |
Logs each request and its response at Information level |
ValidationBehavior<TRequest> / ValidationBehavior<TRequest, TResponse> |
Executes FluentValidation validators; returns a failed Result containing a BadRequestException on validation failure |
Register per-feature via the fluent builder:
builder.Services
.AddDispatcher<MyCommand, int, MyCommandHandler>()
.AddDecoration<LoggingBehavior<MyCommand, int>>()
.AddValidator<MyCommandValidator>();
With the generated dispatcher, register validators in bulk instead — each entry wires the validator and its ValidationBehavior:
builder.Services.AddDispatcher(options =>
{
options.Validators.Add(typeof(MyCommandValidator), ServiceLifetime.Transient);
options.Validators.Add(typeof(MyQueryValidator), ServiceLifetime.Transient);
});
6. Notification System
Publish notifications to zero or more handlers. Each handler is wrapped in its own notification behavior pipeline and returns a ValueTask<Result>; Publish runs every handler and aggregates failures into the returned Result (a single exception when one handler fails, an AggregateException when several fail).
| Abstraction | Role |
|---|---|
INotification |
Marker interface for notification types |
INotificationHandler<TNotification> |
Handles a notification |
INotificationBehavior<TNotification> |
Cross-cutting concerns for notification handlers |
Built-in notification behaviors:
| Behavior | Description |
|---|---|
NotificationLoggingBehavior<TNotification> |
Logs each notification at start and finish |
NotificationMetricsBehavior<TNotification> |
Measures handler duration; warns if over 500 ms |
NotificationRetryBehavior<TNotification> |
Retries failed handlers up to N times with configurable backoff |
Notification handlers are discovered by the source generator automatically. Add notification behaviors via the fluent builder:
builder.Services
.AddNotification<OrderPlaced>()
.AddHandler<SendConfirmationEmail>()
.AddHandler<UpdateInventory>()
.AddDecoration<NotificationRetryBehavior<OrderPlaced>>()
.AddDecoration<NotificationLoggingBehavior<OrderPlaced>>();
7. Fluent Validation Integration
The ValidationBehavior automatically discovers and executes all registered IValidator<TRequest> implementations. If validation fails, the behavior returns Result.Failure(...) containing a BadRequestException built from the collected ValidationFailure entries (via ValidationUtilities.CreateValidationErrorResult) — invalid data never reaches your handler, and the caller can map the failed result to a 400 Bad Request response.
8. Pipeline Context
The PipelineContext<T> provides a strongly-typed data carrier that flows through the entire pipeline. Each Send or Publish call gets its own isolated instance. Behaviors and handlers read and write properties on the user-defined T — no dictionaries, no magic strings, no boxing.
// Define your context type
public class MyPipelineContext
{
public string? CorrelationId { get; set; }
public int? UserId { get; set; }
public Stopwatch? Timer { get; set; }
}
// A context-aware behavior that stamps a correlation ID
public class CorrelationIdBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public ValueTask<Result<TResponse>> HandleAsync(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
=> HandleAsync(request, _ => next(), new PipelineContext<MyPipelineContext>(), ct);
public async ValueTask<Result<TResponse>> HandleAsync<TContext>(
TRequest request, RequestHandlerContextDelegate<TResponse, TContext> next,
PipelineContext<TContext> context, CancellationToken ct)
where TContext : class, new()
{
if (context is PipelineContext<MyPipelineContext> ctx)
ctx.Data.CorrelationId = Guid.NewGuid().ToString();
return await next(context);
}
}
// A handler that reads the correlation ID
public class MyHandler : RequestHandler<MyCommand, Data>
{
protected override async ValueTask<Result<Data>> HandleAsync<TContext>(
MyCommand request, PipelineContext<TContext> context, CancellationToken ct)
where TContext : class, new()
{
if (context is PipelineContext<MyPipelineContext> ctx)
Console.WriteLine($"CorrelationId: {ctx.Data.CorrelationId}");
return await base.HandleAsync(request, context, ct);
}
}
// Caller provides (or omits) context
var ctx = new PipelineContext<MyPipelineContext>();
ctx.Data.UserId = 42;
Result<Data> result = await dispatcher.Send<MyCommand, Data, MyPipelineContext>(command, ctx);
The IDispatcher interface has overloads that accept a context type parameter:
Send<TRequest, TResponse, TContext>(request, context?, ct)— request/response with typed contextSend<TRequest, TContext>(request, context?, ct)— request without response, with typed contextPublish<TNotification, TContext>(notification, context?, ct)— notification with typed context
Existing Send<TRequest, TResponse>(...) and Publish<TNotification>(...) calls continue to work unchanged — the context type parameter is entirely opt-in.
🚀 Usage Example — Request/Response
Let's walk through creating a complete feature slice for creating a new product.
Step 1: Define the Command and Validator
// Features/Products/CreateProduct.cs
using FluentValidation;
using RA.Utilities.Dispatcher.Abstractions;
// The command containing the data for the new product.
// TResponse is the payload; the dispatcher wraps it in Result<TResponse>.
public record CreateProductCommand(string Name, decimal Price) : IRequest<int>;
// The validator for the command
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThan(0);
}
}
Step 2: Implement the Handler
Inherit from RequestHandler<TRequest, TResponse> and return a ValueTask<Result<TResponse>>. Return Result.Failure with a typed exception for expected failures; exceptions remain available for truly exceptional cases.
// Features/Products/CreateProduct.cs (continued)
using RA.Utilities.Core.Exceptions;
using RA.Utilities.Core.Results;
using RA.Utilities.Dispatcher.Handlers;
public class CreateProductHandler : RequestHandler<CreateProductCommand, int>
{
private readonly IProductRepository _productRepository;
public CreateProductHandler(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public override async ValueTask<Result<int>> HandleAsync(CreateProductCommand command, CancellationToken cancellationToken)
{
if (await _productRepository.DoesProductExistAsync(command.Name))
{
return Result.Failure<int>(new ConflictException(nameof(Product), command.Name));
}
var newProduct = new Product { Name = command.Name, Price = command.Price };
int productId = await _productRepository.AddAsync(newProduct);
return Result.Success(productId);
}
}
Step 3: Register Services in Program.cs
The handler above is discovered by the source generator, so no registration call is needed for it. Only the validator (which also wires up the ValidationBehavior) still requires explicit registration:
// Program.cs
using RA.Utilities.Dispatcher.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDispatcher(); // registers IDispatcher + all auto-discovered handlers
builder.Services
.AddDispatcher<CreateProductCommand, int, CreateProductHandler>()
.AddValidator<CreateProductCommandValidator>()
.AddDecoration<LoggingBehavior<CreateProductCommand, int>>();
var app = builder.Build();
app.MapEndpoints(app.Services);
app.Run();
AddDispatcher re-registers the handler (harmless — same scoped registration), so you only need it when the feature also has behaviors or validators to add.
📬 Usage Example — Notifications
Publish a notification from a request handler, and let multiple handlers process it independently.
Step 1: Define the Notification
// Features/Orders/OrderPlaced.cs
using RA.Utilities.Dispatcher.Abstractions;
public record OrderPlaced(int OrderId, string CustomerEmail) : INotification;
Step 2: Implement Handlers
// Features/Orders/SendConfirmationEmail.cs
using Microsoft.Extensions.Logging;
using RA.Utilities.Core.Results;
using RA.Utilities.Dispatcher.Abstractions;
public class SendConfirmationEmail : INotificationHandler<OrderPlaced>
{
private readonly IEmailService _emailService;
private readonly ILogger<SendConfirmationEmail> _logger;
public SendConfirmationEmail(IEmailService emailService, ILogger<SendConfirmationEmail> logger)
{
_emailService = emailService;
_logger = logger;
}
public async ValueTask<Result> HandleAsync(OrderPlaced notification, CancellationToken cancellationToken)
{
await _emailService.SendAsync(notification.CustomerEmail, "Order Confirmed", /* ... */);
_logger.LogInformation("Confirmation email sent for order {OrderId}", notification.OrderId);
return Result.Success();
}
}
// Features/Orders/UpdateInventory.cs
public class UpdateInventory : INotificationHandler<OrderPlaced>
{
public async ValueTask<Result> HandleAsync(OrderPlaced notification, CancellationToken cancellationToken)
{
// reduce stock levels...
return Result.Success();
}
}
Step 3: Publish
Notification handlers are auto-registered by the generator, so nothing needs to be added in Program.cs for plain handlers. Add notification behaviors explicitly when you need them:
// Program.cs — notification behaviors only (handlers are auto-registered)
builder.Services
.AddNotification<OrderPlaced>()
.AddDecoration<NotificationRetryBehavior<OrderPlaced>>()
.AddDecoration<NotificationLoggingBehavior<OrderPlaced>>();
// Inside a request handler or endpoint — publishing
Result publishResult = await dispatcher.Publish(new OrderPlaced(orderId, customerEmail), cancellationToken);
🧩 Bringing It All Together
A complete Program.cs might look like:
var builder = WebApplication.CreateBuilder(args);
// 1. Register the dispatcher — also applies all compile-time handler registrations
builder.Services.AddDispatcher();
// 2. Wire validators and behaviors per feature (handlers themselves need no registration)
builder.Services
.AddDispatcher<CreateProductCommand, int, CreateProductHandler>()
.AddValidator<CreateProductCommandValidator>()
.AddDecoration<LoggingBehavior<CreateProductCommand, int>>();
builder.Services
.AddDispatcher<DeleteProductCommand, DeleteProductHandler>()
.AddValidator<DeleteProductCommandValidator>();
// 3. Notification behaviors (handlers are auto-registered)
builder.Services
.AddNotification<OrderPlaced>()
.AddDecoration<NotificationRetryBehavior<OrderPlaced>>()
.AddDecoration<NotificationLoggingBehavior<OrderPlaced>>();
var app = builder.Build();
app.MapEndpoints(app.Services);
app.Run();
Contributing
Contributions are welcome! If you have a suggestion or find a bug, please open an issue to discuss it.
Pull Request Process
- Fork the Repository: Start by forking the RA.Utilities repository.
- Create a Branch: Create a new branch for your feature or bug fix from the
mainbranch. - Make Your Changes: Write your code, ensuring it adheres to the existing coding style. Add or update XML documentation for any new public APIs.
- Update README: If you are adding new functionality, please update the
README.mdfile accordingly. - Submit a Pull Request: Push your branch to your fork and open a pull request to the
mainbranch of the original repository. Provide a clear description of the changes you have made.
Coding Standards
- Follow the existing coding style and conventions used in the project.
- Ensure all public members are documented with clear XML comments.
- Keep changes focused. A pull request should address a single feature or bug.
Thank you for contributing!
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- RA.Utilities.Application.Validation (>= 10.2.0)
- RA.Utilities.Core.Results (>= 10.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.