Skip to content

Repository files navigation

AutoDispatch.Generator

NuGetNuGet DownloadsCILicense: MIT.NET 10 Ready

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; no IPipelineBehavior<,> magic at runtime
  • No marker interfaces — commands stay as plain POCOs
  • AOT-friendly — everything is compile-time generated
  • DI-readyAddAutoDispatch() wires up handlers, behaviors, and IDispatcher

Installation

dotnet add package AutoDispatch.Generator

Then register the generated dispatcher:

builder.Services.AddAutoDispatch();

Before vs After

MediatR-style boilerplate

usingMediatR;publicsealedrecordCreateOrderCommand(stringCustomerId):IRequest<OrderId>;publicsealedclassCreateOrderHandler:IRequestHandler<CreateOrderCommand,OrderId>{publicTask<OrderId>Handle(CreateOrderCommandrequest,CancellationTokencancellationToken){// ...}}

AutoDispatch

usingAutoDispatch;publicsealedrecordCreateOrderCommand(stringCustomerId);[Handler]publicsealedclassCreateOrderHandler{publicTask<OrderId>HandleAsync(CreateOrderCommandcommand,CancellationTokenct=default){// ...}}

What gets generated

Given one or more [Handler] classes, AutoDispatch emits:

  1. AutoDispatch.HandlerAttribute
  2. AutoDispatch.IDispatcher
  3. AutoDispatch.Dispatcher
  4. AddAutoDispatch() for IServiceCollection

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);}}

Conventions

AutoDispatch discovers public instance non-static methods on classes marked with [Handler].

Supported signatures:

Handler methodGenerated 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 Handle or HandleAsync
  • Handle must have exactly one command parameter
  • HandleAsync may have one command parameter, or a second CancellationToken
  • Methods with zero parameters or more than two parameters are ignored
  • Dispatcher is generated as internal sealed
  • AddAutoDispatch() registers handlers with AddScoped

Semantic aliases

[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.

Usage

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);});

Generated DI registration

builder.Services.AddAutoDispatch();

Produces code like:

services.AddScoped<CreateOrderHandler>();services.AddScoped<DeleteOrderHandler>();services.AddScoped<AutoDispatch.IDispatcher,AutoDispatch.Dispatcher>();

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

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.

Multiple behaviors

[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.

What gets generated

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();}

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.

Diagnostics

CodeSeverityDescription
AD001Warning[Handler] on a class with no valid Handle/HandleAsync methods
AD002ErrorDuplicate handlers discovered for the same command type
AD003WarningHandleAsync does not accept CancellationToken
AD004Error[Behavior] type is not a public, non-abstract open generic class with exactly two type parameters
AD005Error[Behavior] type does not implement IPipelineBehavior<TCommand, TResult>
AD006Error[Behavior] type does not expose a valid public HandleAsync method

AD001

[Handler] on '{Type}' has no Handle or HandleAsync methods. 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

HandleAsync on '{Handler}' for command '{Command}' is missing a CancellationToken parameter. Consider adding CancellationToken ct = default as 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 implement AutoDispatch.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 declare public 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.

XML doc comments and pipeline readability

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){
...}

IDE code fixes

AutoDispatch.CodeFixes ships inside the AutoDispatch.Generator package and adds one-click fixes:

DiagnosticQuick fix
AD001Adds a HandleAsync stub method to a [Handler] class with none
AD003Adds the missing CancellationToken ct = default parameter

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 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.

Scaffolding with dotnet new

dotnet new install AutoDispatch.Templates
dotnet new autodispatch-handler -n CreateOrder --namespace MyApp.Orders

Generates a ready-to-fill CreateOrderCommand.cs with the command record and [Handler] class.

AutoDispatch vs alternatives

ApproachBoilerplateRuntime dispatchPipeline behaviorsCompile-time safetyAOT
AutoDispatchLowNoneCompile-time generatedHigh
MediatRMediumYesRuntime reflectionHigh⚠️
Raw service callsLowNoneManualHigh

Benchmarks

BenchmarkDotNet results for a single no-op handler, comparing the generated IDispatcher against MediatR's IMediator:

MethodMeanRatioAllocatedAlloc Ratio
AutoDispatch_SendAsync23.42 ns1.0096 B1.00
MediatR_Send89.13 ns3.85288 B3.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.

Migrating from MediatR

AutoDispatch follows the same CQRS mental model as MediatR, so migration is mechanical:

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

// BeforepublicsealedrecordCreateOrderCommand(stringCustomerId):IRequest<OrderId>;// AfterpublicsealedrecordCreateOrderCommand(stringCustomerId);

3. Convert handler classes

// 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()));}

4. Convert pipeline behaviors

// 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;}}

5. Update DI registration

// Beforebuilder.Services.AddMediatR(cfg =>cfg.RegisterServicesFromAssemblyContaining<Program>());// Afterbuilder.Services.AddAutoDispatch();

6. Update dispatch call sites

// 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.

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

PackageDescription
AutoWireCompile-time DI auto-registration for Microsoft.Extensions.DependencyInjection.
AutoMap.GeneratorCompile-time object mapping with generated extension methods.
AutoValidate.GeneratorCompile-time validator discovery and registration.
AutoResult.GeneratorCompile-time result helpers and Try*() wrappers.
AutoQuery.GeneratorCompile-time query specifications for LINQ-based filtering.
AutoLog.GeneratorCompile-time high-performance logging — [Log(Level, Message)] on a partial method generates LoggerMessage.Define. AOT-safe.
AutoHttpClient.GeneratorCompile-time typed HTTP client — [HttpClient] on an interface generates a strongly-typed client. AOT-safe Refit alternative.

Related Packages

PackageDownloadsDescription
AutoWireDownloadsCompile-time dependency injection auto-registration for
AutoMap.GeneratorDownloadsCompile-time object mapping for
AutoQuery.GeneratorDownloadsCompile-time query composition for IQueryable using Roslyn incremental source generators
AutoArchitectureDownloadsCompile-time architecture/dependency-rule enforcement for
AutoHttpClient.GeneratorDownloadsCompile-time typed HTTP client generation for
AutoLog.GeneratorDownloadsCompile-time high-performance logging for
AutoValidate.GeneratorDownloadsCompile-time FluentValidation wiring for

License

MIT

About

Compile-time CQRS dispatcher for .NET — zero reflection, AOT-friendly MediatR alternative via Roslyn source generators

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages