CQRS and the Mediator pattern
Code that changes data and code that reads data have different needs. CQRS separates the two. The Mediator pattern and the MediatR library deliver each request to a small Handler and do shared work in one place.
Author: bezzad
The problem: one model for two different jobs
Our online shop has two simple jobs:
- Placing an order. The rules must be checked: stock, the maximum amount, the order status. This is done with the Order Aggregate, the one you saw in the DDD lesson.
- The “My orders” page. A simple list: date, amount, status. This page is opened a thousand times more often than orders are placed.
If we use one model and one service for both jobs:
- The list page gets slow. To show three columns, the whole order with all its lines is loaded and tracked.
- The domain model gets messy. The page needs a field like “name of the first item”. Someone adds it to the Order class.
- The service grows. The OrderService class has ten read methods and ten write methods.
Writing needs rules. Reading needs speed and the right shape for the page. These two do not fit together.
The CQRS idea: separate writing and reading
The name CQRS comes from Command Query Responsibility Segregation. It means: separate the responsibility of the command and the query.
Command (Command)
- It changes something.
- Its name is an imperative verb: PlaceOrder, CancelOrder.
- It goes through the domain model and the rules.
- It returns nothing, or only an ID.
Query (Query)
- It does not change anything.
- Its name is a question: GetMyOrders.
- It reads directly and fast, without the domain model.
- It returns a small model (DTO) made just for that page.
Two levels of CQRS
When most people hear CQRS, they think they need two databases. This is not true. CQRS has two levels:
Level one: one database, two paths
Commands and queries have separate classes, but they use one database. A command works with the Aggregate. A query uses a light query, for example with the AsNoTracking and Select methods, and reads only the needed columns. This level is enough for most projects and costs almost nothing.
Level two: a separate database for reading
When reading is very heavy, we build a separate read model. For example, a table ready for the page, or a search engine. After each change, an event is published and the read model is updated.
This level is very powerful, but it also has costs:
- Eventual consistency (Eventual Consistency). The user places an order and opens the list right away. The new order may not be there yet.
- More code. You must send events, write an updater and handle its errors.
- More operations. One more database or service to maintain and monitor.
The Mediator pattern: each request, one Handler
With CQRS, instead of one big OrderService, we have one small class for each job. Now how does the endpoint find the right class?
The Mediator pattern puts a mediator in the middle. The endpoint only sends a message. The mediator delivers it to the Handler for that message. In .NET, the MediatR library is the most common implementation of this pattern.
Code
The command
public sealed record PlaceOrderCommand(Guid CustomerId, Guid CartId) : IRequest<Guid>;
public sealed class PlaceOrderHandler(ShopDbContext db)
: IRequestHandler<PlaceOrderCommand, Guid>
{
public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken ct)
{
var cart = await db.Carts
.Include(c => c.Items)
.SingleAsync(c => c.Id == command.CartId, ct);
var order = Order.PlaceFrom(cart, command.CustomerId); // rules live in the domain
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
The query
public sealed record GetMyOrdersQuery(Guid CustomerId) : IRequest<List<OrderSummary>>;
public sealed record OrderSummary(Guid Id, DateTime PlacedAt, decimal Total, OrderStatus Status);
public sealed class GetMyOrdersHandler(ShopDbContext db)
: IRequestHandler<GetMyOrdersQuery, List<OrderSummary>>
{
public Task<List<OrderSummary>> Handle(GetMyOrdersQuery query, CancellationToken ct) =>
db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == query.CustomerId)
.OrderByDescending(o => o.PlacedAt)
.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Lines.Sum(l => l.Price * l.Quantity), o.Status))
.ToListAsync(ct);
}
Look at the difference: the query runs no rules, tracks nothing and reads only four fields.
The endpoints
app.MapPost("/orders", async (PlaceOrderCommand command, ISender sender, CancellationToken ct) =>
{
var id = await sender.Send(command, ct);
return TypedResults.Created($"/orders/{id}", id);
});
app.MapGet("/customers/{customerId:guid}/orders",
(Guid customerId, ISender sender, CancellationToken ct) =>
sender.Send(new GetMyOrdersQuery(customerId), ct));
The ISender interface has only the Send method. When the endpoint only sends requests, this is enough.
Pipeline behaviors: shared work, once
Before each command, the input must be checked. Logging and transactions are also needed for all of them. If we write this work in each Handler, it is repeated hundreds of times.
In MediatR, a pipeline behavior (Pipeline Behavior) runs around all Handlers. This is the same idea as the Decorator in the design patterns lesson.
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
foreach (var validator in validators)
await validator.ValidateAndThrowAsync(request, ct);
return await next(ct);
}
}
Registration in DI. The validators in this example come from the FluentValidation library:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
- The order of registration matters. The first registered behavior is the outermost layer. So in this code, logging sees all requests, even the ones that validation rejects.
- A behavior must be general. If some work is only for one command, its place is inside that Handler.
The costs of Mediator
The Mediator pattern is not free. Know these costs before you choose it:
- Finding code gets harder. “Go to definition” on Send does not take you to the Handler. You must search for the class.
- Dependencies get hidden. The endpoint only takes ISender. You can no longer see from the constructor what the code depends on.
- Many classes. For each simple job, one record, one Handler and sometimes one Validator.
- Chains of Send. If Handlers call each other with Send, the flow of the code gets lost.
- The license. The MediatR library has a commercial license from version 13. There is a free Community edition for small companies. Before you use it, read the license terms for your own company.
builder.Services.AddScoped<PlaceOrderHandler>();
app.MapPost("/orders", async (PlaceOrderCommand command, PlaceOrderHandler handler, CancellationToken ct) =>
TypedResults.Ok(await handler.Handle(command, ct)));
With this approach, “go to definition” takes you straight to the Handler, and dependencies are not hidden.
Important rules
- A query changes nothing. Not the database, not the cache, not the user’s state.
- A command does not return full data. Only an ID, or success or failure. To see data, send a query.
- Business rules live in the domain, not in the Handler. The Handler only coordinates.
- A Handler does not call another Handler. Put shared logic in a normal class or in the domain.
- At level two, prepare the user interface for the delay. For example, after placing an order, show the confirmation page from the command’s answer, not from the read model.
Common mistakes
| Mistake | Why is it bad? | Right way |
|---|---|---|
| Thinking CQRS means two databases | A lot of complexity for a problem you do not have. | First, separate classes on one database. |
| A query with the full Aggregate and tracking | It is slow and uses a lot of memory. | A light query with Select and no tracking. |
| A query that changes something | It has a hidden effect, and you cannot cache it or repeat it. | Changes only in commands. |
| MediatR only to reduce constructor parameters | The design problem is hidden, not solved. | Split the busy class by job. |
| Handlers that call each other with Send | The flow of the code gets lost and the transactions get mixed. | Shared logic in a service or in the domain. |
| Ignoring the delay of the read model | The user does not see their new order and places it again. | A clear message in the user interface, or reading from the write model at that moment. |
When to use CQRS and Mediator?
Good fit
- Reading and writing have very different needs.
- The project has many use cases.
- A lot of shared work is needed on all requests.
- The team works with Vertical Slice. Each feature is one command or query.
Bad fit
- A small CRUD service where reading and writing look almost the same.
- The team does not know this pattern and the project is short-term.
- The only goal is a “modern architecture”, not a real problem.
Summary in six lines
- A command changes something. A query only reads. CQRS separates the two.
- Level one is one database and two paths. It is enough for most projects.
- A separate read model is fast, but it adds delay and more code.
- The Mediator pattern delivers each request to a small Handler.
- Pipeline behaviors run shared work once, around all Handlers.
- Know the costs: indirect code, many classes and the MediatR license. You can also do it without a library.