AutoDispatch.Generator
1.21.0
dotnet add package AutoDispatch.Generator --version 1.21.0
NuGet\Install-Package AutoDispatch.Generator -Version 1.21.0
<PackageReference Include="AutoDispatch.Generator" Version="1.21.0" />
<PackageVersion Include="AutoDispatch.Generator" Version="1.21.0" />
<PackageReference Include="AutoDispatch.Generator" />
paket add AutoDispatch.Generator --version 1.21.0
#r "nuget: AutoDispatch.Generator, 1.21.0"
#:package AutoDispatch.Generator@1.21.0
#addin nuget:?package=AutoDispatch.Generator&version=1.21.0
#tool nuget:?package=AutoDispatch.Generator&version=1.21.0
AutoDispatch.Generator
AutoDispatch gives you the MediatR-style handler pattern without IRequest<T>, IRequestHandler<,>, reflection, or runtime dispatch overhead. Mark a handler with [Handler], write Handle or HandleAsync, and the generator emits a strongly-typed dispatcher at build time.
Why AutoDispatch?
- Same mental model as MediatR — command/query + handler + dispatcher
- Zero reflection — direct generated calls, no runtime dispatch overhead
- Pipeline behaviors —
[Behavior(Order = N)]wraps all async handlers at compile time, and[StreamBehavior(Order = N)]wraps streaming queries the same way; noIPipelineBehavior<,>magic at runtime - Configurable notification fan-out —
PublishAsyncruns handlers sequentially by default, or mark a notification[ParallelPublish]forTask.WhenAllconcurrency - Exception handling middleware —
[ExceptionHandler]open generics intercept a typed exception thrown by a handler or pipeline behavior and can supply a fallback response, matching MediatR'sIRequestExceptionHandler<,,> - Exception actions —
[ExceptionAction]open generics always run as side-effect-only observers on a typed exception (logging, metrics, alerting) without suppressing it, matching MediatR'sIRequestExceptionAction<,> - Request pre/post-processors —
[PreProcessor]/[PostProcessor]open generics run unconditionally right before/after a handler executes, without writing a fullnext()-calling pipeline behavior, matching MediatR'sIRequestPreProcessor<>/IRequestPostProcessor<,> - Constrained (scoped) behaviors — add a generic constraint (e.g.
where TCommand : IAudited) to any[Behavior]/[PreProcessor]/[PostProcessor]/[StreamBehavior]to apply it only to matching commands, instead of every command in the compilation - Built-in OpenTelemetry-compatible tracing — opt in with
AddAutoDispatch(o => o.EnableTracing = true)to wrap everySendAsync/PublishAsync/StreamAsynccall in anActivity, with zero overhead when no listener is subscribed - Notification pipeline behaviors —
[NotificationBehavior(Order = N)]wraps the entirePublishAsyncfan-out for a notification type, something MediatR has no equivalent for - Automatic MediatR migration hints — if MediatR is still referenced, AutoDispatch reports an
AD100/AD101suggestion with a one-click fix that converts a handler to[Handler]/[NotificationHandler]for you - Automatic FluentValidation integration — reference FluentValidation and validators are auto-registered and auto-invoked before every async handler runs, no attribute or manual DI wiring needed
- Pipeline visualization — every generated pipeline is also rendered as a Mermaid flowchart, available at compile time via
AutoDispatchPipelineDiagrams, for pasting into docs/ADRs - Minimal API endpoint generation —
[Endpoint("POST", "/orders")]on a command/query type generates aMapAutoDispatchEndpoints()extension method that wires it directly to an ASP.NET Core minimal API route, no hand-written lambda needed, complete with generated OpenAPI metadata (.WithName/.WithSummary/.WithTags/.Produces<T>) - Result pattern with automatic exception capture — return
Task<Result>/Task<Result<T>>from a handler and any unhandled exception (including FluentValidation's) is automatically converted into a failedResult, no manualtry/catchrequired — something MediatR has no equivalent for at all - No marker interfaces — commands stay as plain POCOs
- AOT-friendly — everything is compile-time generated; see the Native AOT sample for a project that publishes with
PublishAot=trueand zero trim/AOT analyzer warnings - DI-ready —
AddAutoDispatch()wires up handlers, behaviors, andIDispatcher
Installation
dotnet add package AutoDispatch.Generator
Then register the generated dispatcher:
builder.Services.AddAutoDispatch();
Before vs After
MediatR-style boilerplate
using MediatR;
public sealed record CreateOrderCommand(string CustomerId) : IRequest<OrderId>;
public sealed class CreateOrderHandler : IRequestHandler<CreateOrderCommand, OrderId>
{
public Task<OrderId> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
// ...
}
}
AutoDispatch
using AutoDispatch;
public sealed record CreateOrderCommand(string CustomerId);
[Handler]
public sealed class CreateOrderHandler
{
public Task<OrderId> HandleAsync(CreateOrderCommand command, CancellationToken ct = default)
{
// ...
}
}
What gets generated
Given one or more [Handler] classes, AutoDispatch emits:
AutoDispatch.HandlerAttributeAutoDispatch.IDispatcherAutoDispatch.DispatcherAddAutoDispatch()forIServiceCollection
Example generated dispatcher:
#nullable enable
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace AutoDispatch
{
public interface IDispatcher
{
Task<OrderId> SendAsync(CreateOrderCommand command, CancellationToken ct = default);
void Send(DeleteOrderCommand command);
}
internal sealed class Dispatcher : IDispatcher
{
private readonly IServiceProvider _sp;
public Dispatcher(IServiceProvider sp) => _sp = sp;
public Task<OrderId> SendAsync(CreateOrderCommand command, CancellationToken ct = default)
=> _sp.GetRequiredService<CreateOrderHandler>().HandleAsync(command, ct);
public void Send(DeleteOrderCommand command)
=> _sp.GetRequiredService<DeleteOrderHandler>().Handle(command);
}
}
Conventions
AutoDispatch discovers public instance non-static methods on classes marked with [Handler].
Supported signatures:
| Handler method | Generated dispatcher method |
|---|---|
T Handle(TCommand cmd) |
T Send(TCommand command) |
void Handle(TCommand cmd) |
void Send(TCommand command) |
Task HandleAsync(TCommand cmd, CancellationToken ct = default) |
Task SendAsync(TCommand command, CancellationToken ct = default) |
Task<T> HandleAsync(TCommand cmd, CancellationToken ct = default) |
Task<T> SendAsync(TCommand command, CancellationToken ct = default) |
Task HandleAsync(TCommand cmd) |
Task SendAsync(TCommand command, CancellationToken ct = default) |
Task<T> HandleAsync(TCommand cmd) |
Task<T> SendAsync(TCommand command, CancellationToken ct = default) |
Rules:
- Only methods named exactly
HandleorHandleAsync Handlemust have exactly one command parameterHandleAsyncmay have one command parameter, or a secondCancellationToken- Methods with zero parameters or more than two parameters are ignored
Dispatcheris generated asinternal sealedAddAutoDispatch()registers handlers withAddScoped
Semantic aliases
[CommandHandler] and [QueryHandler] are aliases for [Handler] — use whichever reads best in your codebase.
[CommandHandler]
public sealed class CreateOrderHandler
{
public Task<OrderId> HandleAsync(CreateOrderCommand command, CancellationToken ct = default)
=> Task.FromResult(new OrderId(Guid.NewGuid()));
}
[QueryHandler]
public sealed class GetOrderHandler
{
public Task<Order?> HandleAsync(GetOrderQuery query, CancellationToken ct = default)
=> Task.FromResult<Order?>(null);
}
All three attributes are equivalent — the generated code is identical.
Usage
using AutoDispatch;
public sealed record CreateOrderCommand(string CustomerId);
public sealed record DeleteOrderCommand(Guid OrderId);
public sealed record OrderId(Guid Value);
[Handler]
public sealed class CreateOrderHandler
{
public Task<OrderId> HandleAsync(CreateOrderCommand command, CancellationToken ct = default)
=> Task.FromResult(new OrderId(Guid.NewGuid()));
}
[Handler]
public sealed class DeleteOrderHandler
{
public void Handle(DeleteOrderCommand command)
{
}
}
Then consume the generated dispatcher:
app.MapPost("/orders", async (CreateOrderCommand command, AutoDispatch.IDispatcher dispatcher, CancellationToken ct) =>
{
var orderId = await dispatcher.SendAsync(command, ct);
return Results.Ok(orderId);
});
Generated DI registration
builder.Services.AddAutoDispatch();
Produces code like:
services.AddScoped<CreateOrderHandler>();
services.AddScoped<DeleteOrderHandler>();
services.AddScoped<AutoDispatch.IDispatcher, AutoDispatch.Dispatcher>();
Minimal API endpoint generation
Mark a command or query type [Endpoint(method, route)] to wire it directly to an ASP.NET Core minimal API route — no hand-written MapPost/MapGet lambda required:
[Endpoint("POST", "/orders")]
public sealed record CreateOrderCommand(string CustomerId);
[Endpoint("GET", "/orders/{id}")]
public sealed record GetOrderQuery(Guid Id);
[Endpoint("DELETE", "/orders/{id}")]
public sealed record DeleteOrderCommand(Guid Id);
Then map every attributed endpoint in one line:
app.MapAutoDispatchEndpoints();
This generates something like:
app.MapPost("/orders", async ([FromBody] CreateOrderCommand request, IDispatcher dispatcher, CancellationToken ct) =>
{
var response = await dispatcher.SendAsync(request, ct);
return Results.Ok(response);
})
.WithName("CreateOrder")
.WithTags("orders")
.Produces<Order>(StatusCodes.Status200OK);
app.MapGet("/orders/{id}", async ([AsParameters] GetOrderQuery request, IDispatcher dispatcher, CancellationToken ct) =>
{
var response = await dispatcher.SendAsync(request, ct);
return Results.Ok(response);
})
.WithName("GetOrder")
.WithTags("orders")
.Produces<Order>(StatusCodes.Status200OK);
Every generated route is also annotated with OpenAPI metadata for free, so it shows up correctly in Swagger UI / Microsoft.AspNetCore.OpenApi without any extra code:
.WithName(...)— derived from the request type name with itsCommand/Querysuffix stripped (e.g.CreateOrderCommand→CreateOrder).WithSummary(...)— forwarded from the handler'sHandle/HandleAsyncXML doc<summary>, if present (requires<GenerateDocumentationFile>true</GenerateDocumentationFile>— see XML doc comments and pipeline readability).WithTags(...)— the route's first path segment (e.g./orders/{id}→"orders").Produces<T>(...)/.Produces(...)— the handler's actual result type and status code (200 OKwith a body, or204 No ContentforTask/voidhandlers)POST/PUT/PATCHbind the request type from the body ([FromBody]);GET/HEAD/DELETEbind it from the route/query string ([AsParameters]), matching standard ASP.NET Core minimal API conventionsAsync handlers with a result return
200 OKwith the response body; handlers with no result (Task/void) return204 No ContentAD031(error) if two[Endpoint]s map the same HTTP method + route;AD032(warning) if[Endpoint]is applied to a type that no[Handler]/[CommandHandler]/[QueryHandler]actually dispatchesNothing is generated at all unless the compilation references
Microsoft.AspNetCore.Routing(e.g. an ASP.NET Core project) — a class library with[Endpoint]attributes but no ASP.NET Core reference pays zero costSee
samples/AutoDispatch.MinimalApiSamplefor a complete, runnable ASP.NET Core project using this feature
Tracing (OpenTelemetry-compatible)
Every command/notification/stream dispatch can be wrapped in a System.Diagnostics.Activity from a generated "AutoDispatch" ActivitySource, without adding any dependency on OpenTelemetry itself:
builder.Services.AddAutoDispatch(o => o.EnableTracing = true);
Then subscribe from your OpenTelemetry SDK setup as you would any other ActivitySource:
builder.Services.AddOpenTelemetry().WithTracing(tracing =>
tracing.AddSource(AutoDispatch.AutoDispatchTelemetry.ActivitySourceName));
With tracing enabled, IDispatcher resolves to a generated TracingDispatcher decorator that:
- Starts one
ActivityperSendAsync/PublishAsync/StreamAsynccall (namedAutoDispatch.SendAsync,AutoDispatch.PublishAsync,AutoDispatch.StreamAsync), tagged with the short command/notification/query type name - Sets
ActivityStatusCode.Errorand anerror.typetag if the call throws, then rethrows unchanged — tracing never changes behavior or swallows exceptions - For streams, keeps the
Activityopen for the whole enumeration and records an error status if anyMoveNextAsync()call throws
Tracing is opt-in and pay-for-play: EnableTracing defaults to false, so the plain Dispatcher is registered and there is no decorator, no extra virtual call, and no Activity allocation unless you turn it on. Even when enabled, if nothing is listening to the "AutoDispatch" source, ActivitySource.StartActivity(...) returns null and every activity?. call below is a no-op — the cost is one extra method call on the hot path, not a full tracing pipeline.
Native AOT
AutoDispatch has no reflection, no Assembly.GetTypes() scanning, and no dynamic proxies anywhere in its generated output — every handler/behavior/DI registration is plain C# emitted at build time. That makes it fully compatible with Native AOT publishing out of the box.
See the samples/AutoDispatch.AotSample project for a minimal console app that:
- Sets
<PublishAot>true</PublishAot>,<EnableTrimAnalyzer>true</EnableTrimAnalyzer>, and<EnableAotAnalyzer>true</EnableAotAnalyzer> - Builds and publishes with zero trim/AOT analyzer warnings
- Runs a command handler, a query handler, and a void command handler end-to-end through the generated
IDispatcher
Pipeline behaviors
[Behavior(Order = N)] wraps all async handlers in a compile-time pipeline. Identical mental model to MediatR's IPipelineBehavior<,> — but the chain is emitted as generated code, not resolved via reflection at runtime.
Behavior requirements:
- The behavior class must be public, non-abstract, and open-generic with exactly two type parameters
- It must implement
IPipelineBehavior<TCommand, TResult> - It must expose
public Task<TResult> HandleAsync(TCommand command, Func<Task<TResult>> next, CancellationToken ct = default)
Define a behavior
using AutoDispatch;
[Behavior(Order = 0)]
public sealed class LoggingBehavior<TCommand, TResult>
: IPipelineBehavior<TCommand, TResult>
{
private readonly ILogger<LoggingBehavior<TCommand, TResult>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TCommand, TResult>> logger)
=> _logger = logger;
public async Task<TResult> HandleAsync(
TCommand command,
Func<Task<TResult>> next,
CancellationToken ct = default)
{
_logger.LogInformation("→ {Command}", typeof(TCommand).Name);
var result = await next();
_logger.LogInformation("← {Command}", typeof(TCommand).Name);
return result;
}
}
That's all. AddAutoDispatch() registers it automatically.
Multiple behaviors
[Behavior(Order = 0)] // runs first (outermost)
public sealed class LoggingBehavior<TCmd, TResult> : IPipelineBehavior<TCmd, TResult> { ... }
[Behavior(Order = 1)] // runs second
public sealed class ValidationBehavior<TCmd, TResult> : IPipelineBehavior<TCmd, TResult> { ... }
[Behavior(Order = 2)] // runs last (innermost, just before the handler)
public sealed class TimingBehavior<TCmd, TResult> : IPipelineBehavior<TCmd, TResult> { ... }
Execution order: Logging → Validation → Timing → Handler → Timing → Validation → Logging.
When multiple behaviors have the same Order, AutoDispatch preserves declaration order.
What gets generated
For Task<OrderId> SendAsync(CreateOrderCommand) with two behaviors:
// Generated dispatcher method:
public Task<OrderId> SendAsync(CreateOrderCommand command, CancellationToken ct = default)
{
Func<Task<OrderId>> pipeline =
() => _sp.GetRequiredService<CreateOrderHandler>().HandleAsync(command, ct);
var _b1 = _sp.GetRequiredService<TimingBehavior<CreateOrderCommand, OrderId>>();
var _p1 = pipeline;
pipeline = () => _b1.HandleAsync(command, _p1, ct);
var _b0 = _sp.GetRequiredService<LoggingBehavior<CreateOrderCommand, OrderId>>();
var _p0 = pipeline;
pipeline = () => _b0.HandleAsync(command, _p0, ct);
return pipeline();
}
Behaviors and void-async handlers
For Task (no result) handlers, the generator wraps the call in Task<Unit> internally. Unit is emitted by the generator — you never reference it directly; the method signature stays Task SendAsync(...).
Behaviors can also short-circuit by returning a result without calling next().
Behaviors only apply to async handlers
Sync T Send(...) and void Send(...) methods are not wrapped. Add a pipeline when you migrate a sync handler to async, or keep it sync for zero overhead.
Constrained (scoped) behaviors
By default a [Behavior] (and [PreProcessor]/[PostProcessor]/[StreamBehavior]) applies to
every command in the compilation. Add a generic constraint to the TCommand type parameter
to scope it to only the commands that satisfy it — matching how MediatR users constrain a
registered IPipelineBehavior<,> to a subset of requests:
public interface IAudited { }
public sealed record CreateOrderCommand : IAudited { ... } // audited
public sealed record PingCommand { ... } // not audited
[Behavior(Order = 0)]
public sealed class AuditBehavior<TCommand, TResult> : IPipelineBehavior<TCommand, TResult>
where TCommand : IAudited
{
public Task<TResult> HandleAsync(TCommand command, Func<Task<TResult>> next, CancellationToken ct = default)
{
// log an audit entry for `command` ...
return next();
}
}
AuditBehavior is only woven into CreateOrderCommand's generated SendAsync — PingCommand
falls back to its normal dispatch (simple expression-bodied if no other pipeline steps apply)
with no AuditBehavior reference at all, so it never pays for a pipeline it doesn't use.
Constraint checking supports named interface/base-class constraints (the common case — marker
interfaces like IAudited); a generic constraint type (e.g. IMarker<T>) isn't resolved yet and
is treated as always-satisfied rather than silently dropping the behavior. If a constraint never
matches any registered command/query at all — typically a typo — AutoDispatch reports AD027 so
the mistake doesn't fail silently.
Notifications (publish/subscribe)
[Handler] gives you MediatR's Send (exactly one handler per command). [NotificationHandler] gives you the other half — MediatR's Publish: any number of handlers may subscribe to the same notification type, and every one of them runs when you publish it.
using AutoDispatch;
public sealed record OrderCreated(Guid OrderId);
[NotificationHandler]
public sealed class SendConfirmationEmail
{
public Task HandleAsync(OrderCreated notification, CancellationToken ct = default)
{
// ...
return Task.CompletedTask;
}
}
[NotificationHandler]
public sealed class UpdateAnalytics
{
public Task HandleAsync(OrderCreated notification, CancellationToken ct = default)
{
// ...
return Task.CompletedTask;
}
}
AddAutoDispatch() registers both handlers automatically, and IDispatcher gains a matching PublishAsync overload:
await dispatcher.PublishAsync(new OrderCreated(orderId), ct);
// runs SendConfirmationEmail.HandleAsync, then UpdateAnalytics.HandleAsync
Conventions
- Only
HandleAsync(TNotification notification, CancellationToken ct = default)is supported — notification handlers publish, they don't return a result, so plainHandleandTask<T>-returning methods are ignored - Unlike
[Handler], multiple[NotificationHandler]classes may handle the same notification type — there is no AD002-style "duplicate handler" error - By default, handlers run sequentially, in deterministic order (by handler type name), awaiting each one before starting the next — matching MediatR's default
ForeachAwaitPublisherbehavior. If a handler throws, remaining handlers for that publish call do not run - Mark the notification type itself
[ParallelPublish]to switch that notification to concurrent fan-out viaTask.WhenAllinstead — matching MediatR's opt-inTaskWhenAllPublisher. All handlers start immediately and are awaited together; every handler runs even if another one throws (a synchronous throw is safely converted to a faulted task so it doesn't skip the rest), and failures surface once every handler has finished [NotificationHandler(Lifetime = HandlerLifetime.Singleton)](orTransient) works the same way as it does on[Handler]- Pipeline
[Behavior]s apply only to command/query dispatch (Send/SendAsync). To wrapPublishAsyncitself, use notification pipeline behaviors instead
Parallel publish
Only opt in when handlers for a notification are independent of one another and safe to run concurrently (no shared mutable state, no ordering assumptions between handlers):
using AutoDispatch;
[ParallelPublish]
public sealed record OrderCreated(Guid OrderId);
[NotificationHandler]
public sealed class SendConfirmationEmail
{
public Task HandleAsync(OrderCreated notification, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
}
[NotificationHandler]
public sealed class UpdateAnalytics
{
public Task HandleAsync(OrderCreated notification, CancellationToken ct = default) => /* ... */ Task.CompletedTask;
}
// SendConfirmationEmail and UpdateAnalytics now both start immediately and run concurrently
await dispatcher.PublishAsync(new OrderCreated(orderId), ct);
Notification pipeline behaviors
[Behavior] wraps a single command's handler call. [NotificationBehavior] is its Publish-side
counterpart — it wraps the entire fan-out for a notification type (every subscribed handler,
whether sequential or [ParallelPublish]) in one Func<Task>-based pipeline. There is no MediatR
equivalent for this: MediatR's IPipelineBehavior<,> only wraps Send, never Publish.
using AutoDispatch;
public sealed record OrderCreated(Guid OrderId);
[NotificationBehavior(Order = 0)]
public sealed class LoggingNotificationBehavior<TNotification> : INotificationPipelineBehavior<TNotification>
{
private readonly ILogger<LoggingNotificationBehavior<TNotification>> _logger;
public LoggingNotificationBehavior(ILogger<LoggingNotificationBehavior<TNotification>> logger) => _logger = logger;
public async Task HandleAsync(TNotification notification, Func<Task> next, CancellationToken ct = default)
{
_logger.LogInformation("Publishing {Notification}", typeof(TNotification).Name);
await next();
_logger.LogInformation("Published {Notification}", typeof(TNotification).Name);
}
}
Every PublishAsync(OrderCreated, ...) call — sequential or [ParallelPublish] — now runs inside
this behavior. Register any number of them; like [Behavior], they compose by Order (ties broken
by declaration order), and a behavior can call next() zero, one, or multiple times, or not at all
to short-circuit the entire publish.
Conventions
- Must be a
public, non-abstractopen generic class with exactly one type parameter (TNotification) - Must implement
INotificationPipelineBehavior<TNotification>using its own type parameter (AD029otherwise) - Must declare
public Task HandleAsync(TNotification notification, Func<Task> next, CancellationToken ct = default)(AD030otherwise) - Applies to every notification type in the compilation — there is currently no constrained/scoped variant (unlike
[Behavior]); this may be added in a future release AddAutoDispatch()registers each[NotificationBehavior]type as an open generic, the same way[Behavior]is registered- No codegen change to
PublishAsyncat all when no[NotificationBehavior]is registered
Streaming queries
MediatR's IStreamRequest<TResponse> has no zero-reflection equivalent in most alternatives —
[StreamHandler] closes that gap. Mark a class [StreamHandler] with a public
IAsyncEnumerable<TResult> HandleAsync(TQuery query, CancellationToken ct = default) method, and
AutoDispatch generates a matching StreamAsync method on IDispatcher that returns the handler's
async stream directly — no buffering, no intermediate list, items are produced lazily as your
handler yields them.
using AutoDispatch;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
public sealed record GetOrdersQuery(string CustomerId);
public sealed record OrderSummary(string OrderId, decimal Total);
[StreamHandler]
public sealed class GetOrdersHandler
{
public async IAsyncEnumerable<OrderSummary> HandleAsync(
GetOrdersQuery query,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in _repository.StreamOrdersAsync(query.CustomerId, ct))
{
yield return new OrderSummary(order.Id, order.Total);
}
}
}
await foreach (var summary in dispatcher.StreamAsync(new GetOrdersQuery(customerId), ct))
{
// process each item as it arrives — no need to wait for the full result set
}
Conventions
- Like
[Handler], streaming is request/response — exactly one[StreamHandler]is allowed per query type; a second handler for the same query type reports AD010, same spirit as AD002 for commands - Only
HandleAsync(TQuery query, CancellationToken ct = default)returningIAsyncEnumerable<TResult>is recognized; methods returningTask/Task<T>belong on a[Handler], not a[StreamHandler] AddAutoDispatch()registers stream handlers the same way as command/notification handlers, honoring[StreamHandler(Lifetime = ...)][Behavior](the command pipeline) does not apply toStreamAsync— use[StreamBehavior]instead (below) to wrap streaming queries
Stream pipeline behaviors
Streams get their own pipeline, matching MediatR's IStreamPipelineBehavior<TRequest, TResponse>.
Declare a public, open generic class with exactly two type parameters implementing
IStreamPipelineBehavior<TQuery, TResult>, and AutoDispatch wraps every generated StreamAsync
call with it — in Order order (ascending, outermost first), same ordering rules as [Behavior].
Unlike command behaviors (which wrap a Task<TResult>), next() here returns
IAsyncEnumerable<TResult> directly, so a stream behavior is typically itself an async iterator
that forwards (or filters/transforms) items as they arrive:
using AutoDispatch;
using System.Collections.Generic;
[StreamBehavior(Order = 0)]
public sealed class LoggingStreamBehavior<TQuery, TResult> : IStreamPipelineBehavior<TQuery, TResult>
{
public async IAsyncEnumerable<TResult> HandleAsync(
TQuery query,
Func<IAsyncEnumerable<TResult>> next,
CancellationToken ct = default)
{
_logger.LogInformation("Streaming {Query}", query);
await foreach (var item in next().WithCancellation(ct))
{
yield return item;
}
}
}
With no [StreamBehavior]s registered, StreamAsync delegates directly to the handler exactly as
before (no wrapping overhead). Once one or more are registered, AutoDispatch builds a lazy chain of
Func<IAsyncEnumerable<TResult>> calls — nothing runs until the caller actually enumerates the
result, matching the handler's own laziness.
Exception handling
Matching MediatR's IRequestExceptionHandler<TRequest, TResponse, TException>, you can register
typed handlers that intercept an exception thrown by a command/query handler (or by any
[Behavior] in its pipeline) and either supply a fallback response or let it keep propagating.
Declare a public, open generic class with exactly two type parameters (TCommand, TResult)
implementing IExceptionHandler<TCommand, TResult, TException> for one fixed, concrete
exception type:
using AutoDispatch;
public sealed class ValidationException : Exception { }
[ExceptionHandler(Order = 0)]
public sealed class ValidationExceptionHandler<TCommand, TResult> : IExceptionHandler<TCommand, TResult, ValidationException>
{
public Task<ExceptionHandlerResult<TResult>> HandleAsync(TCommand command, ValidationException exception, CancellationToken ct = default)
{
_logger.LogWarning(exception, "Validation failed for {Command}", command);
// Return a fallback response instead of letting the exception propagate:
return Task.FromResult(ExceptionHandlerResult<TResult>.Handled(default!));
// Or let it keep propagating (e.g. to the next applicable handler, or to the caller):
// return Task.FromResult(ExceptionHandlerResult<TResult>.Unhandled());
}
}
Conventions:
- With no
[ExceptionHandler]s registered, dispatch codegen is byte-for-byte unchanged from earlier versions — notry/catch, noasyncoverhead added. - Once one or more are registered, every async command/query dispatch method (with or without
[Behavior]s) is wrapped in atry/catchper distinct exception type. - Multiple handlers may target unrelated or related exception types; catch clauses are always
generated most-derived exception type first (so a handler for
Exceptionnever shadows one forValidationException), then byOrder(ascending), then by declaration order — this also matches how ordinary C#catchblocks must be ordered to compile. - If a handler returns
Unhandled(), the exception is rethrown so the next applicable handler (or the caller) sees it, exactly like MediatR's behavior when no handler setsstate.Handled.
Exception actions
Matching MediatR's IRequestExceptionAction<TRequest, TException>, you can also register
side-effect-only observers that always run when a matching exception is thrown — they cannot
suppress the exception or supply a fallback response, unlike [ExceptionHandler]. This is the
right tool for logging, metrics, or alerting that must fire regardless of whether some other
handler ultimately recovers. Declare a public, open generic class with exactly one type
parameter (TCommand) implementing IExceptionAction<TCommand, TException> for one fixed,
concrete exception type:
using AutoDispatch;
[ExceptionAction(Order = 0)]
public sealed class LoggingExceptionAction<TCommand> : IExceptionAction<TCommand, ValidationException>
{
public Task ExecuteAsync(TCommand command, ValidationException exception, CancellationToken ct = default)
{
_logger.LogWarning(exception, "Validation failed for {Command}", command);
return Task.CompletedTask;
}
}
Conventions:
- Actions and handlers for the same exception type share the same generated
catchblock; within it, all matching actions run first (inOrder), then the matching handlers run — mirroring MediatR's pipeline whereIRequestExceptionActionalways executes beforeIRequestExceptionHandlergets a chance to short-circuit. - Actions run even when no
[ExceptionHandler]is registered for the exception type at all — the exception is rethrown afterward viathrow;, preserving the original stack trace. - Catch-block ordering (most-derived exception type first) is computed across both actions and handlers together, so mixing the two for overlapping exception hierarchies still produces valid, correctly-ordered C#.
Request pre/post-processors
Matching MediatR's IRequestPreProcessor<TRequest> and IRequestPostProcessor<TRequest, TResponse>,
you can register processors that always run immediately before or after a command/query handler
executes — without writing a full [Behavior] (which requires calling a next() delegate
yourself). Pre/post-processors sit as the innermost step of the pipeline, running directly
around the handler call, inside any custom [Behavior]s:
using AutoDispatch;
[PreProcessor(Order = 0)]
public sealed class LoggingPreProcessor<TCommand> : IPreProcessor<TCommand>
{
public Task ProcessAsync(TCommand command, CancellationToken ct = default)
{
_logger.LogInformation("Handling {Command}", command);
return Task.CompletedTask;
}
}
[PostProcessor(Order = 0)]
public sealed class LoggingPostProcessor<TCommand, TResult> : IPostProcessor<TCommand, TResult>
{
public Task ProcessAsync(TCommand command, TResult response, CancellationToken ct = default)
{
_logger.LogInformation("Handled {Command} -> {Response}", command, response);
return Task.CompletedTask;
}
}
Conventions:
[PreProcessor]is a public, open generic class with exactly one type parameter (TCommand) implementingIPreProcessor<TCommand>;[PostProcessor]has exactly two (TCommand,TResult) implementingIPostProcessor<TCommand, TResult>— both are fully open (unlike[ExceptionHandler]/[ExceptionAction], there's no fixed exception type to validate).- All matching pre-processors run first (in
Order), then the handler, then all matching post-processors (inOrder, receiving the handler's response) — for void-async handlers the response isUnit.Value. - With no
[PreProcessor]/[PostProcessor]s registered, dispatch codegen is unchanged.
Automatic FluentValidation integration
If your project references FluentValidation, AutoDispatch automatically wires validation into every async command's pipeline — no attribute, base class, or manual registration required on the command or the validator:
using FluentValidation;
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(c => c.CustomerId).NotEmpty();
RuleFor(c => c.Quantity).GreaterThan(0);
}
}
That's it — no [PreProcessor], no services.AddScoped<IValidator<...>, ...>(), no
AddValidatorsFromAssembly. As soon as FluentValidation is referenced:
- Every
AbstractValidator<T>/IValidator<T>implementation found anywhere in the compilation is auto-registered in DI asIValidator<T>. - A single generated open-generic pre-processor (
AutoDispatchValidationPreProcessor<TCommand>) runs first, before any other[PreProcessor]/[Behavior], resolving every registeredIValidator<TCommand>for the command being dispatched and throwing FluentValidation's ownValidationExceptionon the first failure. - Commands with no matching validator pay only a single empty-enumerable iteration — effectively free.
- Like other pre-processors, this only applies to async handlers (
Handle/HandleAsyncreturningTask/Task<T>); synchronous handlers are unaffected. - Without a FluentValidation reference, nothing changes — no extra generated code, no extra DI registrations, exactly the same output as before this feature existed.
Result pattern
Return Task<Result> or Task<Result<T>> from a handler to represent expected/business failures
as data instead of exceptions — a pattern MediatR has no built-in support for at all:
using AutoDispatch;
public sealed record CreateOrderCommand(string CustomerId);
public sealed record Order(Guid Id, string CustomerId);
[Handler]
public sealed class CreateOrderHandler
{
public Task<Result<Order>> HandleAsync(CreateOrderCommand command, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(command.CustomerId))
{
return Task.FromResult(Result<Order>.Fail("CustomerId is required.", code: "invalid_customer"));
}
return Task.FromResult(Result<Order>.Success(new Order(Guid.NewGuid(), command.CustomerId)));
}
}
var result = await dispatcher.SendAsync(new CreateOrderCommand(customerId));
if (result.IsSuccess)
{
return Results.Ok(result.Value);
}
return Results.BadRequest(result.Errors.Select(e => e.Message));
The real payoff, though, is automatic exception-to-Result conversion: whenever a dispatch
method's result type is Result/Result<T>, AutoDispatch wraps it in a try/catch and converts
any unhandled exception — thrown by the handler itself, a [Behavior], a [PreProcessor], or
even the automatic FluentValidation pre-processor's ValidationException — into Result.Fail(...)
/Result<T>.Fail(...) automatically. You never need a manual try/catch at the call site, and a
FluentValidation NotEmpty()/GreaterThan(0) failure surfaces as an ordinary failed Result with
zero extra code:
[Handler]
public sealed class CreateOrderHandler
{
// A FluentValidation validator for CreateOrderCommand can throw ValidationException here
// (via the automatic pre-processor) -- it becomes Result<Order>.Fail(ex.Message), not an
// unhandled exception.
public Task<Result<Order>> HandleAsync(CreateOrderCommand command, CancellationToken ct = default)
=> Task.FromResult(Result<Order>.Success(new Order(Guid.NewGuid(), command.CustomerId)));
}
Conventions:
[ExceptionHandler]s for specific exception types still run first and take priority — the automaticcatch (Exception)that produces a failedResultis always the innermost/last catch clause, so a more specific handler that returnsHandled(...)short-circuits before the automatic conversion ever runs.Result<T>has an implicit conversion fromT, so a handler canreturn value;directly in contexts that support it.- This only applies to async handlers, matching every other pipeline feature.
- Handlers that don't return
Result/Result<T>are completely unaffected — this is entirely opt-in per handler, based purely on its declared return type.
Pipeline visualization (Mermaid diagrams)
Every generated dispatch pipeline — commands, queries, and notifications, including any
[Behavior]/[PreProcessor]/[PostProcessor]/exception middleware attached to it — is also
rendered as a Mermaid flowchart and exposed at compile time via a
generated AutoDispatchPipelineDiagrams static class:
// Paste straight into https://mermaid.live or any Mermaid-aware Markdown viewer
string diagram = AutoDispatchPipelineDiagrams.ByRequestType["CreateOrderCommand"];
// Or get every pipeline in the project combined into one flowchart
string everything = AutoDispatchPipelineDiagrams.All;
This is purely descriptive (a dictionary of strings with zero runtime cost beyond the constant
allocation) — a handy way to document exactly what happens, and in what order, for a given
command/query/notification without reading generated code, and a nice fit for pasting into ADRs,
onboarding docs, or PR descriptions when adding a new behavior to a pipeline.
Diagnostics
| Code | Severity | Description |
|---|---|---|
| AD001 | Warning | [Handler] on a class with no valid Handle/HandleAsync methods |
| AD002 | Error | Duplicate handlers discovered for the same command type |
| AD003 | Warning | HandleAsync does not accept CancellationToken |
| AD004 | Error | [Behavior] type is not a public, non-abstract open generic class with exactly two type parameters |
| AD005 | Error | [Behavior] type does not implement IPipelineBehavior<TCommand, TResult> |
| AD006 | Error | [Behavior] type does not expose a valid public HandleAsync method |
| AD007 | Warning | [NotificationHandler] on a class with no valid HandleAsync(TNotification, CancellationToken) method |
| AD008 | Warning | Notification HandleAsync does not accept CancellationToken |
| AD009 | Warning | [StreamHandler] on a class with no valid HandleAsync(TQuery, CancellationToken) method returning IAsyncEnumerable<TResult> |
| AD010 | Error | Duplicate stream handlers discovered for the same query type |
| AD011 | Warning | Stream HandleAsync does not accept CancellationToken |
| AD012 | Error | [StreamBehavior] type is not a public, non-abstract open generic class with exactly two type parameters |
| AD013 | Error | [StreamBehavior] type does not implement IStreamPipelineBehavior<TQuery, TResult> |
| AD014 | Error | [StreamBehavior] type does not expose a valid public HandleAsync method |
| AD015 | Error | [ExceptionHandler] type is not a public, non-abstract open generic class with exactly two type parameters |
| AD016 | Error | [ExceptionHandler] type does not implement IExceptionHandler<TCommand, TResult, TException> for a fixed exception type |
| AD017 | Error | [ExceptionHandler] type does not expose a valid public HandleAsync method |
| AD018 | Error | [ExceptionAction] type is not a public, non-abstract open generic class with exactly one type parameter |
| AD019 | Error | [ExceptionAction] type does not implement IExceptionAction<TCommand, TException> for a fixed exception type |
| AD020 | Error | [ExceptionAction] type does not expose a valid public ExecuteAsync method |
| AD021 | Error | [PreProcessor] type is not a public, non-abstract open generic class with exactly one type parameter |
| AD022 | Error | [PreProcessor] type does not implement IPreProcessor<TCommand> |
| AD023 | Error | [PreProcessor] type does not expose a valid public ProcessAsync method |
| AD024 | Error | [PostProcessor] type is not a public, non-abstract open generic class with exactly two type parameters |
| AD025 | Error | [PostProcessor] type does not implement IPostProcessor<TCommand, TResult> |
| AD026 | Error | [PostProcessor] type does not expose a valid public ProcessAsync method |
| AD027 | Warning | A constrained [Behavior]/[PreProcessor]/[PostProcessor]/[StreamBehavior]'s constraint doesn't match any registered command/query — it will never run |
| AD028 | Error | [NotificationBehavior] type is not a public, non-abstract open generic class with exactly one type parameter |
| AD029 | Error | [NotificationBehavior] type does not implement INotificationPipelineBehavior<TNotification> |
| AD030 | Error | [NotificationBehavior] type does not expose a valid public HandleAsync method |
| AD031 | Error | Two [Endpoint] declarations map the same HTTP method + route |
| AD032 | Warning | [Endpoint] is applied to a type that no [Handler]/[CommandHandler]/[QueryHandler] actually dispatches |
AD001
[Handler]on '{Type}' has noHandleorHandleAsyncmethods. No dispatch methods will be generated.
Add a valid Handle or HandleAsync method to the handler class.
AD002
Duplicate handler for command '{Command}': both '{HandlerA}' and '{HandlerB}' define a Handle/HandleAsync method for this command type. Remove one handler or rename the method.
Each command/query type must map to exactly one handler method.
AD003
HandleAsyncon '{Handler}' for command '{Command}' is missing aCancellationTokenparameter. Consider addingCancellationToken ct = defaultas the second parameter.`
The method still works; the warning helps you preserve cancellation flow.
AD004
[Behavior]on '{Type}' must be a public, non-abstract class with exactly two type parameters so AutoDispatch can close it as<TCommand, TResult>.`
Pipeline behaviors are resolved as closed generics at dispatch time, so [Behavior] types must be declared as open generic classes such as LoggingBehavior<TCommand, TResult>.
AD005
[Behavior]on '{Type}' must implementAutoDispatch.IPipelineBehavior<TCommand, TResult>using its declared type parameters.`
Implement the generated IPipelineBehavior<TCommand, TResult> interface directly on the behavior type.
AD006
[Behavior]on '{Type}' must declarepublic Task<TResult> HandleAsync(TCommand command, Func<Task<TResult>> next, CancellationToken ct = default).`
Explicit interface implementations are not enough — the generated dispatcher calls the behavior's public HandleAsync method directly.
AD007
[NotificationHandler]on '{Type}' has noHandleAsync(TNotification, CancellationToken)method. No publish dispatch will be generated for this handler.`
Add a valid HandleAsync(TNotification notification, CancellationToken ct = default) method that returns Task.
AD008
HandleAsyncon '{Handler}' for notification '{Notification}' is missing aCancellationTokenparameter. Consider addingCancellationToken ct = defaultas the second parameter.`
The method still works; the warning helps you preserve cancellation flow through PublishAsync.
AD009
[StreamHandler]on '{Type}' has noHandleAsync(TQuery, CancellationToken)method returningIAsyncEnumerable<TResult>. No streaming dispatch will be generated.`
Add a valid HandleAsync(TQuery query, CancellationToken ct = default) method that returns IAsyncEnumerable<TResult>.
AD010
Duplicate stream handler for query '{Query}': both '{HandlerA}' and '{HandlerB}' define aHandleAsyncstream method for this query type. Remove one handler or rename the method.
Each query type must map to exactly one stream handler, just like commands.
AD011
HandleAsyncon '{Handler}' for query '{Query}' is missing aCancellationTokenparameter. Consider addingCancellationToken ct = defaultas the second parameter.`
The method still works; the warning helps you preserve cancellation flow through StreamAsync.
AD012
[StreamBehavior]on '{Type}' must be a public, non-abstract class with exactly two type parameters so AutoDispatch can close it as<TQuery, TResult>.`
Stream pipeline behaviors are resolved as closed generics at dispatch time, so [StreamBehavior] types must be declared as open generic classes such as LoggingStreamBehavior<TQuery, TResult>.
AD013
[StreamBehavior]on '{Type}' must implementAutoDispatch.IStreamPipelineBehavior<TQuery, TResult>using its declared type parameters.`
Implement the generated IStreamPipelineBehavior<TQuery, TResult> interface directly on the behavior type.
AD014
[StreamBehavior]on '{Type}' must declarepublic IAsyncEnumerable<TResult> HandleAsync(TQuery query, Func<IAsyncEnumerable<TResult>> next, CancellationToken ct = default).`
Explicit interface implementations are not enough — the generated dispatcher calls the behavior's public HandleAsync method directly.
XML doc comments and pipeline readability
Doc comments on Handle/HandleAsync methods are forwarded to the generated IDispatcher member automatically (and, if you use minimal API endpoint generation, to the generated route's .WithSummary(...) too). This requires <GenerateDocumentationFile>true</GenerateDocumentationFile> in your project — without it, the C# compiler discards doc comment trivia entirely and there is nothing for AutoDispatch to forward.
[Handler]
public sealed class CreateOrderHandler
{
/// <summary>Creates an order for the given customer.</summary>
public Task<OrderId> HandleAsync(CreateOrderCommand command, CancellationToken ct = default)
=> Task.FromResult(new OrderId(Guid.NewGuid()));
}
generates:
public interface IDispatcher
{
/// <summary>Creates an order for the given customer.</summary>
Task<OrderId> SendAsync(CreateOrderCommand command, CancellationToken ct = default);
}
Generated async dispatch methods that go through a behavior pipeline are also annotated with a comment showing the execution order, so you never have to guess:
// Pipeline: LoggingBehavior -> ValidationBehavior -> CreateOrderHandler.HandleAsync -> LoggingBehavior -> ValidationBehavior
public Task<OrderId> SendAsync(CreateOrderCommand command, CancellationToken ct = default)
{
...
}
IDE code fixes
AutoDispatch.CodeFixes ships inside the AutoDispatch.Generator package and adds one-click fixes:
| Diagnostic | Quick fix |
|---|---|
| AD001 | Adds a HandleAsync stub method to a [Handler] class with none |
| AD003 | Adds the missing CancellationToken ct = default parameter |
| AD007 | Adds a HandleAsync stub method to a [NotificationHandler] class with none |
| AD008 | Adds the missing CancellationToken ct = default parameter to a notification HandleAsync |
| AD009 | Adds a HandleAsync stub method to a [StreamHandler] class with none |
| AD011 | Adds the missing CancellationToken ct = default parameter to a stream HandleAsync |
| AD100 | Converts a MediatR IRequestHandler<,>/IRequestHandler<> class to [Handler] — see Migrating from MediatR |
| AD101 | Converts a MediatR INotificationHandler<> class to [NotificationHandler] — see Migrating from MediatR |
Automatic MediatR migration hints (AD100/AD101)
You don't have to convert an existing MediatR codebase by hand. As soon as AutoDispatch.Generator
is installed alongside MediatR, it detects any class still implementing MediatR's
IRequestHandler<,>, IRequestHandler<>, or INotificationHandler<> and reports an IDE suggestion
(AD100/AD101, Info severity — never breaks your build) with a one-click "Convert to
AutoDispatch [Handler]" / "Convert to AutoDispatch [NotificationHandler]" fix that:
- Adds the
[Handler]/[NotificationHandler]attribute - Removes the MediatR interface from the class's base list
- Renames MediatR's
Handlemethod to AutoDispatch'sHandleAsyncconvention (parameters, including the existingCancellationToken, are left untouched)
This costs nothing in projects that don't reference MediatR at all — the analyzer looks up
MediatR's interfaces by fully-qualified name and skips all further work for the whole compilation
if they aren't found. Combined with Ctrl+. → Fix all occurrences in Solution, an entire
MediatR-based codebase's handlers can be converted in a couple of clicks; see
Migrating from MediatR for the remaining manual steps (pipeline
behaviors, DI registration, and call sites).
Testing handlers and behaviors
The AutoDispatch.Testing package makes it
easy to unit test handlers and [Behavior] chains without a DI container:
dotnet add package AutoDispatch.Testing
// FakeServiceProvider — a minimal IServiceProvider for constructing the generated Dispatcher
var sp = new FakeServiceProvider().Add(new CreateOrderHandler());
IDispatcher dispatcher = new Dispatcher(sp);
var orderId = await dispatcher.SendAsync(new CreateOrderCommand("cust-1"));
// PipelineTestHarness — test a behavior in isolation, short-circuiting next()
var result = await PipelineTestHarness.InvokeAsync<CreateOrderCommand, OrderId>(
loggingBehavior.HandleAsync,
command,
nextResult: expectedOrderId);
See the AutoDispatch.Testing README for more.
Scaffolding with dotnet new
dotnet new install AutoDispatch.Templates
dotnet new autodispatch-handler -n CreateOrder --namespace MyApp.Orders
dotnet new autodispatch-notification -n OrderCreated --namespace MyApp.Orders
dotnet new autodispatch-stream -n GetOrders --namespace MyApp.Orders
Generates a ready-to-fill CreateOrderCommand.cs with the command record and [Handler] class,
OrderCreatedNotification.cs with the notification record and [NotificationHandler] class, or
GetOrdersQuery.cs with the query record and [StreamHandler] class.
AutoDispatch vs alternatives
| Approach | Boilerplate | Runtime dispatch | Pipeline behaviors | Notifications (publish) | Streaming queries | Compile-time safety | AOT |
|---|---|---|---|---|---|---|---|
| AutoDispatch | Low | None | Compile-time generated, with typed [ExceptionHandler]s |
✅ (fan-out, sequential or [ParallelPublish]) |
✅ (IAsyncEnumerable<T> + pipeline) |
High | ✅ |
| MediatR | Medium | Yes | Runtime reflection, with IRequestExceptionHandler<,,> |
✅ | ✅ | High | ⚠️ |
| Raw service calls | Low | None | Manual | Manual | Manual | High | ✅ |
Benchmarks
BenchmarkDotNet results comparing the generated
IDispatcher against MediatR's IMediator, for a single no-op command handler, a notification
fanned out to two no-op handlers, and a 10-item stream fully enumerated:
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| AutoDispatch_SendAsync | 16.36 ns | 1.00 | 96 B | 1.00 |
| MediatR_Send | 69.66 ns | 4.26 | 288 B | 3.00 |
| AutoDispatch_PublishAsync | 30.13 ns | 1.84 | 24 B | 0.25 |
| MediatR_Publish | 106.47 ns | 6.51 | 464 B | 4.83 |
| AutoDispatch_StreamAsync | 172.46 ns | 10.54 | 144 B | 1.50 |
| MediatR_CreateStream | 435.03 ns | 26.60 | 536 B | 5.58 |
SendAsync is ~4x faster, 3x fewer allocations. PublishAsync fanning out to two handlers is
~3.5x faster and allocates ~19x less. StreamAsync fully enumerating a 10-item stream is ~2.5x
faster and allocates ~3.7x less — no reflection-based handler lookup, no runtime-built
pipeline, publisher, or stream wrapper. Run it yourself with dotnet run -c Release in
benchmarks/AutoDispatch.Benchmarks.
A separate benchmark measures a full pipeline — two behaviors plus a pre-processor and a post-processor wrapping the handler — instead of a bare no-op dispatch:
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| AutoDispatch_SendAsync_FullPipeline | 173.9 ns | 1.00 | 432 B | 1.00 |
| MediatR_Send_FullPipeline | 313.9 ns | 1.81 | 1280 B | 2.96 |
Even fully wired up with behaviors and pre/post-processors on both sides, AutoDispatch is still ~1.8x faster and allocates ~3x less than MediatR's equivalent runtime pipeline.
Migrating from MediatR
AutoDispatch follows the same CQRS mental model as MediatR, so migration is mechanical.
Tip: Steps 2 and 3 below (removing marker interfaces, renaming
HandletoHandleAsync, swapping in[Handler]/[NotificationHandler]) can be done automatically. AddAutoDispatch.Generatorto a project that still references MediatR and it will surface anAD100/AD101suggestion with a one-click fix on every handler — see Automatic MediatR migration hints. Steps 1, 4, 5, and 6 (removing the MediatR package, converting pipeline behaviors, DI registration, and call sites) are still manual.
1. Install AutoDispatch and remove MediatR
dotnet add package AutoDispatch.Generator
dotnet remove package MediatR
dotnet remove package MediatR.Extensions.Microsoft.DependencyInjection
2. Remove marker interfaces from commands
// Before
public sealed record CreateOrderCommand(string CustomerId) : IRequest<OrderId>;
// After
public sealed record CreateOrderCommand(string CustomerId);
3. Convert handler classes
// Before
public sealed class CreateOrderHandler : IRequestHandler<CreateOrderCommand, OrderId>
{
public Task<OrderId> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
=> Task.FromResult(new OrderId(Guid.NewGuid()));
}
// After
[Handler]
public sealed class CreateOrderHandler
{
public Task<OrderId> HandleAsync(CreateOrderCommand command, CancellationToken ct = default)
=> Task.FromResult(new OrderId(Guid.NewGuid()));
}
4. Convert pipeline behaviors
// Before
public sealed class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
_logger.LogInformation("→ {Request}", typeof(TRequest).Name);
var result = await next();
_logger.LogInformation("← {Request}", typeof(TRequest).Name);
return result;
}
}
// After
[Behavior(Order = 0)]
public sealed class LoggingBehavior<TCommand, TResult>
: IPipelineBehavior<TCommand, TResult>
{
public async Task<TResult> HandleAsync(TCommand command, Func<Task<TResult>> next, CancellationToken ct = default)
{
_logger.LogInformation("→ {Command}", typeof(TCommand).Name);
var result = await next();
_logger.LogInformation("← {Command}", typeof(TCommand).Name);
return result;
}
}
5. Update DI registration
// Before
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
// After
builder.Services.AddAutoDispatch();
6. Update dispatch call sites
// Before (IMediator)
var orderId = await mediator.Send(new CreateOrderCommand(customerId), ct);
// After (IDispatcher)
var orderId = await dispatcher.SendAsync(new CreateOrderCommand(customerId), ct);
Tip: Use the AutoDispatch Migrator Copilot agent to automate the migration across your entire codebase.
Best fit
Use AutoDispatch when you want:
- CQRS-style organization without MediatR ceremony
- Build-time generated dispatch code
- Fast startup and predictable runtime behavior
- Plain C# command/query types with no framework coupling
Also by the same author
🌐 Full suite overview: swevo.github.io
| Package | Description |
|---|---|
| AutoWire | Compile-time DI auto-registration for Microsoft.Extensions.DependencyInjection. |
| AutoMap.Generator | Compile-time object mapping with generated extension methods. |
| AutoValidate.Generator | Compile-time validator discovery and registration. |
| AutoResult.Generator | Compile-time result helpers and Try*() wrappers. |
| AutoQuery.Generator | Compile-time query specifications for LINQ-based filtering. |
| AutoLog.Generator | Compile-time high-performance logging — [Log(Level, Message)] on a partial method generates LoggerMessage.Define. AOT-safe. |
| AutoHttpClient.Generator | Compile-time typed HTTP client — [HttpClient] on an interface generates a strongly-typed client. AOT-safe Refit alternative. |
Related Packages
| Package | Downloads | Description |
|---|---|---|
| AutoWire | Compile-time dependency injection auto-registration for | |
| AutoMap.Generator | Compile-time object mapping for | |
| AutoQuery.Generator | Compile-time query composition for IQueryable using Roslyn incremental source generators | |
| AutoArchitecture | Compile-time architecture/dependency-rule enforcement for | |
| AutoHttpClient.Generator | Compile-time typed HTTP client generation for | |
| AutoLog.Generator | Compile-time high-performance logging for | |
| AutoValidate.Generator | Compile-time FluentValidation wiring for |
License
MIT
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
1.21.0: Result pattern with automatic exception capture -- a built-in Result/Result<T> type (Error, IsSuccess/IsFailure, Value, Fail(...), implicit T -> Result<T> conversion) generated into every consuming project; return Task<Result>/Task<Result<T>> from a handler and AutoDispatch automatically converts any unhandled exception (handler, behavior, pre/post-processor, or the automatic FluentValidation pre-processor's ValidationException) into Result.Fail(ex.Message) instead of propagating -- no manual try/catch needed, and a registered [ExceptionHandler] for a specific exception type still takes priority; entirely opt-in per handler based on its declared return type; no MediatR equivalent. 1.20.0: Generated minimal API endpoints (from 1.19.0's [Endpoint] attribute) are now annotated with OpenAPI metadata automatically -- .WithName(...) derived from the request type, .WithSummary(...) forwarded from the handler's XML doc <summary> (requires GenerateDocumentationFile), .WithTags(...) from the route's first path segment, and .Produces<T>(...)/.Produces(...) with the real response type and status code (200 OK or 204 No Content) -- so every generated route shows up correctly in Swagger UI / Microsoft.AspNetCore.OpenApi with zero extra code. 1.19.0: Minimal API endpoint generation -- [Endpoint("POST", "/orders")] on a command/query type generates a MapAutoDispatchEndpoints(this IEndpointRouteBuilder app) extension method wiring it directly to an ASP.NET Core minimal API route dispatched through IDispatcher, with correct FromBody/AsParameters binding per HTTP method, AD031/AD032 diagnostics, and zero cost unless both [Endpoint] is used and Microsoft.AspNetCore.Routing is referenced; see samples/AutoDispatch.MinimalApiSample. 1.18.0: Native AOT sample (samples/AutoDispatch.AotSample) demonstrating PublishAot=true with zero trim/AOT analyzer warnings; fixed a TracingDispatcher compile error (CS0127) that affected any project with a sync void command handler (return statement was emitted for a void-returning wrapper method), found via the new sample. 1.17.0: Automatic FluentValidation integration — referencing FluentValidation is enough on its own: validators are auto-registered in DI and auto-invoked (as a generated pre-processor) before every async handler runs, no attribute or manual wiring needed; and pipeline visualization — every generated pipeline (commands, queries, notifications) is now also rendered as a Mermaid flowchart via AutoDispatchPipelineDiagrams, ready to paste into docs/ADRs. 1.16.0: Automatic MediatR migration hints — a new analyzer detects classes still implementing MediatR's IRequestHandler<,>, IRequestHandler<>, or INotificationHandler<> and reports an Info-severity AD100/AD101 suggestion with a one-click "Convert to AutoDispatch [Handler]"/"Convert to AutoDispatch [NotificationHandler]" code fix that adds the attribute, removes the MediatR interface, and renames Handle to HandleAsync; costs nothing in projects that do not reference MediatR. 1.15.0: Notification pipeline behaviors — mark a public, open generic class with one type parameter (TNotification) implementing INotificationPipelineBehavior<TNotification> as [NotificationBehavior(Order=N)] to wrap the *entire* PublishAsync fan-out for every notification type (all subscribed handlers, sequential or [ParallelPublish]) in a Func<Task>-based pipeline — logging, metrics, retry, or short-circuiting logic that runs once per Publish call instead of once per handler; there is no MediatR equivalent for this (MediatR's pipeline behaviors only wrap Send, not Publish); AD028/AD029/AD030 diagnostics for misconfigured notification behaviors; no codegen change at all when none are registered. 1.14.1: NuGet package metadata fix — PackageReleaseNotes on NuGet.org was stale since 1.12.0 (missing 1.13.0/1.13.1/1.14.0 entries); no code changes. 1.14.0: Built-in OpenTelemetry-compatible tracing — opt in with AddAutoDispatch(o => o.EnableTracing = true) to resolve IDispatcher as a generated TracingDispatcher decorator that wraps every SendAsync/PublishAsync/StreamAsync call in a System.Diagnostics.Activity from a new "AutoDispatch" ActivitySource (AutoDispatch.AutoDispatchTelemetry.ActivitySourceName), with no dependency on the OpenTelemetry SDK itself; activities are tagged with the command/notification/query type and record an error status (without suppressing the exception) if the call throws; tracing defaults to off and costs nothing extra when disabled or unobserved. 1.13.1: AD027 diagnostic (Warning) — reported when a constrained [Behavior]/[PreProcessor]/[PostProcessor]/[StreamBehavior]'s named-type constraint (e.g. where TCommand : IAudited) doesn't match any command/query registered in the compilation, so it would silently never run; almost always a typo'd or overly-narrow constraint. 1.13.0: Constrained (scoped) behaviors/processors — a [Behavior], [PreProcessor], [PostProcessor], or [StreamBehavior]'s open generic type parameter may now declare a named-type constraint (e.g. where TCommand : IAudited), and AutoDispatch only weaves that behavior/processor into commands (or streaming queries) whose type actually satisfies the constraint, matching how MediatR users scope a registered IPipelineBehavior<,> to a subset of requests via a generic constraint. 1.12.0: Request pre/post-processors — mark a public, open generic class with one type parameter (TCommand) implementing IPreProcessor<TCommand> as [PreProcessor(Order=N)] to run unconditionally right before a handler executes, or a class with two type parameters (TCommand, TResult) implementing IPostProcessor<TCommand,TResult> as [PostProcessor(Order=N)] to run right after with the handler's response, without writing a full next()-calling [Behavior]; both sit innermost, wrapping directly around the handler call inside any custom [Behavior]s, matching MediatR's IRequestPreProcessor<>/IRequestPostProcessor<,>; AD021-AD026 diagnostics; no codegen change when unused. 1.11.0: Exception actions — mark a public, open generic class with one type parameter (TCommand) implementing IExceptionAction<TCommand,TException> for one fixed exception type as [ExceptionAction(Order=N)] to run a side-effect-only observer (logging, metrics, alerting) whenever that exception is thrown from a handler or pipeline behavior; actions always run and never suppress the exception, matching MediatR's IRequestExceptionAction<,>; actions run before handlers in the same catch block, and the most-derived-exception-first catch ordering is now computed across both actions and handlers together; AD018/AD019/AD020 diagnostics. 1.10.0: Exception handling middleware — mark a public, open generic class with two type parameters (TCommand, TResult) implementing IExceptionHandler<TCommand,TResult,TException> for one fixed exception type as [ExceptionHandler(Order=N)] to intercept that exception from a handler or pipeline behavior and either supply a fallback response or let it keep propagating, matching MediatR's IRequestExceptionHandler<,,>; catch clauses are generated most-derived-exception-type first so handlers for related exception types never produce unreachable code; AD015/AD016/AD017 diagnostics; no codegen change at all when no exception handlers are registered. 1.9.0: Configurable notification publish strategy — mark a notification type [ParallelPublish] to fan PublishAsync out to all handlers concurrently via Task.WhenAll (matching MediatR's TaskWhenAllPublisher) instead of the sequential default; every handler runs even if another throws, including synchronous throws (safely converted to faulted tasks). 1.8.0: Stream pipeline behaviors — [StreamBehavior(Order=N)] open generic classes implementing IStreamPipelineBehavior<TQuery,TResult> now wrap generated StreamAsync calls the same way [Behavior] wraps commands, closing the "no pipeline support for streams" gap from 1.7.0; AD012/AD013/AD014 diagnostics for misconfigured stream behaviors. 1.7.0: Streaming queries — [StreamHandler] classes with HandleAsync(TQuery, CancellationToken) : IAsyncEnumerable<TResult> are exposed via a new StreamAsync() method on IDispatcher, delegating directly to the handler with no buffering; exactly one handler per query type (AD010 duplicate error, like commands); AD009/AD011 diagnostics with matching IDE code fixes; new autodispatch-stream dotnet-new template; benchmarked ~2.5x faster and ~3.7x fewer allocations than MediatR's CreateStream. 1.6.1: IDE code fixes for notifications — AD007 (add HandleAsync stub) and AD008 (add CancellationToken parameter) now have one-click fixes, matching AD001/AD003 for commands. 1.6.0: Notifications / publish-subscribe — [NotificationHandler] classes with HandleAsync(TNotification, CancellationToken) are fanned out via a new PublishAsync() method on IDispatcher; any number of handlers may subscribe to the same notification type (no duplicate-handler error, unlike commands); AD007/AD008 diagnostics. 1.5.0: XML doc comments on Handle/HandleAsync methods are now forwarded to the generated IDispatcher members; generated async dispatch methods with behaviors now include a "// Pipeline: A -> B -> Handler -> B -> A" comment showing execution order; added IDE code fixes for AD001 (add HandleAsync stub) and AD003 (add CancellationToken parameter) via the companion AutoDispatch.CodeFixes assembly shipped in the same package. 1.4.0: Validated pipeline behaviors with ordered execution, declaration-order tie-breaking, and AD004/AD005/AD006 diagnostics for misconfigured behaviors. 1.3.1: Fix package icon (was showing an incorrect/placeholder image). 1.3.0: [Behavior(Order=N)] pipeline behaviors; IPipelineBehavior<TCommand,TResult>; Unit struct for void-async handlers; open-generic DI registration. 1.2.0: [CommandHandler]/[QueryHandler] semantic aliases. 1.1.0: HandlerLifetime (Scoped/Singleton/Transient). 1.0.0: [Handler] attribute, Handle/HandleAsync discovery, typed IDispatcher + Dispatcher generated, AddAutoDispatch() DI registration, AD001/AD002/AD003 diagnostics.