All writing

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