Skip to main content
SEMastery

CQRS Pattern in .NET: The Way It Should Have Been From the Start

A friendly, hands-on guide to the CQRS pattern in .NET 10. Learn commands, queries, handlers, diagrams, and when to actually use it.

12 min readUpdated April 27, 2026

A kitchen story to start with

Imagine a busy restaurant in your town. You walk in, sit down, and a waiter comes to take your order. You say, "One masala dosa and one tea, please." The waiter writes it down and walks to the kitchen.

Now think about who does what here.

The waiter who takes your order is doing one job: accepting a request to change something. A new order must be made. This is an action. It has rules. The kitchen must have dosa batter. The stove must be free.

The menu card on your table is doing a completely different job. It only shows you what is available. It never cooks anything. It never changes the kitchen. It just reads out information in a way that is easy for you to understand.

A good restaurant keeps these two jobs apart. The person taking orders is not the same as the printed menu. Mixing them would be slow and confusing.

CQRS is exactly this idea, but for software. We keep the order-taking side (writing, changing data) away from the menu side (reading, showing data). That is the whole heart of it. Everything else is detail.

What does CQRS stand for?

CQRS stands for Command Query Responsibility Segregation. That is a big mouthful, so let us break it gently.

  • Command = a request to do something or change something. Like "Book this hotel room."
  • Query = a request to get information. Like "Show me my bookings."
  • Responsibility Segregation = a fancy way of saying "keep the two jobs separate."

So CQRS just says: the part of your code that changes data should be separate from the part that reads data. Two paths. Two models. One clear rule.

The two clear paths of CQRS: commands change data, queries read data.

Why was the old way painful?

For years, many of us built apps in one common style. We had one big "Service" class for each thing. A ProductService would have GetProduct, GetAllProducts, CreateProduct, UpdateProduct, and DeleteProduct, all in one place. We used the same Product model for everything.

This feels fine at the start. But it grows into a mess.

Reads and writes have very different needs:

ConcernWriting data (commands)Reading data (queries)
Main goalProtect business rulesBe fast and easy to show
ValidationHeavy, strictAlmost none
Shape of dataFull domain modelFlat DTO for the screen
How often it runsLess oftenVery often
Scaling needsCareful, consistentCheap to scale wide

When you force both jobs through one model, you get problems. Your read screens drag in heavy validation they do not need. Your write code returns big objects shaped for screens instead of for rules. Adding a new report means touching code that also saves orders. One change risks breaking the other.

CQRS is the calm answer. Give each job its own model and its own path. They stop fighting.

The three small pieces

A simple CQRS setup in .NET has three small building blocks. Do not overthink them.

  1. A Command or Query — a small message object that says what you want.
  2. A Handler — a class that knows how to do that one job.
  3. A Dispatcher (or mediator) — a tiny helper that finds the right handler for each message.

Let us see each one.

A command

A command is just a plain object holding the data needed to change something. Notice it asks for an action, not a low-level field edit. Microsoft Learn gives good advice here: name commands after the business task, like "Book hotel room," not "Set status to reserved."

// A command: a request to change something.
public sealed record CreateOrderCommand(
    int CustomerId,
    string ProductName,
    int Quantity) : IRequest<int>;

A command handler

The handler does the real work. It checks rules, then saves. It is the only place that "knows how" to create an order.

public sealed class CreateOrderHandler
    : IRequestHandler<CreateOrderCommand, int>
{
    private readonly AppDbContext _db;
 
    public CreateOrderHandler(AppDbContext db) => _db = db;
 
    public async Task<int> Handle(
        CreateOrderCommand cmd,
        CancellationToken ct)
    {
        if (cmd.Quantity <= 0)
            throw new ArgumentException("Quantity must be at least 1.");
 
        var order = new Order(cmd.CustomerId, cmd.ProductName, cmd.Quantity);
 
        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);
 
        return order.Id; // return the new id, nothing more
    }
}

A query and its handler

