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.
- 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; noIPipelineBehavior<,>magic at runtime - No marker interfaces — commands stay as plain POCOs
- AOT-friendly — everything is compile-time generated
- DI-ready —
AddAutoDispatch()wires up handlers, behaviors, andIDispatcher
dotnet add package AutoDispatch.GeneratorThen register the generated dispatcher:
builder.Services.AddAutoDispatch();usingMediatR;publicsealedrecordCreateOrderCommand(stringCustomerId):IRequest<OrderId>;publicsealedclassCreateOrderHandler:IRequestHandler<CreateOrderCommand,OrderId>{publicTask<OrderId>Handle(CreateOrderCommandrequest,CancellationTokencancellationToken){// ...}}usingAutoDispatch;publicsealedrecordCreateOrderCommand(stringCustomerId);[Handler]publicsealedclassCreateOrderHandler{publicTask<OrderId>HandleAsync(CreateOrderCommandcommand,CancellationTokenct=default){// ...}}Given one or more [Handler] classes, AutoDispatch emits:
AutoDispatch.HandlerAttributeAutoDispatch.IDispatcherAutoDispatch.DispatcherAddAutoDispatch()forIServiceCollection
Example generated dispatcher:
#nullable enable
usingSystem.Threading;usingSystem.Threading.Tasks;usingMicrosoft.Extensions.DependencyInjection;namespaceAutoDispatch{publicinterfaceIDispatcher{Task<OrderId>SendAsync(CreateOrderCommandcommand,CancellationTokenct=default);voidSend(DeleteOrderCommandcommand);}internalsealedclassDispatcher:IDispatcher{privatereadonlyIServiceProvider_sp;publicDispatcher(IServiceProvidersp)=>_sp=sp;publicTask<OrderId>SendAsync(CreateOrderCommandcommand,CancellationTokenct=default)=>_sp.GetRequiredService<CreateOrderHandler>().HandleAsync(command,ct);publicvoidSend(DeleteOrderCommandcommand)=>_sp.GetRequiredService<DeleteOrderHandler>().Handle(command);}}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
[CommandHandler] and [QueryHandler] are aliases for [Handler] — use whichever reads best in your codebase.
[CommandHandler]publicsealedclassCreateOrderHandler{publicTask<OrderId>HandleAsync(CreateOrderCommandcommand,CancellationTokenct=default)=>Task.FromResult(newOrderId(Guid.NewGuid()));}[QueryHandler]publicsealedclassGetOrderHandler{publicTask<Order?>HandleAsync(GetOrderQueryquery,CancellationTokenct=default)=>Task.FromResult<Order?>(null);}All three attributes are equivalent — the generated code is identical.
usingAutoDispatch;publicsealedrecordCreateOrderCommand(stringCustomerId);publicsealedrecordDeleteOrderCommand(GuidOrderId);publicsealedrecordOrderId(GuidValue);[Handler]publicsealedclassCreateOrderHandler{publicTask<OrderId>HandleAsync(CreateOrderCommandcommand,CancellationTokenct=default)=>Task.FromResult(newOrderId(Guid.NewGuid()));}[Handler]publicsealedclassDeleteOrderHandler{publicvoidHandle(DeleteOrderCommandcommand){}}Then consume the generated dispatcher:
app.MapPost("/orders",async(CreateOrderCommandcommand,AutoDispatch.IDispatcherdispatcher,CancellationTokenct)=>{varorderId=awaitdispatcher.SendAsync(command,ct);returnResults.Ok(orderId);});builder.Services.AddAutoDispatch();Produces code like:
services.AddScoped<CreateOrderHandler>();services.AddScoped<DeleteOrderHandler>();services.AddScoped<AutoDispatch.IDispatcher,AutoDispatch.Dispatcher>();[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)
usingAutoDispatch;[Behavior(Order=0)]publicsealedclassLoggingBehavior<TCommand,TResult>:IPipelineBehavior<TCommand,TResult>{privatereadonlyILogger<LoggingBehavior<TCommand,TResult>>_logger;publicLoggingBehavior(ILogger<LoggingBehavior<TCommand,TResult>>logger)=>_logger=logger;publicasyncTask<TResult>HandleAsync(TCommandcommand,Func<Task<TResult>>next,CancellationTokenct=default){_logger.LogInformation("→ {Command}",typeof(TCommand).Name);varresult=awaitnext();_logger.LogInformation("← {Command}",typeof(TCommand).Name);returnresult;}}That's all. AddAutoDispatch() registers it automatically.
[Behavior(Order=0)]// runs first (outermost)publicsealedclassLoggingBehavior<TCmd,TResult>:IPipelineBehavior<TCmd,TResult>{ ...}[Behavior(Order=1)]// runs secondpublicsealedclassValidationBehavior<TCmd,TResult>:IPipelineBehavior<TCmd,TResult>{ ...}[Behavior(Order=2)]// runs last (innermost, just before the handler)publicsealedclassTimingBehavior<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.
For Task<OrderId> SendAsync(CreateOrderCommand) with two behaviors:
// Generated dispatcher method:publicTask<OrderId>SendAsync(CreateOrderCommandcommand,CancellationTokenct=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);returnpipeline();}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().
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.
| 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 |
[Handler]on '{Type}' has noHandleorHandleAsyncmethods. No dispatch methods will be generated.
Add a valid Handle or HandleAsync method to the handler class.
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.
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.
[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>.
[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.
[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.
Doc comments on Handle/HandleAsync methods are forwarded to the generated IDispatcher member automatically:
[Handler]publicsealedclassCreateOrderHandler{/// <summary>Creates an order for the given customer.</summary>publicTask<OrderId>HandleAsync(CreateOrderCommandcommand,CancellationTokenct=default)=>Task.FromResult(newOrderId(Guid.NewGuid()));}generates:
publicinterfaceIDispatcher{/// <summary>Creates an order for the given customer.</summary>Task<OrderId>SendAsync(CreateOrderCommandcommand,CancellationTokenct=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 -> ValidationBehaviorpublicTask<OrderId>SendAsync(CreateOrderCommandcommand,CancellationTokenct=default){
...}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 |
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 Dispatchervarsp=newFakeServiceProvider().Add(newCreateOrderHandler());IDispatcherdispatcher=newDispatcher(sp);varorderId=awaitdispatcher.SendAsync(newCreateOrderCommand("cust-1"));// PipelineTestHarness — test a behavior in isolation, short-circuiting next()varresult=awaitPipelineTestHarness.InvokeAsync<CreateOrderCommand,OrderId>(loggingBehavior.HandleAsync,command,nextResult:expectedOrderId);See the AutoDispatch.Testing README for more.
dotnet new install AutoDispatch.Templates
dotnet new autodispatch-handler -n CreateOrder --namespace MyApp.OrdersGenerates a ready-to-fill CreateOrderCommand.cs with the command record and [Handler] class.
| Approach | Boilerplate | Runtime dispatch | Pipeline behaviors | Compile-time safety | AOT |
|---|---|---|---|---|---|
| AutoDispatch | Low | None | Compile-time generated | High | ✅ |
| MediatR | Medium | Yes | Runtime reflection | High | |
| Raw service calls | Low | None | Manual | High | ✅ |
BenchmarkDotNet results for a single no-op
handler, comparing the generated IDispatcher against MediatR's IMediator:
| Method | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|
| AutoDispatch_SendAsync | 23.42 ns | 1.00 | 96 B | 1.00 |
| MediatR_Send | 89.13 ns | 3.85 | 288 B | 3.00 |
~3.8x faster, 3x fewer allocations — no reflection-based handler lookup, no runtime-built
pipeline. Run it yourself with dotnet run -c Release in benchmarks/AutoDispatch.Benchmarks.
AutoDispatch follows the same CQRS mental model as MediatR, so migration is mechanical:
dotnet add package AutoDispatch.Generator
dotnet remove package MediatR
dotnet remove package MediatR.Extensions.Microsoft.DependencyInjection// BeforepublicsealedrecordCreateOrderCommand(stringCustomerId):IRequest<OrderId>;// AfterpublicsealedrecordCreateOrderCommand(stringCustomerId);// BeforepublicsealedclassCreateOrderHandler:IRequestHandler<CreateOrderCommand,OrderId>{publicTask<OrderId>Handle(CreateOrderCommandrequest,CancellationTokencancellationToken)=>Task.FromResult(newOrderId(Guid.NewGuid()));}// After[Handler]publicsealedclassCreateOrderHandler{publicTask<OrderId>HandleAsync(CreateOrderCommandcommand,CancellationTokenct=default)=>Task.FromResult(newOrderId(Guid.NewGuid()));}// BeforepublicsealedclassLoggingBehavior<TRequest,TResponse>:IPipelineBehavior<TRequest,TResponse>whereTRequest:notnull{publicasyncTask<TResponse>Handle(TRequestrequest,RequestHandlerDelegate<TResponse>next,CancellationTokenct){_logger.LogInformation("→ {Request}",typeof(TRequest).Name);varresult=awaitnext();_logger.LogInformation("← {Request}",typeof(TRequest).Name);returnresult;}}// After[Behavior(Order=0)]publicsealedclassLoggingBehavior<TCommand,TResult>:IPipelineBehavior<TCommand,TResult>{publicasyncTask<TResult>HandleAsync(TCommandcommand,Func<Task<TResult>>next,CancellationTokenct=default){_logger.LogInformation("→ {Command}",typeof(TCommand).Name);varresult=awaitnext();_logger.LogInformation("← {Command}",typeof(TCommand).Name);returnresult;}}// Beforebuilder.Services.AddMediatR(cfg =>cfg.RegisterServicesFromAssemblyContaining<Program>());// Afterbuilder.Services.AddAutoDispatch();// Before (IMediator)varorderId=awaitmediator.Send(newCreateOrderCommand(customerId),ct);// After (IDispatcher)varorderId=awaitdispatcher.SendAsync(newCreateOrderCommand(customerId),ct);Tip: Use the AutoDispatch Migrator Copilot agent to automate the migration across your entire codebase.
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
🌐 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. |
| 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 |
MIT