You’ve probably seen it in many “clean architecture” diagrams: arrows pointing from Controllers to Handlers, from Handlers to Repositories, and this thing in the middle....MediatR. Paired with a buzzing acronym CQRS (Command Query Responsibility Segregation), it can feel like you’re building the next NASA control system when you just want to create a To-Do app.
But before you throw it all away as overengineering, let’s actually break it down. Because when used correctly, MediatR + CQRS can give your code: clear separation, testability, and fewer "what does this method even do?" moments.
What Is MediatR?
MediatR is a simple in-process messaging library for .NET, created by Jimmy Bogard. It helps you decouple your logic by acting as a mediator (get it?) between the request and the handler. Instead of calling a service directly, you send a request and let the handler handle it.
So instead of:
var result = _orderService.PlaceOrder(request);
You do:
var result = await _mediator.Send(new PlaceOrderCommand(request));
The MediatR tells the right handler to take over.
What Is CQRS?
CQRS stands for Command Query Responsibility Segregation. In simple terms: separate your read logic from your write logic. That’s it.
Commands mutate state. (PlaceOrderCommand, DeleteUserCommand)
Queries return data. (GetOrderByIdQuery, GetUsersListQuery)
In traditional CRUD apps, you have services like:
UserService.CreateUser();
UserService.GetUsers();
And when paired with MediatR, you get a smooth flow:
await _mediator.Send(new RegisterUserCommand(...));
var users = await _mediator.Send(new GetUsersQuery());
The benefit? You can optimize them independently. Queries can hit fast read replicas or return DTOs tailored for UI. Commands can enforce strict validation and business rules.
MediatR + CQRS
These two patterns play really well together. MediatR routes your commands and queries to the right handler. You end up with a codebase where:
- The controller doesn’t know the details of business logic.
- The logic is easily testable (you just test the handler).
- The code is predictable and scalable.
But Do You Need It?
Here’s the catch: this combo isn’t free. You’ll be creating lots of files - commands, queries, handlers. Don’t use CQRS and MediatR unless you really need them.
When it makes sense:
- You have complex domains with deep business logic.
- You need clear audit trails, validations, and handler pipelines.
- Your system is growing fast, and maintainability is key.
- You’re writing unit-test-heavy applications and value isolation.
When it’s overkill:
- It’s a simple CRUD app.
- You have a tight deadline and limited devs.
- You’re building a proof of concept or internal tool.
There’s no shame in keeping things simple.
Creating a Simple Project
Let’s walk through how to structure a simple .NET Web API project using CQRS with MediatR. We'll build a small app that manages Pokémon data.
Model
We start with a basic model to represent Pokémon data:
public class PokemonModel
{
public int Id { get; set; }
public string Name { get; set; }
public string Generation { get; set; }
}
Data Access
We create an interface and its implementation to simulate a data store. This acts as our data access abstraction:
public interface IDemoDataAccess
{
List<PokemonModel> GetPokemons();
PokemonModel InsertPokemon(string name, string generation);
}
public class DemoDataAccess : IDemoDataAccess
{
private List<PokemonModel> pokemons = new()
{
new PokemonModel { Id = 1, Name = "Bulbasaur", Generation = "Kanto" },
new PokemonModel { Id = 2, Name = "Azurill", Generation = "Hoenn" }
};
public List<PokemonModel> GetPokemons() => pokemons;
public PokemonModel InsertPokemon(string name, string generation)
{
int newId = pokemons.Max(p => p.Id) + 1;
var pokemon = new PokemonModel { Id = newId, Name = name, Generation = generation };
pokemons.Add(pokemon);
return pokemon;
}
}
Queries
In CQRS, we separate reads (queries) from writes (commands).
And Queries are used for reading data only — no changes allowed.
public record GetPokemonListQuery : IRequest<List<PokemonModel>> { }
public record GetPokemonByIdQuery(int id) : IRequest<PokemonModel> { }
Commands
Used for write operations like creating or updating data.
public record InsertPokemonCommand(string Name, string Generation) : IRequest<PokemonModel> { }
Handlers
Handlers are where the real logic happens. MediatR finds the right handler based on the request sent.
Get List
public class GetPokemonListHandler : IRequestHandler<GetPokemonListQuery, List<PokemonModel>>
{
private readonly IDemoDataAccess _data;
public GetPokemonListHandler(IDemoDataAccess data)
{
_data = data;
}
public Task<List<PokemonModel>> Handle(GetPokemonListQuery request, CancellationToken cancellationToken)
{
return Task.FromResult(_data.GetPokemons());
}
}
Get By Id
public class GetPokemonByIdHandler : IRequestHandler<GetPokemonByIdQuery, PokemonModel>
{
private readonly IMediator _mediator;
public GetPokemonByIdHandler(IMediator mediator)
{
_mediator = mediator;
}
public async Task<PokemonModel> Handle(GetPokemonByIdQuery request, CancellationToken cancellationToken)
{
var pokemons = await _mediator.Send(new GetPokemonListQuery());
return pokemons.FirstOrDefault(x => x.Id == request.id);
}
}
Insert
public class InsertPokemonHandler : IRequestHandler<InsertPokemonCommand, PokemonModel>
{
private readonly IDemoDataAccess _data;
public InsertPokemonHandler(IDemoDataAccess data)
{
_data = data;
}
public Task<PokemonModel> Handle(InsertPokemonCommand request, CancellationToken cancellationToken)
{
return Task.FromResult(_data.InsertPokemon(request.Name, request.Generation));
}
}
API Layer
The controller sends requests to MediatR, which then delegates to the appropriate handler.
[ApiController]
[Route("api/[controller]")]
public class PokemonController : ControllerBase
{
private readonly IMediator _mediator;
public PokemonController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet]
public async Task<List<PokemonModel>> GetPokemon()
{
return await _mediator.Send(new GetPokemonListQuery());
}
[HttpGet("{id}")]
public async Task<PokemonModel> GetPokemonById(int id)
{
return await _mediator.Send(new GetPokemonByIdQuery(id));
}
[HttpPost]
public async Task<PokemonModel> Post([FromBody] PokemonModel pokemon)
{
return await _mediator.Send(new InsertPokemonCommand(pokemon.Name, pokemon.Generation));
}
}
How CQRS and MediatR Work Together
- Separation of Concerns: Queries (
GetPokemonListQuery
) are for reading. Commands (InsertPokemonCommand
) are for writing. - Request/Response Pattern: Each query or command implements
IRequest<T>
, and is handled byIRequestHandler<TRequest, TResponse>
. - MediatR Acts as a Mediator:
IMediator.Send()
sends requests to their correct handlers. The controller doesn’t need to know who handles what, it just sends the request.
Final Thoughts
MediatR and CQRS are amazing tools. Used right, they bring order to the chaos of growing applications. But like any tool, they’re not a requirement.
Don’t feel pressured to adopt them just because it looks cleaner.
Yes, clean code is great. But pragmatic code is even better.
Start simple. Let your architecture evolve with your app. Reach for CQRS and MediatR when your business logic cries out for structure, not just because the latest YouTube video told you so.
The best architecture is the one you understand, maintain, and scale comfortably.