Skip to main content
SEMastery

CQRS Pattern with MediatR in .NET: A Friendly Guide

Learn the CQRS pattern with MediatR in .NET using simple words, clear diagrams, and real C# code. Beginner friendly, with pitfalls and licensing notes.

13 min readUpdated May 25, 2026

A school office with two windows

Picture your school office. There are two windows.

At the first window, you ask for things. "What is my exam timetable?" "What marks did I get?" You only want to read information. Nothing changes.

At the second window, you change things. "Please update my address." "Please add me to the football team." Here you are writing new information into the school records.

A smart school keeps these two windows separate. The reading window can be fast and simple, because it never touches the records. The writing window can be careful and strict, because it changes important data.

This simple idea, two windows for two different jobs, is the heart of CQRS. Let us learn it step by step.

What is CQRS?

CQRS stands for Command Query Responsibility Segregation. That is a big name for a small idea.

  • A command is a request that changes data. "Create an order." "Cancel a booking." "Update a name."
  • A query is a request that only reads data. "Show my orders." "Get this product."

CQRS says: keep commands and queries in separate code paths. Do not mix the code that writes with the code that reads.

That is the whole pattern. The rest is just how we organise it neatly in .NET.

Two windows, two jobs. Commands change data. Queries only read it.

Why split reads from writes?

You might ask: why bother? Reading and writing both touch the database anyway.

Here is the reason. Reads and writes are different problems.

When you write data, you care about rules. Is the email valid? Is there enough stock? Should we send a confirmation? Writes need checks, safety, and care.

When you read data, you care about speed and shape. You may want a flat list, joined from many tables, shaped just for one screen. Reads do not need business rules. They just need to be quick and clear.

When you force both into the same model, the code grows messy. A single class tries to be a strict rule-keeper and a fast report builder. It does neither job well.

CQRS lets each side stay simple.

AspectCommand side (write)Query side (read)
Main goalApply rules safelyReturn data fast
ReturnsUsually nothing, or an idData shaped for the screen
ValidationStrict and importantLittle or none
ModelRich domain objectsFlat, simple DTOs
Can be cached?RarelyOften, yes

Where does MediatR fit in?

So far CQRS is just an idea. We still need a tidy way to send a command or query and let the right piece of code handle it.

This is where MediatR helps.

MediatR is a small .NET library created by Jimmy Bogard. It is an in-process mediator. That is a fancy way of saying: a middleman inside your app.

Instead of a controller calling a service, which calls another service, the controller just says: "Here is a request. Please send it to whoever handles it." MediatR finds the correct handler and runs it.

Think of MediatR as the office clerk standing between the two windows and the back rooms. You hand the clerk a slip of paper. You do not need to know which room or which person does the work. The clerk knows.

The controller does not know the handler. It just hands the request to MediatR.

This keeps your controllers thin. They take a request, send it, and return the answer. All the real work lives in small, focused handlers.

Important: MediatR is now commercial

Before we write code, you must know one thing. This is important for real projects.

In July 2025, MediatR (and AutoMapper) moved to a commercial license under a new company called Lucky Penny Software, started by the original author Jimmy Bogard. This was done to keep the projects healthy and well maintained for the long term. That is a fair choice. Good software takes real work.

Here is the short version of the licensing:

Who you areWhat you can do
Learner, student, hobby projectFree Community edition is fine
Non-profit or small company (under $5M revenue)Free Community edition is fine
Larger company, in productionYou likely need a paid license

So you can absolutely use MediatR to learn CQRS, like we do here. But if you work at a bigger company, check the license before shipping. The older free versions still exist, but newer versions follow the new rules. We will also show, near the end, that you can do CQRS without MediatR if you prefer.

Setting up MediatR

Let us build a tiny orders feature. First, add the package.

dotnet add package MediatR

Then register it in Program.cs. We tell MediatR which assembly to scan so it can find all our handlers.

var builder = WebApplication.CreateBuilder(args);
 
// Register MediatR and let it find all handlers in this project.
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
 
var app = builder.Build();

That single registration is enough. MediatR will now find every command, query, and handler in the project automatically.

Writing a command

A command changes data. Let us create a command to place an order.

In MediatR, a request is just a class. We use a record because it is short and clean. The command carries the data it needs, and says what type of result it returns (here, the new order id).

using MediatR;
 
// The command: a request to create an order.
public record CreateOrderCommand(string Product, int Quantity)
    : IRequest<int>;
 
// The handler: the code that actually does the work.
public class CreateOrderHandler
    : IRequestHandler<CreateOrderCommand, int>
{
    private readonly AppDbContext _db;
 
    public CreateOrderHandler(AppDbContext db) => _db = db;
 
    public async Task<int> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Business rules live here. Keep writes careful.
        if (request.Quantity <= 0)
            throw new ArgumentException("Quantity must be positive.");
 
        var order = new Order
        {
            Product = request.Product,
            Quantity = request.Quantity
        };
 
        _db.Orders.Add(order);
        await _db.SaveChangesAsync(cancellationToken);
 
        return order.Id; // give back the new id
    }
}

Notice the clear split. The command is the message. The handler is the worker. They are separate classes with one job each.

Writing a query

A query only reads. It must never change data. Here is a query to get one order.

using MediatR;
 
// The query: a request to read one order. Returns a flat DTO.
public record GetOrderQuery(int Id) : IRequest<OrderDto>;
 
// A simple, flat shape made just for reading.
public record OrderDto(int Id, string Product, int Quantity);
 
public class GetOrderHandler
    : IRequestHandler<GetOrderQuery, OrderDto>
{
    private readonly AppDbContext _db;
 
    public GetOrderHandler(AppDbContext db) => _db = db;
 
    public async Task<OrderDto> Handle(
        GetOrderQuery request,
        CancellationToken cancellationToken)
    {
        // No business rules. Just read and shape the data.
        return await _db.Orders
            .Where(o => o.Id == request.Id)
            .Select(o => new OrderDto(o.Id, o.Product, o.Quantity))
            .FirstOrDefaultAsync(cancellationToken)
            ?? throw new KeyNotFoundException("Order not found.");
    }
}

See how the read side uses a small OrderDto, not the full Order entity. The read side builds exactly the shape the screen wants. Nothing more.

The controller stays thin

Now the controller is tiny. It only sends requests to MediatR and returns the answers.

using MediatR;
using Microsoft.AspNetCore.Mvc;
 
[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
    private readonly ISender _mediator;
 
    public OrdersController(ISender mediator) => _mediator = mediator;
 
    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderCommand command)
    {
        var id = await _mediator.Send(command);
        return CreatedAtAction(nameof(Get), new { id }, null);
    }
 
    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> Get(int id)
    {
        var order = await _mediator.Send(new GetOrderQuery(id));
        return Ok(order);
    }
}

The controller does not know AppDbContext. It does not know the handlers. It just sends a message and trusts MediatR to route it. This is clean and easy to test.

A request flowing through CQRS with MediatR

HTTP request
Controller
MediatR Send
Right handler
Database
Response

Steps

1

Request

User calls the API endpoint

2

Controller

Builds a command or query object

3

Send

Hands the message to MediatR

4

Handler

MediatR finds and runs the handler

5

Database

Handler reads or writes data

6

Respond

Result flows back to the user

Each step has one clear job. The controller stays thin and the handler does the real work.

Cross-cutting concerns with pipeline behaviors

Here is where MediatR really shines. In real apps you need extra jobs around every request: logging, validation, timing, security checks. These are called cross-cutting concerns, because they cut across all your features.

You do not want to copy this code into every handler. MediatR gives you a pipeline. A request passes through a chain of behaviors before and after the handler, like a parcel moving along a conveyor belt with checks at each station.

Every request passes through the pipeline before reaching its handler, then flows back out.

Here is a simple logging behavior that runs for every request.

using MediatR;
 
public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _log;
 
    public LoggingBehavior(
        ILogger<LoggingBehavior<TRequest, TResponse>> log) => _log = log;
 
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var name = typeof(TRequest).Name;
        _log.LogInformation("Handling {Request}", name);
 
        var response = await next(); // call the next step (or handler)
 
        _log.LogInformation("Handled {Request}", name);
        return response;
    }
}

You register it once, and it wraps every command and query. You write the logging code a single time. That is a huge win in larger apps. Validation, retries, and timing can all be added the same way.

Building a validation behavior

Request arrives
Run validators
Valid?
Call handler
Return error

Steps

1

Arrive

Request enters the pipeline

2

Validate

Behavior runs all rules for this request

3

Decide

If valid, continue; if not, stop here

4

Handle

Valid requests reach the real handler

5

Reject

Bad requests return a clear error early

A behavior checks the request, and only lets valid requests reach the handler.

Notifications: telling many listeners at once

CQRS often works together with events. After a command succeeds, you may want several things to happen. Reduce stock. Send an email. Add loyalty points.

MediatR supports this with notifications. You publish one notification, and many handlers react to it. The publisher does not know or care who is listening.

using MediatR;
 
// A notification: something that already happened.
public record OrderPlaced(int OrderId) : INotification;
 
// One of many reactions. Others can exist too.
public class SendEmailWhenOrderPlaced : INotificationHandler<OrderPlaced>
{
    public Task Handle(OrderPlaced note, CancellationToken ct)
    {
        // Send a confirmation email here.
        return Task.CompletedTask;
    }
}

This keeps each reaction in its own small class. Adding a new reaction never touches the command handler.

How the pieces fit together

Let us zoom out and see the whole shape of a CQRS app built with MediatR.

Commands and queries take separate paths, but both flow through MediatR.

The command side uses rich domain objects with rules. The query side uses flat DTOs built for speed. Both go through MediatR, but they never share the same model. That separation is the goal.

Do you need two databases?

This is a common worry, so let us be clear. No, you do not.

Most teams using CQRS run a single normal database, like SQL Server or PostgreSQL. The "separation" happens in code: separate read models and write models. That alone gives big gains in clarity, with zero extra infrastructure.

Using two databases, one tuned for writes and one for reads, is an advanced step. It is often paired with event sourcing. You may never need it. Start with one database and separate models. Add more only when real pressure demands it.

LevelSetupWhen to use it
BeginnerOne database, split models in codeAlmost always start here
AdvancedSeparate read and write databasesHeavy read load, large scale
ExpertCQRS plus event sourcingAudit trails, complex history

Common mistakes to avoid

CQRS is simple, but people still trip over a few things.

  • Using it everywhere. A tiny CRUD app does not need CQRS. The extra classes add noise. Use it where logic is rich, not for a to-do list.
  • Letting queries change data. A query must only read. If your read handler writes to the database, you have broken the pattern.
  • Sharing one fat model. If your command and query use the exact same entity for everything, you have lost the main benefit. Let the read side have its own simple DTOs.
  • Forgetting the license. In a company project, check the MediatR license before you ship.
  • Putting logic in controllers. The whole point is thin controllers and focused handlers. Keep rules in handlers.

Can you do CQRS without MediatR?

Yes. CQRS is just an idea: split reads from writes. MediatR is only a helpful tool, not a rule.

Because of the new license, some teams now write their own tiny dispatcher, or use the plain built-in dependency injection to call handlers directly. A small hand-written mediator can be under a hundred lines. You lose some polish, but you remove a paid dependency and keep full control.

So the takeaway is this. Learn the CQRS idea first. MediatR makes it pleasant, but you own the pattern, not the library.

Quick recap

  • CQRS means keeping write code (commands) separate from read code (queries), like a school office with two windows.
  • Commands change data and apply rules. Queries only read data and return simple shapes.
  • MediatR is a mediator that routes each request to the right handler, keeping controllers thin.
  • Pipeline behaviors let you add logging, validation, and more around every request, written just once.
  • Notifications let one event trigger many independent reactions.
  • You usually need only one database; separate read and write databases is an advanced add-on.
  • MediatR is now commercially licensed (since July 2025). It is free to learn with, but larger companies need a paid license for production.
  • You can do CQRS without MediatR. The pattern is the idea; the library is just a helper.

References and further reading

Related Patterns