A query never changes anything. It reads, and it returns a flat DTO shaped for the screen. No domain rules. No saving.

// A query: a request to read something.
public sealed record GetOrderByIdQuery(int OrderId)
    : IRequest<OrderSummaryDto?>;
 
public sealed record OrderSummaryDto(
    int Id, string ProductName, int Quantity, string Status);
 
public sealed class GetOrderByIdHandler
    : IRequestHandler<GetOrderByIdQuery, OrderSummaryDto?>
{
    private readonly AppDbContext _db;
 
    public GetOrderByIdHandler(AppDbContext db) => _db = db;
 
    public async Task<OrderSummaryDto?> Handle(
        GetOrderByIdQuery query,
        CancellationToken ct)
    {
        // Read-only, no tracking, projected straight to a DTO.
        return await _db.Orders
            .AsNoTracking()
            .Where(o => o.Id == query.OrderId)
            .Select(o => new OrderSummaryDto(
                o.Id, o.ProductName, o.Quantity, o.Status))
            .FirstOrDefaultAsync(ct);
    }
}

See the difference? The command handler protects rules. The query handler just shapes data for the screen. They never get in each other's way.

How a request flows through CQRS

Here is the journey of one request, step by step, when a user creates an order.

A command travelling through the system

Controller
Dispatcher
Handler
Database
Response

Steps

1

Controller

Builds a CreateOrderCommand from the request.

2

Dispatcher

Finds the one handler for that command.

3

Handler

Checks rules, then saves the order.

4

Database

Stores the new order row.

5

Response

Returns the new order id to the user.

From the browser click to a saved order and a fresh id.

And here is the read side, which is shorter because it does less work.

A query travelling through the system

Controller
Dispatcher
Handler
Database
DTO

Steps

1

Controller

Builds a GetOrderByIdQuery.

2

Dispatcher

Routes it to the query handler.

3

Handler

Reads with AsNoTracking, projects to DTO.

4

DTO

Returns a flat object for the screen.

A read path stays simple: no rules, no saving, just data.

The controller stays thin. It does not hold business logic. It just creates a message and sends it.

[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;
 
    public OrdersController(IMediator mediator) => _mediator = mediator;
 
    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderCommand cmd)
    {
        int id = await _mediator.Send(cmd);
        return CreatedAtAction(nameof(GetById), new { id }, null);
    }
 
    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id)
    {
        var dto = await _mediator.Send(new GetOrderByIdQuery(id));
        return dto is null ? NotFound() : Ok(dto);
    }
}

A quick word about MediatR and the licence change

For years, most .NET teams reached for a library called MediatR to wire up commands, queries, and handlers. It is well loved and easy to use.

But there is important news. As of July 2025, MediatR moved to a commercial model under Lucky Penny Software. Version 12 and earlier stay under their old open-source licence, but version 13 and newer need a paid commercial tier for many business uses, with a free community tier for individuals and non-commercial use. The popular messaging library MassTransit has made a similar commercial move too.

This is not a disaster. CQRS is an idea, not a library. You have good free choices:

OptionLicenceGood to know
MediatR 12.xOpen sourceStill free; pinned older version
MediatR 13+CommercialPaid for most business use
WolverineOpen sourceUses source generators, fast, adds messaging
Cortex.MediatorMIT (free)Drop-in style, modern features
LiteBusFreeLightweight, focused on CQS
Your own dispatcherYours~100 lines, no dependency, fully in your control

Writing your own tiny dispatcher is honestly not hard. Here is a small version that resolves a handler from the .NET dependency injection container and calls it.

public interface IRequest<TResponse> { }
 
public interface IRequestHandler<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, CancellationToken ct);
}
 
public sealed class Mediator : IMediator
{
    private readonly IServiceProvider _provider;
    public Mediator(IServiceProvider provider) => _provider = provider;
 
