As enterprise applications scale, traditional monolithic services often suffer from domain models that try to serve two opposing masters: high-performance, complex write transactions and lightning-fast read operations. Trying to optimise a single data model for both scenarios leads to bloated codebases, complex database joins, and rigid maintenance bottlenecks.
CQRS (Command Query Responsibility Segregation) solves this architectural dilemma by separating read operations from write operations. Combined with the Mediator pattern (implemented via the popular MediatR library), you can decouple your application components, eliminate tight controller dependencies, and cleanly inject cross-cutting concerns like validation and logging.
This article walks through a complete, production-grade implementation of CQRS and MediatR in an ASP.NET Core Web API.
1. Understanding CQRS & The Mediator Pattern
Commands: Represent state-changing write operations (Create, Update, Delete). They do not return heavy data models; instead, they typically return success indicators, status codes, or generated IDs.
Queries: Represent pure read operations (GetById, ListWithPagination). They are side-effect free and optimized for query projection.
The Mediator Pattern: Instead of controllers talking directly to dozens of distinct services and repositories, they talk to a single mediator. The mediator dispatches requests to their respective handlers, decoupling the sender completely from the receiver.
2. Step 1: Setting Up Packages and Structure
To implement MediatR in an ASP.NET Core solution, install the required NuGet packages into your Application layer:
Shell
dotnet add package MediatR
dotnet add package FluentValidation.DependencyInjectionExtensions
Suggested Project Directory Layout
Plaintext
Application/
│
├── Orders/
│ ├── Commands/
│ │ ├── CreateOrderCommand.cs
│ │ └── CreateOrderCommandHandler.cs
│ ├── Queries/
│ │ ├── GetOrderByIdQuery.cs
│ │ └── GetOrderByIdQueryHandler.cs
│ └── DTOs/
│ └── OrderDto.cs
└── Common/
└── Behaviors/
└── ValidationBehavior.cs
3. Step 2: Implementing Write Operations (Commands)
Let’s build a command to create an order. The command holds the data, and the command handler contains the business logic.
C#
// Application/Orders/Commands/CreateOrderCommand.cs
using MediatR;
namespace Application.Orders.Commands;
public record CreateOrderCommand(string CustomerEmail, List<OrderItemDto> Items) : IRequest<int>;
public record OrderItemDto(int ProductId, int Quantity, decimal UnitPrice);
Next, implement the corresponding handler. This handler interacts with your data persistence layer to save the new order and returns the generated identifier.
C#
// Application/Orders/Commands/CreateOrderCommandHandler.cs
using MediatR;
namespace Application.Orders.Commands;
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
// Inject your database context or repository interface here
private readonly IApplicationDbContext _context;
public CreateOrderCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<int> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
// 1. Map command to Domain Entity (or aggregate root)
var order = new Order(request.CustomerEmail);
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
// 2. Persist to database
_context.Orders.Add(order);
await _context.SaveChangesAsync(cancellationToken);
// 3. Return the new entity ID
return order.Id;
}
}
4. Step 3: Implementing Read Operations (Queries)
Queries are optimized exclusively for fetching data, bypassing domain logic rules and mapping directly to read-optimized DTOs using projection (e.g., EF Core .Select()).
C#
// Application/Orders/Queries/GetOrderByIdQuery.cs
using Application.Orders.DTOs;
using MediatR;
namespace Application.Orders.Queries;
public record GetOrderByIdQuery(int Id) : IRequest<OrderDto?>;
C#
// Application/Orders/Queries/GetOrderByIdQueryHandler.cs
using Application.Orders.DTOs;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Application.Orders.Queries;
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IApplicationDbContext _context;
public GetOrderByIdQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<OrderDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
{
// Direct projection to DTO for optimal read performance
return await _context.Orders
.Where(o => o.Id == request.Id)
.Select(o => new OrderDto(
o.Id,
o.CustomerEmail,
o.TotalAmount,
o.CreatedAt,
o.Items.Select(i => new OrderItemDetailDto(i.ProductId, i.Quantity, i.UnitPrice)).ToList()
))
.FirstOrDefaultAsync(cancellationToken);
}
}
5. Step 4: Adding Cross-Cutting Concerns via Pipeline Behaviors
One of MediatR's most powerful features is Pipeline Behaviors (akin to ASP.NET Core middleware). You can intercept every command or query to execute shared logic like validation, logging, or transaction management.
Here is how to implement automatic request validation using FluentValidation:
C#
// Application/Common/Behaviors/ValidationBehavior.cs
using FluentValidation;
using MediatR;
namespace Application.Common.Behaviors;
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)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
}
// Proceed to the next behavior or the actual command/query handler
return await next();
}
}
6. Step 5: Exposing Endpoints via API Controllers
With MediatR, your API controllers become exceptionally thin. They no longer inject multiple business services or repositories; they only inject ISender.
C#
// WebApi/Controllers/OrdersController.cs
using Application.Orders.Commands;
using Application.Orders.DTOs;
using Application.Orders.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly ISender _sender;
public OrdersController(ISender sender)
{
_sender = sender;
}
[HttpPost]
public async Task<ActionResult<int>> Create([FromBody] CreateOrderCommand command, CancellationToken cancellationToken)
{
var orderId = await _sender.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = orderId }, orderId);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id, CancellationToken cancellationToken)
{
var query = new GetOrderByIdQuery(id);
var order = await _sender.Send(query, cancellationToken);
if (order == null)
return NotFound();
return Ok(order);
}
}
Registering MediatR in Program.cs
C#
// WebApi/Program.cs
using Application.Common.Behaviors;
using FluentValidation;
using MediatR;
var builder = WebApplication.CreateBuilder(args);
// Register MediatR handlers from the Application assembly
builder.Services.AddMediatR(cfg => {
cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
// Register pipeline behaviors
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
// Register FluentValidation validators automatically
builder.Services.AddValidatorsFromAssembly(typeof(CreateOrderCommand).Assembly);
builder.Services.AddControllers();
// ... other service configurations
Conclusion
By adopting CQRS and MediatR in your ASP.NET Core Web API, you achieve modular separation of concerns. Your controllers shrink to simple routers, write use cases are isolated in dedicated command handlers, read workflows are optimized through clean projections, and cross-cutting concerns like validation execute transparently via pipeline behaviors.

Join the conversation! Your thoughts help the community grow.