Levelwise
English
Code design

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.

Not reviewedWritten with AI helpReading time: 16 minExample of placing an order and the "My orders" pageC# code on .NET 10

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:

  1. The list page gets slow. To show three columns, the whole order with all its lines is loaded and tracked.
  2. The domain model gets messy. The page needs a field like “name of the first item”. Someone adds it to the Order class.
  3. 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.

WriteChanges somethingPlaceOrderPlaceOrderHandlerOrderRules live hereDatabaseReadChanges nothingGetMyOrdersGetMyOrdersHandlerOrderSummaryNo domain model, only the needed fields
A command goes through the domain model. A query reads only what the page needs.

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.
The root of this idea: At the method level, this idea is older and its name is CQS: a method that returns something should not change anything. CQRS takes the same idea to the level of classes and models.

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.

CommandWrite databaseNormalized tablesOrderPlacedUpdaterRead databaseReady for the pageQueryA short delay between write and readThe user may see old data for a moment

This level is very powerful, but it also has costs:

  1. Eventual consistency (Eventual Consistency). The user places an order and opens the list right away. The new order may not be there yet.
  2. More code. You must send events, write an updater and handle its errors.
  3. More operations. One more database or service to maintain and monitor.
Start from level one. Build level two only when measurements show that reading is a real problem. Also, CQRS is not the same as Event Sourcing. You can have CQRS without storing events instead of data.

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.

Send()From web requestLoggingValidationTransactionPlaceOrderHandlerInvalid input: it returns right hereThe main class and the transaction never run
Each behavior does its own work and then passes the request to the inner layer.
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:

  1. Finding code gets harder. “Go to definition” on Send does not take you to the Handler. You must search for the class.
  2. Dependencies get hidden. The endpoint only takes ISender. You can no longer see from the constructor what the code depends on.
  3. Many classes. For each simple job, one record, one Handler and sometimes one Validator.
  4. Chains of Send. If Handlers call each other with Send, the flow of the code gets lost.
  5. 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.
You can also do it without a library. You do not need MediatR for CQRS. You can inject each Handler directly into the endpoint. Do the shared work with endpoint filters or middleware in ASP.NET Core.
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

  1. A query changes nothing. Not the database, not the cache, not the user’s state.
  2. A command does not return full data. Only an ID, or success or failure. To see data, send a query.
  3. Business rules live in the domain, not in the Handler. The Handler only coordinates.
  4. A Handler does not call another Handler. Put shared logic in a normal class or in the domain.
  5. 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

  1. A command changes something. A query only reads. CQRS separates the two.
  2. Level one is one database and two paths. It is enough for most projects.
  3. A separate read model is fast, but it adds delay and more code.
  4. The Mediator pattern delivers each request to a small Handler.
  5. Pipeline behaviors run shared work once, around all Handlers.
  6. Know the costs: indirect code, many classes and the MediatR license. You can also do it without a library.