Stop Conflating CQRS and MediatR

“We need to implement CQRS? Great, let me install MediatR.” I have heard some version of this sentence in almost every .NET architecture discussion over the last few years, and I have said it myself more than once earlier in my career. It sounds harmless, but it hides a mistake that quietly shapes how teams design their applications.

Somewhere along the way, the .NET community fused two separate ideas into one. CQRS became shorthand for “install MediatR and create request/handler classes,” and MediatR became shorthand for “doing CQRS.” Neither statement is accurate, and treating them as the same thing has pushed a fair number of teams toward complexity they did not actually need. Some teams even avoid CQRS entirely because they assume it forces a messaging framework on them, which is also not true.

What CQRS Actually Means

CQRS stands for Command Query Responsibility Segregation, and at its core it is a simple architectural principle: the model you use to write data should not be the same model you use to read it. That is the whole idea. There is no mandated library, no required messaging pipeline, and no specific class structure attached to the definition.

This separation exists because read and write concerns genuinely pull in different directions in most non-trivial applications. A write operation has to enforce business rules, protect invariants, and keep the domain state consistent. A read operation usually just needs to assemble data from one or more sources and shape it for whatever is consuming it, often a UI screen or an API response. Forcing both concerns through a single model tends to produce a model that is decent at neither job.

  • Read and write models can each be optimised for their own purpose instead of compromising for the other
  • Read-side and write-side code can evolve independently as requirements change
  • Read and write paths can be scaled separately when load patterns differ
  • The boundary between domain logic and presentation concerns becomes explicit

None of this requires a mediator, a message bus, or even separate databases. A method that queries the database directly and returns a DTO, sitting next to a method that loads an aggregate and calls domain methods on it, already satisfies CQRS in its simplest form.

What MediatR Actually Does

MediatR is an implementation of the mediator pattern, nothing more and nothing less. Its job is to remove direct dependencies between components by routing communication through a single, central point. Instead of a controller holding a reference to five different services, it holds one dependency, IMediator, and sends a request through it.

The library gives you three practical capabilities: in-process request/response messaging, pipeline behaviours for cutting across concerns like logging or validation, and a publish/subscribe mechanism for notifications. Each of these is genuinely useful on its own, independent of any CQRS conversation.

The most common complaint about MediatR is the indirection it introduces. When a request and its handler live in different files, sometimes different folders, tracing what actually happens when you call Send() takes more clicks than it should, especially for someone new to the codebase. In practice, this is easy to fix by keeping the request record and its handler class in the same file. It costs nothing and it removes most of the navigation pain people associate with the library.

Why the Two Keep Showing Up Together

The pairing is not an accident. MediatR’s request/response model maps cleanly onto command/query separation. A command becomes an IRequest, a query becomes an IRequest, and the actual logic sits inside a matching handler. Here is what that looks like for a command that creates a habit record.

public record CreateHabit(string Name, string? Description, int Priority) : IRequest<HabitDto>;
 
public sealed class CreateHabitHandler(
    ApplicationDbContext dbContext,
    IValidator<CreateHabit> validator)
    : IRequestHandler<CreateHabit, HabitDto>
{
    public async Task<HabitDto> Handle(CreateHabit request, CancellationToken cancellationToken)
    {
        await validator.ValidateAndThrowAsync(request);
 
        Habit habit = request.ToEntity();
 
        dbContext.Habits.Add(habit);
 
        await dbContext.SaveChangesAsync(cancellationToken);
 
        return habit.ToDto();
    }
}

The record on top is the command itself, just a plain data carrier describing intent. The handler below does the real work: it validates the incoming request using FluentValidation, maps it to an entity, adds it to the DbContext, and saves the change. Call this through mediator.Send(new CreateHabit(…)) from a minimal API endpoint or a controller action, and you get back a HabitDto once the save succeeds. If validation fails, ValidateAndThrowAsync throws before the entity is ever touched, so a bad request never reaches the database.

Wiring commands and queries through MediatR this way buys you a few real advantages. Validation, logging, caching, and transaction handling can all live in pipeline behaviours instead of being repeated in every handler. Handlers are small and easy to unit test in isolation, since each one does exactly one thing. The downside is the extra abstraction: you are now writing a request class, a handler class, and registering both, for work that a single method could have done directly. For a small CRUD-heavy service, that overhead adds up fast without adding much value.

Implementing CQRS Without MediatR

CQRS does not need any of this machinery. You can define your own thin command and query handler interfaces and get the same separation of concerns with far less ceremony. Start with a plain interface for command handlers.

public interface ICommandHandler<in TCommand, TResult>
{
    Task<TResult> Handle(TCommand command, CancellationToken cancellationToken = default);
}
 
// IQueryHandler follows the same shape for reads
 
public record CreateOrderCommand(string CustomerId, List<OrderItem> Items)
    : ICommand<CreateOrderResult>;
 
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, CreateOrderResult>
{
    public async Task<CreateOrderResult> Handle(
        CreateOrderCommand command,
        CancellationToken cancellationToken = default)
    {
        // implementation goes here
    }
}
 
// Registration in Program.cs
builder.Services
    .AddScoped<ICommandHandler<CreateOrderCommand, CreateOrderResult>, CreateOrderCommandHandler>();

This is the same shape as the MediatR version, just without a library sitting between the caller and the handler. CreateOrderCommand carries the intent, CreateOrderCommandHandler carries the logic, and the interface gives you a contract you can mock in a unit test. Registration is a single AddScoped call per handler, which you can also generate through assembly scanning if you have many handlers and do not want to list each one by hand.

Using it from a controller looks like this.

[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public async Task<ActionResult<CreateOrderResult>> CreateOrder(
        CreateOrderCommand command,
        ICommandHandler<CreateOrderCommand, CreateOrderResult> handler)
    {
        var result = await handler.Handle(command);
 
        return Ok(result);
    }
}

ASP.NET Core resolves the handler as a method parameter through dependency injection, so the controller stays a thin adapter between HTTP and your application logic. The response is a plain CreateOrderResult wrapped in a 200 OK, with no mediator call in between. What you lose compared to the MediatR version is pipeline behaviours and automatic handler discovery. You have to inject the exact handler interface you need into each endpoint, which gets repetitive once you have thirty or forty commands and queries in a larger application, and cross-cutting concerns like validation have to be added manually inside each handler or through a decorator pattern around the interface.

Trade-offs Worth Thinking About Before You Choose

Neither approach is universally correct, and the right answer depends mostly on the size of your team and the number of commands and queries you expect to maintain. A small service with a handful of endpoints rarely benefits from MediatR’s ceremony, plain handler interfaces called directly from controllers will do the job with less code to read. A larger application with dozens of commands, shared validation rules, and a need for consistent logging or transaction handling across all of them tends to get real value from MediatR’s pipeline behaviours, because writing that plumbing by hand for every handler gets repetitive and error-prone.

There is also a licensing angle that did not exist when this discussion first came up. In July 2025, a few months after this article was originally written, Jimmy Bogard moved MediatR to a commercial license under his company Lucky Penny Software. Versions up to 12.x stay under the original open source license and continue to work as before, but new versions require a paid license once an organisation crosses a revenue threshold, or acceptance of a copyleft license below it. This does not make MediatR a bad choice, but it is now a genuine input into the decision, alongside team size and code complexity, especially for teams that were reaching for MediatR by default without weighing the alternative.

A mistake I see often is picking MediatR on a new project purely because it appeared in a course or a sample repository, without anyone on the team asking whether the indirection earns its keep. The opposite mistake also happens: teams avoid CQRS altogether because they assume it requires MediatR, a separate read database, and event sourcing, when in most cases a couple of plain handler classes would have solved the actual problem in an afternoon.

The Takeaway

CQRS and MediatR solve two different problems. CQRS is about separating how you read data from how you write it. MediatR is about decoupling components through a central mediator. They combine well because a command/query shape and a request/response library happen to fit together neatly, not because one requires the other.

Before reaching for either, it is worth asking what problem you are actually trying to solve. If your read and write concerns are genuinely pulling apart, use CQRS, with or without a mediator. If you are drowning in tangled dependencies between components, MediatR or a similar pattern can help, independent of whether you are doing CQRS at all. Sometimes you will want both, sometimes just one, and sometimes neither, and that judgment call is what separates thoughtful architecture from copying whatever the last course you watched used.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading