Learn CQRS and MediatR in ASP.NET Core with practical C# examples. Master commands, queries, and pipeline behaviors — start building cleaner APIs today.
If you have ever opened a service class in an ASP.NET Core project and found a 2,000-line file that reads data, writes data, validates input, sends emails, and logs everything in between, you already understand why CQRS and MediatR in ASP.NET Core have become two of the most searched-for patterns in the .NET ecosystem. CQRS (Command Query Responsibility Segregation) splits your application into two clean halves — operations that change state and operations that read state — while MediatR gives you a lightweight, in-process messaging library to wire it all together without controllers knowing anything about your business logic.
In this tutorial, you will learn what CQRS actually is (and what it is not), why MediatR is the most popular way to implement it in .NET, and how to build a complete, runnable example with commands, queries, handlers, and pipeline behaviors. We will also cover the licensing change in MediatR, best practices, and the pitfalls that trip up most teams.
What Is the CQRS Pattern in C#?
CQRS stands for Command Query Responsibility Segregation. It builds on a simple principle from Bertrand Meyer called Command-Query Separation (CQS): a method should either change state or return data, but never both. CQRS lifts that idea from the method level to the architecture level:
- Commands change the state of the system:
CreateOrder,UpdateCustomerEmail,CancelSubscription. They express intent and typically return nothing (or just an identifier). - Queries read state and return data:
GetOrderById,ListActiveCustomers. They never mutate anything.
Why does this separation matter? Because reads and writes have fundamentally different needs. Writes need validation, business rules, transactions, and auditing. Reads need speed, projections shaped for the UI, and often caching. When you force both through the same service methods and the same domain models, every change to one side risks breaking the other. Splitting them means each side can evolve independently — your query side can use raw Dapper SQL against a read-optimized view while your command side uses EF Core with full change tracking, and neither cares about the other.
One important clarification: CQRS does not require separate databases, event sourcing, or eventual consistency. Those are optional extensions for high-scale systems. For most applications, CQRS is simply a code organization pattern — one class per operation — and that alone delivers most of the value.
Why Use MediatR for CQRS in ASP.NET Core?
You can implement CQRS without any library, but MediatR (by Jimmy Bogard, the author of AutoMapper) removes the boilerplate. It is an in-process implementation of the mediator pattern: instead of controllers calling services directly, they send a request object to MediatR, which routes it to exactly one handler.
The benefits are concrete:
- Thin controllers. A controller action becomes three lines: receive the request, send it, return the result. No injected service soup.
- One class per use case.
CreateProductCommandHandlerdoes one thing. It is easy to find, easy to test, and easy to delete. - Cross-cutting concerns in one place. Pipeline behaviors let you wrap every request with validation, logging, caching, or transactions — like middleware for your business logic.
- Loose coupling. The sender never references the handler. Refactoring a handler cannot break a controller.
A note on licensing: as of version 13, MediatR moved to a commercial license under Lucky Penny Software — it remains free for smaller organizations and open-source projects, but larger companies need a paid license. Versions 12.x and earlier remain Apache 2.0 licensed. If licensing is a blocker, community alternatives like Mediator (source-generator based) or a hand-rolled dispatcher work with the same patterns shown below. The concepts in this article apply regardless of which you choose.
Setting Up MediatR in an ASP.NET Core Project
Create a Web API project and install the package:
// Terminal
// dotnet new webapi -n CqrsDemo
// dotnet add package MediatR
Register MediatR in Program.cs. A single call scans your assembly for every handler:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.MapControllers();
app.Run();
Writing Your First Command and Handler
Let's build a product catalog. A command is a plain object describing intent — C# records are perfect because they are immutable and concise:
using MediatR;
// The command: carries everything needed to perform the write.
// IRequest<int> means "this request returns an int" (the new product's ID).
public record CreateProductCommand(string Name, decimal Price, int Stock)
: IRequest<int>;
The handler contains the actual business logic:
public class CreateProductCommandHandler
: IRequestHandler<CreateProductCommand, int>
{
private readonly AppDbContext _db;
public CreateProductCommandHandler(AppDbContext db) => _db = db;
public async Task<int> Handle(
CreateProductCommand request, CancellationToken cancellationToken)
{
if (request.Price <= 0)
throw new ArgumentException("Price must be positive.");
var product = new Product
{
Name = request.Name,
Price = request.Price,
Stock = request.Stock
};
_db.Products.Add(product);
await _db.SaveChangesAsync(cancellationToken);
return product.Id;
}
}
Notice what is not here: no HTTP concerns, no controller references, no framework noise. This class is trivially unit-testable — construct it with an in-memory context, call Handle, assert the result.
Writing a Query and Handler
Queries follow the same shape but return data. A key CQRS practice: return a DTO shaped for the consumer, never your EF Core entity. This prevents over-fetching and stops your database schema from leaking into your API contract.
public record GetProductByIdQuery(int Id) : IRequest<ProductDto?>;
public record ProductDto(int Id, string Name, decimal Price, bool InStock);
public class GetProductByIdQueryHandler
: IRequestHandler<GetProductByIdQuery, ProductDto?>
{
private readonly AppDbContext _db;
public GetProductByIdQueryHandler(AppDbContext db) => _db = db;
public async Task<ProductDto?> Handle(
GetProductByIdQuery request, CancellationToken cancellationToken)
{
return await _db.Products
.AsNoTracking() // read-only: skip change tracking for speed
.Where(p => p.Id == request.Id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Stock > 0))
.FirstOrDefaultAsync(cancellationToken);
}
}
AsNoTracking() matters here: because queries never write, there is no reason to pay EF Core's change-tracking cost. This is CQRS paying off already — the read path gets optimized without touching the write path.
The Controller: Thin by Design
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(CreateProductCommand command)
{
var id = await _mediator.Send(command);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _mediator.Send(new GetProductByIdQuery(id));
return product is null ? NotFound() : Ok(product);
}
}
The controller injects one dependency no matter how many use cases you add. Adding a feature never means editing constructor signatures across your API layer.
Pipeline Behaviors: Cross-Cutting Concerns Done Right
This is where MediatR earns its keep. An IPipelineBehavior wraps every request like ASP.NET Core middleware wraps every HTTP call. Here is a validation behavior using FluentValidation that runs before any handler:
using FluentValidation;
using MediatR;
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
throw new ValidationException(failures);
return await next(); // continue to the handler
}
}
Define a validator per command:
public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Price).GreaterThan(0);
RuleFor(x => x.Stock).GreaterThanOrEqualTo(0);
}
}
And register everything once:
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
builder.Services.AddTransient(
typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Now every command in your entire application is validated automatically. The same pattern gives you request logging, performance timing, response caching for queries, and database transactions for commands — each concern written once, applied everywhere. That is the "why" behind MediatR: it turns cross-cutting code from something you remember to add into something you cannot forget.
CQRS and MediatR Best Practices
- One handler per file, named after the use case. Organize by feature folder (
Features/Products/CreateProduct.cs), not by technical layer. This "vertical slice" layout makes features discoverable. - Commands express intent, not CRUD. Prefer
DeactivateCustomerCommandoverUpdateCustomerCommandwith a status flag. Intent-named commands document your business. - Keep queries side-effect free — enforce it. Use
AsNoTracking(), or route queries through a read-only Dapper connection so mutation is impossible by construction. - Return DTOs, never entities. Entities are your persistence model; DTOs are your contract.
- Always pass the
CancellationTokenthrough. ASP.NET Core cancels it when the client disconnects; honoring it stops wasted database work. - Use records for requests. Immutability guarantees the request cannot be mutated mid-pipeline by a behavior.
Common Pitfalls to Avoid
- Handlers calling other handlers. Sending a command from inside a handler creates hidden call chains that are painful to debug. Extract shared logic into a plain service both handlers use, or publish a notification (
INotification) for genuine follow-on events. - Treating CQRS as all-or-nothing. You do not need separate read/write databases on day one. Start with separated classes over one database; split storage only when read scaling actually demands it.
- Anemic handlers. If every handler is three lines that forward to a service, you have added indirection without value. The handler is the right home for the use-case logic.
- Queries that write. A query that updates a "last viewed" timestamp violates the contract and will eventually surprise someone. If you need the side effect, make it explicit with a separate command or notification.
- Using MediatR everywhere. A simple health-check endpoint or a static lookup does not need a command/handler pair. Apply the pattern where use cases have real logic.
Conclusion: Key Takeaways on CQRS and MediatR in ASP.NET Core
Adopting CQRS and MediatR in ASP.NET Core is less about architecture astronautics and more about everyday maintainability: every use case lives in one small, testable class; controllers shrink to routing glue; and cross-cutting concerns like validation and logging are written once as pipeline behaviors instead of scattered across services.
- CQRS separates commands (writes) from queries (reads) so each side can be optimized and evolved independently.
- MediatR implements the mediator pattern in-process: controllers
Send()a request, exactly one handler responds. - Pipeline behaviors are the killer feature — validation, logging, caching, and transactions applied uniformly.
- You do not need event sourcing or separate databases to benefit; start simple and scale the pattern with your needs.
- Check MediatR's licensing for your organization size (v13+ is commercial for larger companies; v12 remains Apache 2.0, and alternatives exist).
The best next step is hands-on: take one bloated service class in a project you maintain and refactor its methods into commands and queries with handlers. Within an hour you will feel the difference — and your next code review will show it.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment