Learn CQRS with MediatR in ASP.NET Core. Step-by-step tutorial with code examples, best practices, and real-world patterns. Start building today!
CQRS with MediatR in ASP.NET Core — The Complete Guide
If you've been building ASP.NET Core applications for a while, you've probably noticed how controllers bloat with business logic over time. MediatR in ASP.NET Core solves this by implementing the mediator pattern, which pairs perfectly with the CQRS pattern in C# to create clean, testable, and maintainable applications. In this tutorial, you'll learn how to implement command query separation from scratch with practical, runnable code examples.
CQRS — Command Query Responsibility Segregation — is an architectural pattern that separates read operations (queries) from write operations (commands). Instead of using the same model and service layer for both reads and writes, you create dedicated paths for each. MediatR acts as the glue, routing commands and queries to their respective handlers without your controllers needing to know the details.
Why Use CQRS with MediatR in ASP.NET Core?
Before we dive into code, let's understand why this pattern exists and when it makes sense.
In a traditional CRUD application, your controller calls a service, which calls a repository, all using the same model. This works fine for simple apps. But as complexity grows, you run into problems:
- Fat controllers — Business logic leaks into controllers, making them untestable
- Rigid service layers — A single service class handling reads and writes becomes a God class
- Cross-cutting concerns — Logging, validation, and caching get duplicated everywhere
- Scaling bottlenecks — You can't optimize reads and writes independently
The CQRS pattern addresses all of these. Commands handle writes (create, update, delete) and return minimal data. Queries handle reads and can be optimized independently — different database views, caching strategies, or even separate read databases. MediatR provides the in-process messaging infrastructure to wire it all together.
When CQRS Is Worth It (and When It Isn't)
CQRS adds structure, but it also adds files. For a simple CRUD app with five entities and no complex business rules, it's overkill. Use it when:
- Your domain has complex business logic that differs between reads and writes
- Multiple developers work on the same codebase and need clear boundaries
- You need different optimization strategies for reads vs. writes
- You want pipeline behaviors (logging, validation, caching) applied consistently
Setting Up MediatR in ASP.NET Core
Let's build a product management API step by step. First, create a new ASP.NET Core Web API project and install the required NuGet package:
// Install via Package Manager Console
// Install-Package MediatR
// Or via .NET CLI
// dotnet add package MediatR
Register MediatR in your Program.cs:
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthorization();
app.MapControllers();
app.Run();
The RegisterServicesFromAssembly call scans the assembly for all IRequest and IRequestHandler implementations and registers them in the DI container automatically.
Implementing Commands — The Write Side of CQRS
Commands represent intentions to change state. They should be named as imperative verbs: CreateProduct, UpdatePrice, DeleteOrder. Let's start with the domain model and a create command.
// Models/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Category { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
// Commands/CreateProductCommand.cs
using MediatR;
public record CreateProductCommand(
string Name,
string Description,
decimal Price,
string Category
) : IRequest<int>; // Returns the new product ID
Notice we're using a C# record for the command. Records are ideal here because commands should be immutable — once created, their data shouldn't change. The IRequest<int> interface tells MediatR this request returns an int (the new product's ID).
Now the handler:
// Handlers/CreateProductHandler.cs
using MediatR;
public class CreateProductHandler : IRequestHandler<CreateProductCommand, int>
{
private readonly AppDbContext _context;
public CreateProductHandler(AppDbContext context)
{
_context = context;
}
public async Task<int> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
Category = request.Category,
CreatedAt = DateTime.UtcNow
};
_context.Products.Add(product);
await _context.SaveChangesAsync(cancellationToken);
return product.Id;
}
}
Each handler has a single responsibility: one command, one handler, one action. This is the core benefit — your business logic lives in focused, testable classes instead of bloated services.
Update and Delete Commands
// Commands/UpdateProductCommand.cs
public record UpdateProductCommand(
int Id,
string Name,
string Description,
decimal Price,
string Category
) : IRequest<bool>;
// Handlers/UpdateProductHandler.cs
public class UpdateProductHandler : IRequestHandler<UpdateProductCommand, bool>
{
private readonly AppDbContext _context;
public UpdateProductHandler(AppDbContext context)
{
_context = context;
}
public async Task<bool> Handle(
UpdateProductCommand request,
CancellationToken cancellationToken)
{
var product = await _context.Products
.FindAsync(new object[] { request.Id }, cancellationToken);
if (product is null)
return false;
product.Name = request.Name;
product.Description = request.Description;
product.Price = request.Price;
product.Category = request.Category;
product.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
Implementing Queries — The Read Side of CQRS
Queries retrieve data without modifying state. In a CQRS example in ASP.NET, queries often return DTOs (Data Transfer Objects) rather than domain entities. This lets you shape the response specifically for the consumer without exposing internal details.
// DTOs/ProductDto.cs
public record ProductDto(
int Id,
string Name,
string Description,
decimal Price,
string Category,
DateTime CreatedAt
);
// Queries/GetProductByIdQuery.cs
using MediatR;
public record GetProductByIdQuery(int Id) : IRequest<ProductDto?>;
// Handlers/GetProductByIdHandler.cs
public class GetProductByIdHandler
: IRequestHandler<GetProductByIdQuery, ProductDto?>
{
private readonly AppDbContext _context;
public GetProductByIdHandler(AppDbContext context)
{
_context = context;
}
public async Task<ProductDto?> Handle(
GetProductByIdQuery request,
CancellationToken cancellationToken)
{
var product = await _context.Products
.FindAsync(new object[] { request.Id }, cancellationToken);
if (product is null)
return null;
return new ProductDto(
product.Id,
product.Name,
product.Description,
product.Price,
product.Category,
product.CreatedAt
);
}
}
// Queries/GetAllProductsQuery.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
public record GetAllProductsQuery(string? Category = null)
: IRequest<List<ProductDto>>;
// Handlers/GetAllProductsHandler.cs
public class GetAllProductsHandler
: IRequestHandler<GetAllProductsQuery, List<ProductDto>>
{
private readonly AppDbContext _context;
public GetAllProductsHandler(AppDbContext context)
{
_context = context;
}
public async Task<List<ProductDto>> Handle(
GetAllProductsQuery request,
CancellationToken cancellationToken)
{
var query = _context.Products.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Category))
query = query.Where(p => p.Category == request.Category);
return await query
.Select(p => new ProductDto(
p.Id, p.Name, p.Description,
p.Price, p.Category, p.CreatedAt))
.ToListAsync(cancellationToken);
}
}
The Controller — Thin and Clean
With MediatR handling the heavy lifting, your controller becomes a thin routing layer:
// Controllers/ProductsController.cs
using MediatR;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] string? category = null)
{
var products = await _mediator.Send(
new GetAllProductsQuery(category));
return Ok(products);
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _mediator.Send(
new GetProductByIdQuery(id));
return product is not null ? Ok(product) : NotFound();
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateProductCommand command)
{
var id = await _mediator.Send(command);
return CreatedAtAction(nameof(GetById), new { id }, null);
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(
int id, [FromBody] UpdateProductCommand command)
{
if (id != command.Id)
return BadRequest("Route ID and body ID must match.");
var result = await _mediator.Send(command);
return result ? NoContent() : NotFound();
}
}
Look how clean that is. The controller knows nothing about Entity Framework, business rules, or data mapping. It sends a request through MediatR and returns the result. This is the mediator pattern in C# at its best.
Pipeline Behaviors — Cross-Cutting Concerns Made Easy
One of MediatR's most powerful features is pipeline behaviors. These are middleware-like components that wrap every request, letting you add logging, validation, or caching without modifying a single handler.
Logging Behavior
// Behaviors/LoggingBehavior.cs
using MediatR;
using System.Diagnostics;
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation("Handling {RequestName}", requestName);
var stopwatch = Stopwatch.StartNew();
var response = await next();
stopwatch.Stop();
_logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName, stopwatch.ElapsedMilliseconds);
return response;
}
}
Validation Behavior with FluentValidation
// Install-Package FluentValidation.DependencyInjectionExtensions
// Validators/CreateProductValidator.cs
using FluentValidation;
public class CreateProductValidator
: AbstractValidator<CreateProductCommand>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required.")
.MaximumLength(200);
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be positive.");
RuleFor(x => x.Category)
.NotEmpty().WithMessage("Category is required.");
}
}
// Behaviors/ValidationBehavior.cs
using FluentValidation;
using MediatR;
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
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)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken))))
.SelectMany(result => result.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
throw new ValidationException(failures);
return await next();
}
}
Register the behaviors in Program.cs:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
Now every command and query automatically gets logged and validated — zero changes to existing handlers.
Notifications — Publishing Domain Events
MediatR also supports notifications, which are one-to-many messages. Unlike requests (one handler), a notification can have multiple handlers — perfect for domain events.
// Notifications/ProductCreatedNotification.cs
using MediatR;
public record ProductCreatedNotification(
int ProductId,
string ProductName) : INotification;
// Handlers/SendWelcomeEmailHandler.cs
public class SendWelcomeEmailHandler
: INotificationHandler<ProductCreatedNotification>
{
private readonly ILogger<SendWelcomeEmailHandler> _logger;
public SendWelcomeEmailHandler(
ILogger<SendWelcomeEmailHandler> logger)
{
_logger = logger;
}
public Task Handle(
ProductCreatedNotification notification,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Product created: {Name} (ID: {Id})",
notification.ProductName, notification.ProductId);
return Task.CompletedTask;
}
}
// Handlers/InvalidateCacheHandler.cs
public class InvalidateCacheHandler
: INotificationHandler<ProductCreatedNotification>
{
public Task Handle(
ProductCreatedNotification notification,
CancellationToken cancellationToken)
{
// Invalidate product list cache
return Task.CompletedTask;
}
}
Publish notifications from your command handler after saving:
// Updated CreateProductHandler
public class CreateProductHandler : IRequestHandler<CreateProductCommand, int>
{
private readonly AppDbContext _context;
private readonly IPublisher _publisher;
public CreateProductHandler(AppDbContext context, IPublisher publisher)
{
_context = context;
_publisher = publisher;
}
public async Task<int> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
Category = request.Category,
CreatedAt = DateTime.UtcNow
};
_context.Products.Add(product);
await _context.SaveChangesAsync(cancellationToken);
await _publisher.Publish(
new ProductCreatedNotification(product.Id, product.Name),
cancellationToken);
return product.Id;
}
}
Common Pitfalls and Best Practices
After implementing CQRS with MediatR in production systems, these are the mistakes I see most often:
1. Don't Inject Handlers Directly
Never bypass MediatR by injecting IRequestHandler directly into a class. You'll skip all pipeline behaviors (validation, logging, etc.) and defeat the purpose of the mediator pattern.
2. Keep Commands and Queries Separate
A query should never modify state. A command should return only what the caller needs to proceed (usually an ID or success flag) — not a full entity. Mixing concerns erodes the benefits of command query separation.
3. Don't Over-Architect
You don't need separate read/write databases to use CQRS. Start simple with the same database and separate command/query models. Add complexity (event sourcing, read replicas) only when you have a concrete need.
4. Organize by Feature, Not by Type
Instead of folders like /Commands, /Queries, /Handlers, group by feature: /Features/Products/CreateProduct/ containing the command, handler, and validator together. This scales much better as your application grows.
5. Use CancellationTokens
Always pass the CancellationToken through to async operations. MediatR provides it — use it. This allows graceful request cancellation when clients disconnect.
Recommended Folder Structure
// Feature-based organization
/Features
/Products
/CreateProduct
CreateProductCommand.cs
CreateProductHandler.cs
CreateProductValidator.cs
/GetProducts
GetAllProductsQuery.cs
GetAllProductsHandler.cs
/GetProductById
GetProductByIdQuery.cs
GetProductByIdHandler.cs
/Orders
/PlaceOrder
PlaceOrderCommand.cs
PlaceOrderHandler.cs
/Behaviors
LoggingBehavior.cs
ValidationBehavior.cs
/Common
ProductDto.cs
Conclusion — CQRS and MediatR in ASP.NET Core
Implementing CQRS with MediatR in ASP.NET Core gives you clean separation of concerns, thin controllers, and a powerful pipeline for cross-cutting concerns. The pattern scales from simple APIs to complex domain-driven designs without requiring architectural rewrites along the way.
Key takeaways:
- Commands handle writes; Queries handle reads — never mix them
- MediatR routes requests to handlers and provides pipeline behaviors for validation, logging, and caching
- Pipeline behaviors eliminate cross-cutting concern duplication across your entire application
- Notifications enable domain event publishing with multiple subscribers
- Start simple — same database, separate models — and add complexity only when needed
- Organize code by feature, not by technical layer, for better maintainability
The combination of CQRS and MediatR is one of the most popular architectural patterns in modern .NET development. Once you structure your first project this way, you'll find it hard to go back to fat controllers and monolithic service layers.
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