CQRS with MediatR is one of the most practical ways to organize a .NET 10 application without drowning in layers. CQRS, which stands for Command Query Responsibility Segregation, splits every business operation into two kinds of work: commands that change state and queries that return data. MediatR is the in-process message bus that carries each command or query to exactly one handler, running any registered pipeline behaviors along the way. The outcome is a codebase where every feature is a small, focused handler rather than a method buried inside a large service class.
This guide walks through the pattern end to end. You will see what commands and queries look like, how to register MediatR in a .NET 10 project, how a real handler is written, and when applying the pattern is genuinely worth the extra indirection. The examples follow the same style of code used across the Indotalent product line, where every application runs on .NET 10 with Vertical Slice Architecture and the MediatR pipeline handling validation, logging, and transactions.
Commands vs Queries: The Core of CQRS with MediatR
The first rule of CQRS is that a command never returns data and a query never changes state. A command is an intention: create this order, pay this invoice, update this customer. A query is a question: give me today's orders, find this product, count the open tickets. Keeping the two separate is what makes the rest of the pattern work, because reads and writes can then evolve and be optimized independently.
MediatR models this split with two simple interfaces. Commands and queries are records that implement IRequest or IRequest<TResponse>, and handlers implement IRequestHandler<TRequest, TResponse>. MediatR guarantees that each request type maps to exactly one handler, which removes the guesswork of figuring out which service method does what.
- Commands are verbs: name them as intentions such as
CreateProductCommandorMarkOrderShippedCommand. - Queries are questions: name them as requests for data such as
GetProductByIdQueryorListOrdersQuery. - One handler per request: MediatR throws if a request type resolves to zero or multiple handlers, keeping the mapping obvious.
- Handlers stay thin: each handler does one job, which makes unit testing straightforward.
Setting Up MediatR in a .NET 10 Project
Wiring MediatR into a .NET 10 project takes a few lines. Install the MediatR package together with its dependency injection package, then register all handlers from your assembly during startup. In a Vertical Slice project the handlers live next to their features, so scanning the assembly picks up every slice automatically.
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});
That single registration is all MediatR needs to find every handler, validator, and pipeline behavior in the application. When you add a new feature slice later, you never have to revisit this file. This is one of the quiet wins of using the mediator pattern in a modular codebase: new behavior appears the moment its class exists.
A Real Command Handler in Practice
Consider a product catalog in a typical .NET 10 business application. Creating a product is a command; the endpoint or UI layer builds a CreateProductCommand and calls Send on the mediator. The handler below creates the entity, saves it with EF Core, and returns the new id.
public record CreateProductCommand(string Name, decimal Price)
: IRequest<int>;
public class CreateProductHandler
: IRequestHandler<CreateProductCommand, int>
{
private readonly AppDbContext _db;
public CreateProductHandler(AppDbContext db) => _db = db;
public async Task<int> Handle(
CreateProductCommand command,
CancellationToken ct)
{
var product = Product.Create(command.Name, command.Price);
_db.Products.Add(product);
await _db.SaveChangesAsync(ct);
return product.Id;
}
}
Nothing about this handler references controllers, the UI, or an abstraction layer. It depends only on the database context, which is exactly what you want. If a validation behavior is registered in the pipeline, it runs before this method is invoked; if a logging behavior is registered, it wraps the execution. The handler itself stays focused on business logic.
The query side works the same way but returns data instead of changing it. A GetProductByIdQuery is a record that implements IRequest<ProductDto>, and its handler reads from AppDbContext with AsNoTracking(), maps the entity to a DTO, and returns it. Because reads never mutate state, they can be optimized aggressively without touching write paths.
When CQRS with MediatR Helps You Win
The pattern earns its keep in applications with many operations, shared cross-cutting concerns, and multiple developers. Every Indotalent product is built this way: each feature slice defines its own commands, queries, and handlers, and the MediatR pipeline applies validation and logging uniformly.
- Consistent pipeline: behaviors for validation, logging, and transactions run for every operation automatically.
- Independent features: teams add handlers without stepping on each other's files.
- Small, testable units: each handler is a single class with a single responsibility.
- Easy to extend: adding caching, retries, or audit is a new behavior, not a change to every service.
When the Pattern Adds Noise
CQRS with MediatR is not free. Every command is a new class, every query is a new class, and the pipeline adds a layer of indirection that can feel heavy for a small CRUD screen. If an application has a handful of endpoints and no shared cross-cutting concerns, calling a service method directly is simpler and faster to read. Use the pattern where the pipeline and the handler structure buy you something real, not as a default for every project.
Key Takeaways
- Commands change state and return no data; queries return data and change no state
- MediatR maps each request type to exactly one handler, keeping control flow obvious
- Pipeline behaviors apply validation, logging, and transactions uniformly
- Reach for the pattern when features are many and concerns are shared, not for tiny CRUD apps
FAQ
Is MediatR the same as CQRS? No. CQRS is the design principle of separating commands and queries; MediatR is a library that implements the mediator pattern and conveniently delivers those commands and queries to handlers.
Does CQRS require a separate read database? No. The write and read models can share one database. A separate database is an advanced optimization, not a requirement of the pattern.
Where do pipeline behaviors run? Behaviors wrap the handler inside an ordered pipeline. Validation behaviors run before the handler; logging behaviors can run before and after; transaction behaviors wrap the whole call.
Can I use CQRS with MediatR without Vertical Slice Architecture? Yes. The pattern works in layered applications too, though it pairs naturally with VSA because both push toward small, feature-local units of code.