Learnixo
Back to blog
Backend Systemsintermediate

CQRS with MediatR in .NET: A Production Guide

Implement CQRS using MediatR in ASP.NET Core — commands, queries, handlers, pipeline behaviors — then learn how to build CQRS without MediatR using a hand-rolled dispatcher, and when each approach is the right call.

LearnixoApril 13, 202616 min read
.NETC#CQRSMediatRClean ArchitectureASP.NET CoreDispatcher Pattern
Share:𝕏

What is CQRS?

CQRS (Command Query Responsibility Segregation) separates operations that change state (commands) from operations that read state (queries).

Traditional:     OrderService.CreateOrder()   ← same service does everything
                 OrderService.GetOrders()

CQRS:            CreateOrderCommand  → CreateOrderCommandHandler
                 GetOrdersQuery      → GetOrdersQueryHandler

The benefits:

  • Each handler has one job — easy to test, easy to reason about
  • Commands and queries can scale independently (separate read/write DBs if needed)
  • Pipeline behaviors give you cross-cutting concerns (validation, logging, caching) without touching handlers
  • Clean separation maps perfectly onto Clean Architecture's Application layer

A Note on MediatR Licensing

MediatR 12.x and below is MIT-licensed and free forever — you can freeze on it. MediatR 13+ (released July 2, 2025) switched to a commercial dual-license model, requiring a paid licence for production use. Your options:

  • Freeze on MediatR 12.x — MIT, fully functional, no commercial obligation
  • Pay for MediatR 13+ — commercial licence, ongoing vendor updates
  • Build your own dispatcher — ~100 lines, zero dependencies, can be faster than MediatR (covered below)

The rest of this section uses MediatR 12.x. If you are on 13+, the API is the same.


Setting Up MediatR

Bash
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection  # MediatR < 12
# MediatR 12+ includes DI support natively

Register in Program.cs:

C#
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)
);

For multi-project solutions (e.g., Application layer in a separate project):

C#
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly)
);

Commands

A command represents an intent to change state. It returns either nothing or a minimal result (like a created ID).

Defining a Command

C#
// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(
    int CustomerId,
    List<OrderItemDto> Items,
    string DeliveryAddress
) : IRequest<int>;  // returns the new order's ID

public record OrderItemDto(int ProductId, int Quantity, decimal UnitPrice);

Command Handler

C#
// Application/Orders/Commands/CreateOrder/CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
    private readonly IAppDbContext _db;
    private readonly ICurrentUserService _currentUser;
    private readonly IDateTime _dateTime;

    public CreateOrderCommandHandler(
        IAppDbContext db,
        ICurrentUserService currentUser,
        IDateTime dateTime)
    {
        _db = db;
        _currentUser = currentUser;
        _dateTime = dateTime;
    }

    public async Task<int> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        // Validate business rules
        var customer = await _db.Customers.FindAsync(request.CustomerId, ct)
            ?? throw new NotFoundException(nameof(Customer), request.CustomerId);

        // Build the aggregate
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Reference = GenerateReference(),
            Status = OrderStatus.Pending,
            DeliveryAddress = request.DeliveryAddress,
            CreatedAt = _dateTime.UtcNow,
            CreatedBy = _currentUser.UserId,
            Items = request.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice,
            }).ToList()
        };

        order.TotalAmount = order.Items.Sum(i => i.Quantity * i.UnitPrice);

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);

        return order.Id;
    }

    private static string GenerateReference()
        => $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString()[..6].ToUpper()}";
}

Controller — sending the command

C#
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator) => _mediator = mediator;

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderCommand command, CancellationToken ct)
    {
        var orderId = await _mediator.Send(command, ct);
        return CreatedAtAction(nameof(GetById), new { id = orderId }, new { id = orderId });
    }
}

Queries

A query reads state and returns data. It should never modify state.

Defining a Query + DTO

C#
// Application/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs
public record GetOrderByIdQuery(int OrderId) : IRequest<OrderDetailDto>;

public record OrderDetailDto(
    int Id,
    string Reference,
    string Status,
    decimal TotalAmount,
    string CustomerName,
    List<OrderItemDetailDto> Items
);

public record OrderItemDetailDto(
    string ProductName,
    int Quantity,
    decimal UnitPrice,
    decimal LineTotal
);

Query Handler

C#
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDetailDto>
{
    private readonly IAppDbContext _db;

    public GetOrderByIdQueryHandler(IAppDbContext db) => _db = db;

    public async Task<OrderDetailDto> Handle(GetOrderByIdQuery request, CancellationToken ct)
    {
        var order = await _db.Orders
            .AsNoTracking()
            .Include(o => o.Customer)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .FirstOrDefaultAsync(o => o.Id == request.OrderId, ct)
            ?? throw new NotFoundException(nameof(Order), request.OrderId);

        return new OrderDetailDto(
            order.Id,
            order.Reference,
            order.Status.ToString(),
            order.TotalAmount,
            order.Customer.Name,
            order.Items.Select(i => new OrderItemDetailDto(
                i.Product.Name,
                i.Quantity,
                i.UnitPrice,
                i.Quantity * i.UnitPrice
            )).ToList()
        );
    }
}

Pipeline Behaviors

Pipeline behaviors wrap every IMediator.Send() call — like middleware for your handlers. They run before and after the handler.

IMediator.Send(command)
    → ValidationBehavior
        → LoggingBehavior
            → PerformanceBehavior
                → YourHandler.Handle()

Validation Behavior (with FluentValidation)

Bash
dotnet add package FluentValidation.DependencyInjectionExtensions
C#
// Application/Common/Behaviours/ValidationBehaviour.cs
public class ValidationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);

        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

Command validator:

C#
// Application/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(c => c.CustomerId).GreaterThan(0);
        RuleFor(c => c.Items).NotEmpty().WithMessage("Order must have at least one item.");
        RuleForEach(c => c.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.Quantity).GreaterThan(0);
            item.RuleFor(i => i.UnitPrice).GreaterThan(0);
            item.RuleFor(i => i.ProductId).GreaterThan(0);
        });
        RuleFor(c => c.DeliveryAddress).NotEmpty().MaximumLength(500);
    }
}

Logging Behavior

C#
public class LoggingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;
    private readonly ICurrentUserService _currentUser;

    public LoggingBehaviour(
        ILogger<LoggingBehaviour<TRequest, TResponse>> logger,
        ICurrentUserService currentUser)
    {
        _logger = logger;
        _currentUser = currentUser;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        var userId = _currentUser.UserId ?? "anonymous";

        _logger.LogInformation(
            "Handling {RequestName} for user {UserId}: {@Request}",
            requestName, userId, request);

        var response = await next();

        _logger.LogInformation("Handled {RequestName}", requestName);

        return response;
    }
}

Performance Behavior

C#
public class PerformanceBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly Stopwatch _timer = new();
    private readonly ILogger<PerformanceBehaviour<TRequest, TResponse>> _logger;

    public PerformanceBehaviour(ILogger<PerformanceBehaviour<TRequest, TResponse>> logger)
        => _logger = logger;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        _timer.Restart();
        var response = await next();
        _timer.Stop();

        var elapsed = _timer.ElapsedMilliseconds;

        if (elapsed > 500)
        {
            _logger.LogWarning(
                "Slow request: {RequestName} ({Elapsed}ms) {@Request}",
                typeof(TRequest).Name, elapsed, request);
        }

        return response;
    }
}

Register Behaviors

C#
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>));
});

// FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);

Notifications (Events)

MediatR also handles domain events via INotification. One event, multiple handlers.

C#
// Define
public record OrderCreatedNotification(int OrderId, int CustomerId) : INotification;

// Handler 1: Send confirmation email
public class SendOrderConfirmationEmailHandler
    : INotificationHandler<OrderCreatedNotification>
{
    private readonly IEmailService _email;
    public SendOrderConfirmationEmailHandler(IEmailService email) => _email = email;

    public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
    {
        await _email.SendOrderConfirmationAsync(notification.CustomerId, notification.OrderId, ct);
    }
}

// Handler 2: Update inventory
public class ReserveInventoryHandler : INotificationHandler<OrderCreatedNotification>
{
    public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
    {
        // reduce stock levels...
    }
}

Publish from the command handler:

C#
await _db.SaveChangesAsync(ct);

// After successful save, publish the event
await _mediator.Publish(new OrderCreatedNotification(order.Id, order.CustomerId), ct);

return order.Id;

Folder Structure

src/
└── Application/
    ├── Common/
    │   ├── Behaviours/
    │   │   ├── ValidationBehaviour.cs
    │   │   ├── LoggingBehaviour.cs
    │   │   └── PerformanceBehaviour.cs
    │   ├── Exceptions/
    │   │   ├── NotFoundException.cs
    │   │   └── ValidationException.cs
    │   └── Interfaces/
    │       ├── IAppDbContext.cs
    │       ├── ICurrentUserService.cs
    │       └── IEmailService.cs
    └── Orders/
        ├── Commands/
        │   ├── CreateOrder/
        │   │   ├── CreateOrderCommand.cs
        │   │   ├── CreateOrderCommandHandler.cs
        │   │   └── CreateOrderCommandValidator.cs
        │   └── DeleteOrder/
        │       ├── DeleteOrderCommand.cs
        │       └── DeleteOrderCommandHandler.cs
        └── Queries/
            ├── GetOrderById/
            │   ├── GetOrderByIdQuery.cs
            │   └── GetOrderByIdQueryHandler.cs
            └── GetOrdersList/
                ├── GetOrdersListQuery.cs
                └── GetOrdersListQueryHandler.cs

Each feature is fully self-contained. A new developer can navigate to Orders/Commands/CreateOrder and read the entire feature in three files.


Testing a Handler

Handlers are plain classes — no HTTP, no framework wiring:

C#
public class CreateOrderCommandHandlerTests
{
    [Fact]
    public async Task Handle_ValidCommand_ReturnsNewOrderId()
    {
        // Arrange
        var db = new AppDbContextFactory().CreateInMemory();
        db.Customers.Add(new Customer { Id = 1, Name = "Alice" });
        await db.SaveChangesAsync();

        var handler = new CreateOrderCommandHandler(
            db,
            new FakeCurrentUserService("user-1"),
            new FakeDateTime(DateTime.UtcNow)
        );

        var command = new CreateOrderCommand(
            CustomerId: 1,
            Items: [new(ProductId: 10, Quantity: 2, UnitPrice: 25m)],
            DeliveryAddress: "123 Main St"
        );

        // Act
        var orderId = await handler.Handle(command, CancellationToken.None);

        // Assert
        orderId.Should().BeGreaterThan(0);
        var order = await db.Orders.FindAsync(orderId);
        order.Should().NotBeNull();
        order!.TotalAmount.Should().Be(50m);
    }
}

Key Takeaways

  • Commands change state, queries read it — they never mix
  • Each IRequestHandler<TRequest, TResponse> does exactly one thing — no bloated service classes
  • Pipeline behaviors are the right place for validation, logging, caching, and transaction management — not inside handlers
  • INotification + INotificationHandler replace method calls for domain events, enabling multiple decoupled side effects per event
  • Handlers are pure classes — test them directly without ASP.NET Core or any HTTP wiring

CQRS Without MediatR

MediatR is a convenience library — it's not CQRS. CQRS is a pattern, and you can implement it with nothing but interfaces and .NET's built-in DI container. This is worth knowing because:

  • MediatR 13+ is now commercial — you may want to avoid the dependency
  • You want full control over dispatch without a third-party runtime
  • You're building a library or a small service where MediatR is overkill
  • You're on a hot path where every nanosecond counts

Performance Benchmarks First

Before picking an implementation, see what the numbers actually say (.NET 10):

| Approach | Mean | vs Raw Call | Allocations | |----------|------|------------|-------------| | Raw method call | 0.054 ns | 1× baseline | 0 B | | FrozenDictionary dispatcher | 11.5 ns | 214× | 24 B | | MediatR 12.4.1 | 50.4 ns | 942× | 200 B | | Naive reflection dispatcher | 148.5 ns | 2,776× | 288 B |

Key finding: The FrozenDictionary approach is 4.4× faster than MediatR and uses 8.3× less memory per call. The naive dynamic / .MakeGenericType() dispatcher seen in most tutorials is actually 2.9× slower than MediatR — precisely what you're trying to escape.

The trick is to do all reflection at startup (building the dictionary) and eliminate it entirely from the hot dispatch path.

The Core Interfaces

You only need a few interfaces to express the pattern. Use ValueTask<T> (not Task<T>) for better performance on the hot path:

C#
// A command or query — marker interface
public interface IRequest<TResponse> { }

// A handler that processes it — returns ValueTask for hot-path efficiency
public interface IRequestHandler<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    ValueTask<TResponse> Handle(TRequest request, CancellationToken ct = default);
}

// Void command (no return value)
public interface ICommand : IRequest<Unit> { }
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Unit>
    where TCommand : ICommand { }

// Read-only query
public interface IQuery<TResponse> : IRequest<TResponse> { }
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, TResponse>
    where TQuery : IQuery<TResponse> { }

// Unit type for void commands
public readonly struct Unit
{
    public static readonly Unit Value = new();
}

// Dispatcher interface
public interface ISender
{
    ValueTask<TResponse> SendAsync<TResponse>(IRequest<TResponse> request, CancellationToken ct = default);
}

The FrozenDictionary Dispatcher

The key insight: reflect once at startup, never at dispatch time.

C#
// Step 1 — a non-generic base so we can store wrappers in the dictionary
public abstract class RequestHandlerWrapper
{
    public abstract ValueTask<object?> HandleAsync(
        object request, IServiceProvider sp, CancellationToken ct);
}

// Step 2 — a typed wrapper that knows TRequest and TResponse at compile time
// Handle() is a direct virtual call — zero reflection
public sealed class RequestHandlerWrapper<TRequest, TResponse> : RequestHandlerWrapper
    where TRequest : IRequest<TResponse>
{
    public override async ValueTask<object?> HandleAsync(
        object request, IServiceProvider sp, CancellationToken ct)
    {
        var handler = sp.GetRequiredService<IRequestHandler<TRequest, TResponse>>();
        return await handler.Handle((TRequest)request, ct);
    }
}

// Step 3 — the dispatcher: one FrozenDictionary lookup per call
public sealed class Dispatcher : ISender
{
    private readonly FrozenDictionary<Type, RequestHandlerWrapper> _wrappers;
    private readonly IServiceProvider _sp;

    public Dispatcher(
        IServiceProvider sp,
        FrozenDictionary<Type, RequestHandlerWrapper> wrappers)
    {
        _sp = sp;
        _wrappers = wrappers;
    }

    public async ValueTask<TResponse> SendAsync<TResponse>(
        IRequest<TResponse> request, CancellationToken ct = default)
    {
        if (!_wrappers.TryGetValue(request.GetType(), out var wrapper))
            throw new InvalidOperationException(
                $"No handler registered for {request.GetType().Name}.");

        return (TResponse)(await wrapper.HandleAsync(request, _sp, ct))!;
    }
}

Registration

C#
// Build the FrozenDictionary ONCE at startup — reflection here is fine
var assembly = typeof(Program).Assembly;

var wrappers = assembly.GetTypes()
    .SelectMany(t => t.GetInterfaces()
        .Where(i => i.IsGenericType &&
                    i.GetGenericTypeDefinition() == typeof(IRequest<>))
        .Select(i => new
        {
            RequestType  = t,
            ResponseType = i.GetGenericArguments()[0],
        }))
    .ToDictionary(
        x => x.RequestType,
        x => (RequestHandlerWrapper)Activator.CreateInstance(
            typeof(RequestHandlerWrapper<,>)
                .MakeGenericType(x.RequestType, x.ResponseType))!)
    .ToFrozenDictionary();

builder.Services.AddSingleton(wrappers);   // singleton — built once
builder.Services.AddScoped<ISender, Dispatcher>();

// Register all handlers with Scrutor
builder.Services.Scan(scan => scan
    .FromAssemblyOf<Program>()
    .AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Commands and Queries — identical shape to MediatR

C#
// Command
public record CreateOrderCommand(
    int CustomerId,
    List<OrderItemDto> Items,
    string DeliveryAddress
) : ICommand;

// Handler
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
    private readonly IAppDbContext _db;
    public CreateOrderCommandHandler(IAppDbContext db) => _db = db;

    public async ValueTask<Unit> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        // exactly the same logic as the MediatR version
        var order = new Order { /* ... */ };
        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);
        return Unit.Value;
    }
}

// Query
public record GetOrderByIdQuery(int OrderId) : IQuery<OrderDetailDto>;

public class GetOrderByIdQueryHandler : IQueryHandler<GetOrderByIdQuery, OrderDetailDto>
{
    private readonly IAppDbContext _db;
    public GetOrderByIdQueryHandler(IAppDbContext db) => _db = db;

    public async ValueTask<OrderDetailDto> Handle(GetOrderByIdQuery query, CancellationToken ct)
    {
        var order = await _db.Orders
            .AsNoTracking()
            .Include(o => o.Customer)
            .FirstOrDefaultAsync(o => o.Id == query.OrderId, ct)
            ?? throw new NotFoundException(query.OrderId);

        return new OrderDetailDto(order.Id, order.Reference, /* ... */);
    }
}

Controller

C#
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly ISender _sender;
    public OrdersController(ISender sender) => _sender = sender;

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderCommand command, CancellationToken ct)
    {
        await _sender.SendAsync(command, ct);
        return Ok();
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id, CancellationToken ct)
    {
        var result = await _sender.SendAsync(new GetOrderByIdQuery(id), ct);
        return Ok(result);
    }
}

Adding Pipeline Behaviors (Without MediatR)

The dispatcher above doesn't have a pipeline. Add one using a decorator pattern — each decorator wraps the inner handler and adds cross-cutting logic:

C#
// Validation decorator — wraps any handler, runs FluentValidation before the handler
public class ValidationHandlerDecorator<TRequest, TResponse>
    : IRequestHandler<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IRequestHandler<TRequest, TResponse> _inner;
    private readonly IEnumerable<IValidator<TRequest>>    _validators;

    public ValidationHandlerDecorator(
        IRequestHandler<TRequest, TResponse> inner,
        IEnumerable<IValidator<TRequest>> validators)
    {
        _inner      = inner;
        _validators = validators;
    }

    public async ValueTask<TResponse> Handle(TRequest request, CancellationToken ct)
    {
        var failures = _validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await _inner.Handle(request, ct);
    }
}

You can chain decorators in any order. Scrutor's .Decorate() applies them in reverse registration order (last registered = outermost):

C#
// First: register all handlers
builder.Services.Scan(scan => scan
    .FromAssemblyOf<Program>()
    .AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Then: wrap every handler — outermost first
builder.Services.Decorate(typeof(IRequestHandler<,>), typeof(LoggingHandlerDecorator<,>));
builder.Services.Decorate(typeof(IRequestHandler<,>), typeof(ValidationHandlerDecorator<,>));
// Execution order: ValidationDecorator → LoggingDecorator → Handler

The result is identical to MediatR's IPipelineBehavior — every handler gets validation and logging automatically, zero reflection overhead at dispatch time.


When to Use MediatR vs Roll Your Own

This is about cost — not just money, but complexity budget too. Ask these questions:

Use MediatR when…

✅ Medium-to-large application (10+ features, 3+ developers)
✅ You want pipeline behaviors out of the box (less boilerplate)
✅ Your team already knows MediatR — zero learning curve
✅ You use the notifications (INotification) pattern for domain events
✅ You want the Scrutor-free, convention-based handler discovery

MediatR costs: ~25KB NuGet package, uses reflection at dispatch time (negligible for APIs), one extra abstraction layer.

Skip MediatR (roll your own) when…

✅ Small service (< 10 features, solo or small team)
✅ You want to avoid all third-party runtime dependencies (library project, open-source SDK)
✅ You need maximum performance — hot path with thousands of req/s where reflection matters
✅ You want full control over dispatch logic (e.g. custom retry, telemetry wiring)
✅ Your company has a strict approved-dependencies list

The hand-rolled dispatcher above is ~30 lines of code. The only thing you lose compared to MediatR is:

  • Auto-discovery via RegisterServicesFromAssembly (replace with Scrutor or manual registration)
  • Built-in INotification fan-out (add your own event bus or use the decorator pattern)
  • The IPipelineBehavior ordering syntax (replace with .Decorate() calls)

The Decision Table

MediatR 12.x is MIT-licensed and still available. MediatR 13+ requires a commercial licence. The cost comparison now looks different:

| Factor | MediatR 12.x (frozen) | MediatR 13+ (commercial) | FrozenDictionary dispatcher | |--------|----------------------|--------------------------|----------------------------| | Licence | MIT, free forever | Commercial, paid | MIT (yours) | | Setup time | 5 min | 5 min | ~1 hour | | Dispatch speed | 50 ns | 50 ns | 11.5 ns (4.4× faster) | | Memory per call | 200 B | 200 B | 24 B (8.3× less) | | Package risk | Frozen, no updates | Ongoing vendor dependency | Zero — you own it | | Learning curve | Low (widely known) | Low | Low (simpler interfaces) | | Pipeline ordering | Config-based | Config-based | Explicit .Decorate() |

Practical rule of thumb:

  • Startup / MVP / solo: skip MediatR, use the FrozenDictionary dispatcher — ~1 hour of setup, zero ongoing risk
  • Team product (3+ devs): MediatR 12.x frozen on MIT — shared vocabulary, no licence cost
  • High-frequency service (50k+ req/s, background job processor): FrozenDictionary dispatcher — the 4.4× speed advantage matters at this scale
  • Open-source library: hand-roll always — never take runtime dependencies you don't control

Side-by-Side: Same Feature, Both Approaches

C#
// ── With MediatR 12.x ─────────────────────────────────────────
// Registration:
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

// Dispatch:
var result = await _mediator.Send(new GetOrderByIdQuery(id), ct);

// ── With FrozenDictionary Dispatcher ──────────────────────────
// Registration:
var wrappers = /* assembly scan → FrozenDictionary (see above) */;
builder.Services.AddSingleton(wrappers);
builder.Services.AddScoped<ISender, Dispatcher>();
builder.Services.Scan(scan => scan
    .FromAssemblyOf<Program>()
    .AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Dispatch — ValueTask, no allocations on the hot path:
var result = await _sender.SendAsync(new GetOrderByIdQuery(id), ct);

The calling code is near-identical. Your domain logic, tests, and folder structure stay exactly the same either way. The only difference is dispatch speed and dependency footprint.

Alternatives Worth Knowing

If you want a well-maintained open-source dispatcher without MediatR's commercial licence:

| Library | Approach | Licence | Notes | |---------|----------|---------|-------| | martinothamar/Mediator | Source generator (zero runtime reflection) | MIT | Full AOT support, fastest option | | SwitchMediator | Near drop-in MediatR replacement | MIT | Simplest migration path | | Wolverine | Messaging + in-process + sagas | MIT | More than a dispatcher — includes outbox | | FastEndpoints | Endpoint-as-class (skips dispatcher entirely) | MIT | Great for APIs, no CQRS overhead |


Updated Key Takeaways

  • CQRS is a pattern, not a library — you can implement it with ~100 lines of DI wiring
  • MediatR 13+ is now commercial — MediatR 12.x (MIT) or a hand-rolled dispatcher are the free options
  • Naive dynamic/reflection dispatcher (common in tutorials) is 2.9× slower than MediatR — avoid it
  • FrozenDictionary dispatcher does all reflection at startup and is 4.4× faster than MediatR (11.5 ns, 24 B per call)
  • Use ValueTask<T> (not Task<T>) in your hand-rolled handlers for better hot-path performance
  • Decorator pattern (Scrutor.Decorate) gives you pipeline behaviors without MediatR
  • Commands, queries, handlers, and tests look identical regardless of which dispatcher you choose — the abstraction is fully swappable

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.