A CQRS dispatcher in about 80 lines, without MediatR
The small, dependency-free command/query dispatcher I use in Clean Architecture .NET services, and its trade-offs.
In a Clean Architecture service, the API layer should do one thing: turn an HTTP request into a command or query and hand it off. MediatR made that pattern popular, but it's now commercially licensed (see licence landmines). The core of what most services use from it fits in about 80 lines.
The contracts
public interface ICommand<out TResponse>;
public interface ICommand : ICommand<Unit>;
public interface IQuery<out TResponse>;
public interface ICommandHandler<in TCommand, TResponse> where TCommand : ICommand<TResponse>
{
Task<TResponse> Handle(TCommand command, CancellationToken cancellationToken);
}
public interface IQueryHandler<in TQuery, TResponse> where TQuery : IQuery<TResponse>
{
Task<TResponse> Handle(TQuery query, CancellationToken cancellationToken);
}
public readonly record struct Unit
{
public static readonly Unit Value = default;
}Commands and queries are separate types on purpose. A reviewer can tell from the signature alone whether a call changes state.
The dispatcher
public interface IDispatcher
{
Task<TResponse> Send<TResponse>(ICommand<TResponse> command, CancellationToken ct = default);
Task<TResponse> Query<TResponse>(IQuery<TResponse> query, CancellationToken ct = default);
}
public sealed class Dispatcher(IServiceProvider services) : IDispatcher
{
public Task<TResponse> Send<TResponse>(ICommand<TResponse> command, CancellationToken ct = default)
{
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(command.GetType(), typeof(TResponse));
var handler = services.GetService(handlerType)
?? throw new InvalidOperationException($"No command handler registered for {command.GetType().Name}.");
var method = handlerType.GetMethod("Handle")!;
return (Task<TResponse>)method.Invoke(handler, [command, ct])!;
}
// Query<TResponse> is the same shape, using IQueryHandler<,>.
}Registration
One extension method scans an assembly and registers every handler:
public static IServiceCollection AddCqrs(this IServiceCollection services, params Assembly[] assemblies)
{
services.AddScoped<IDispatcher, Dispatcher>();
var open = new[] { typeof(ICommandHandler<,>), typeof(IQueryHandler<,>) };
foreach (var type in assemblies.SelectMany(a => a.GetTypes()).Where(t => t is { IsClass: true, IsAbstract: false }))
foreach (var iface in type.GetInterfaces().Where(i => i.IsGenericType && open.Contains(i.GetGenericTypeDefinition())))
services.AddScoped(iface, type);
return services;
}An endpoint then stays thin:
app.MapPost("/orders", async (CreateOrder cmd, IDispatcher dispatcher, CancellationToken ct) =>
Results.Ok(await dispatcher.Send(cmd, ct)));Trade-offs
- Reflection on every call. Fine for typical request rates. If it ever shows up in a profile, cache the
MethodInfoper type, or generate the dispatch with a source generator. - No pipeline behaviours. Cross-cutting concerns (validation, logging, transactions) go in decorators or endpoint filters instead. I find these easier to follow than a behaviour chain anyway.
- No notifications. Domain events go over the message bus (MassTransit with an outbox), not through the dispatcher.
- Easy to swap out. The interfaces deliberately mirror MediatR's shape. If a team later wants MediatR, Wolverine or a source-generated mediator, the migration is mechanical.
Not every service needs this. Simple CRUD services in the same system use plain Web API controllers or Minimal APIs. The dispatcher is for domains complex enough to earn the extra layer.