    public async Task<TResponse> Send<TResponse>(
        IRequest<TResponse> request,
        CancellationToken ct = default)
    {
        var handlerType = typeof(IRequestHandler<,>)
            .MakeGenericType(request.GetType(), typeof(TResponse));
 
        dynamic handler = _provider.GetRequiredService(handlerType);
        return await handler.Handle((dynamic)request, ct);
    }
}

That is the same shape MediatR gives you, but free and under your own roof. You register each handler in Program.cs and you are done.

Simple CQRS vs full CQRS

People sometimes get scared of CQRS because they think it forces two databases. It does not. There are levels.

CQRS has levels. Most apps stop at the simple, one-database level.

The most common and most useful level is the first one: one shared database, but separate command and query code. Microsoft calls this "simplified CQRS," and they use it in the eShopOnContainers sample. You get most of the benefit with very little extra cost.

You only climb to the higher levels when you truly need them. For example, a separate read database that is tuned for fast reports, kept up to date by messages. That is powerful, but it brings a new challenge called eventual consistency — the read side may be a tiny bit behind the write side for a short moment.

With separate read and write stores, an event keeps the read side fresh.

If you do not need that, do not build it. Keeping things simple is a feature, not a weakness.

When should you actually use CQRS?

CQRS is a tool, not a rule. Microsoft Learn is clear about this: do not force CQRS and DDD everywhere. Many parts of an app are simple and a plain CRUD service is perfectly fine.

Good signs that CQRS will help:

  • Your reads and writes have very different shapes and rules.
  • Your read traffic is far heavier than your write traffic, and you want to scale them apart.
  • You have rich business logic on the write side that you want to keep clean and protected.
  • Many people work on the same area, and clear separation reduces accidents.

Signs you probably do not need it:

  • A small app or a simple admin screen.
  • Basic create-read-update-delete with no real business rules.
  • A team that is new and just needs to ship something this week.

A nice middle path is to use CQRS only inside the complex parts of your system, and use plain CRUD for the easy parts. You are allowed to mix. Good engineers pick the right tool for each corner of the app.

Common mistakes to avoid

A few friendly warnings, learned the hard way by many teams.

  • Do not let queries change data. A query must be read-only. If it saves anything, the whole idea breaks.
  • Do not share one fat model for both sides. That defeats the point. Let the read side have its own slim DTOs.
  • Do not add two databases on day one. Start simple, with one database. Climb only when pain appears.
  • Do not skip naming. Name commands after real business actions. CancelOrderCommand is clearer than UpdateOrderStatus.
  • Do not over-engineer. If a screen just lists ten rows, you do not need handlers, dispatchers, and DTOs for it. Be honest about complexity.

Putting it all together

Here is how the whole thing fits, from request to response, across both sides.

The full CQRS picture

Request
Dispatcher
Command path
Query path
Storage

Steps

1

Request

Controller builds a command or a query.

2

Dispatcher

Sends the message to its single handler.

3

Command path

Checks rules and writes data.

4

Query path

Reads and projects to a DTO.

5

Storage

One database, or tuned read and write stores.

One thin entry point, two clean paths, shared or separate storage.

When you read your own code months later, this shape is a gift. You open a folder of commands and instantly know "this is where data changes." You open a folder of queries and know "this is read-only, safe to touch." That clarity is the real prize of CQRS.

Quick recap

  • CQRS = Command Query Responsibility Segregation: keep writing code separate from reading code.
  • Like a restaurant, the order-taker (command) is not the menu (query). Two jobs, kept apart.
  • The three small pieces are the message (command or query), the handler, and a tiny dispatcher.
  • Commands change data and protect business rules. Queries only read and return flat DTOs.
  • Name commands after real business actions, like "Book hotel room," not low-level field edits.
  • MediatR went commercial in July 2025 (v13+). Free choices include Wolverine, Cortex.Mediator, LiteBus, or your own ~100-line dispatcher.
  • CQRS has levels. Start with one database and split code only. Add separate read stores or event sourcing only when you truly need them.
  • Do not force CQRS everywhere. Use it for the complex parts and plain CRUD for the simple parts.

References and further reading

Related Patterns