CQRS with MediatR in .NET: A Production Guide
Implement CQRS using MediatR in ASP.NET Core ā commands, queries, handlers, pipeline behaviors for validation and logging, and how it maps to Clean Architecture.
What is CQRS?
CQRS (Command Query Responsibility Segregation) separates operations that change state (commands) from operations that read state (queries).
Traditional: OrderService.CreateOrder() ā same service does everything
OrderService.GetOrders()
CQRS: CreateOrderCommand ā CreateOrderCommandHandler
GetOrdersQuery ā GetOrdersQueryHandlerThe benefits:
- Each handler has one job ā easy to test, easy to reason about
- Commands and queries can scale independently (separate read/write DBs if needed)
- Pipeline behaviors give you cross-cutting concerns (validation, logging, caching) without touching handlers
- Clean separation maps perfectly onto Clean Architecture's Application layer
Setting Up MediatR
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection # MediatR < 12
# MediatR 12+ includes DI support nativelyRegister in Program.cs:
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)
);For multi-project solutions (e.g., Application layer in a separate project):
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly)
);Commands
A command represents an intent to change state. It returns either nothing or a minimal result (like a created ID).
Defining a Command
// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(
int CustomerId,
List<OrderItemDto> Items,
string DeliveryAddress
) : IRequest<int>; // returns the new order's ID
public record OrderItemDto(int ProductId, int Quantity, decimal UnitPrice);Command Handler
// Application/Orders/Commands/CreateOrder/CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
private readonly IAppDbContext _db;
private readonly ICurrentUserService _currentUser;
private readonly IDateTime _dateTime;
public CreateOrderCommandHandler(
IAppDbContext db,
ICurrentUserService currentUser,
IDateTime dateTime)
{
_db = db;
_currentUser = currentUser;
_dateTime = dateTime;
}
public async Task<int> Handle(CreateOrderCommand request, CancellationToken ct)
{
// Validate business rules
var customer = await _db.Customers.FindAsync(request.CustomerId, ct)
?? throw new NotFoundException(nameof(Customer), request.CustomerId);
// Build the aggregate
var order = new Order
{
CustomerId = request.CustomerId,
Reference = GenerateReference(),
Status = OrderStatus.Pending,
DeliveryAddress = request.DeliveryAddress,
CreatedAt = _dateTime.UtcNow,
CreatedBy = _currentUser.UserId,
Items = request.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
}).ToList()
};
order.TotalAmount = order.Items.Sum(i => i.Quantity * i.UnitPrice);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return order.Id;
}
private static string GenerateReference()
=> $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString()[..6].ToUpper()}";
}Controller ā sending the command
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(CreateOrderCommand command, CancellationToken ct)
{
var orderId = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetById), new { id = orderId }, new { id = orderId });
}
}Queries
A query reads state and returns data. It should never modify state.
Defining a Query + DTO
// Application/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs
public record GetOrderByIdQuery(int OrderId) : IRequest<OrderDetailDto>;
public record OrderDetailDto(
int Id,
string Reference,
string Status,
decimal TotalAmount,
string CustomerName,
List<OrderItemDetailDto> Items
);
public record OrderItemDetailDto(
string ProductName,
int Quantity,
decimal UnitPrice,
decimal LineTotal
);Query Handler
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDetailDto>
{
private readonly IAppDbContext _db;
public GetOrderByIdQueryHandler(IAppDbContext db) => _db = db;
public async Task<OrderDetailDto> Handle(GetOrderByIdQuery request, CancellationToken ct)
{
var order = await _db.Orders
.AsNoTracking()
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.FirstOrDefaultAsync(o => o.Id == request.OrderId, ct)
?? throw new NotFoundException(nameof(Order), request.OrderId);
return new OrderDetailDto(
order.Id,
order.Reference,
order.Status.ToString(),
order.TotalAmount,
order.Customer.Name,
order.Items.Select(i => new OrderItemDetailDto(
i.Product.Name,
i.Quantity,
i.UnitPrice,
i.Quantity * i.UnitPrice
)).ToList()
);
}
}Pipeline Behaviors
Pipeline behaviors wrap every IMediator.Send() call ā like middleware for your handlers. They run before and after the handler.
IMediator.Send(command)
ā ValidationBehavior
ā LoggingBehavior
ā PerformanceBehavior
ā YourHandler.Handle()Validation Behavior (with FluentValidation)
dotnet add package FluentValidation.DependencyInjectionExtensions// Application/Common/Behaviours/ValidationBehaviour.cs
public class ValidationBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!_validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}Command validator:
// Application/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(c => c.CustomerId).GreaterThan(0);
RuleFor(c => c.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(c => c.Items).ChildRules(item =>
{
item.RuleFor(i => i.Quantity).GreaterThan(0);
item.RuleFor(i => i.UnitPrice).GreaterThan(0);
item.RuleFor(i => i.ProductId).GreaterThan(0);
});
RuleFor(c => c.DeliveryAddress).NotEmpty().MaximumLength(500);
}
}Logging Behavior
public class LoggingBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;
private readonly ICurrentUserService _currentUser;
public LoggingBehaviour(
ILogger<LoggingBehaviour<TRequest, TResponse>> logger,
ICurrentUserService currentUser)
{
_logger = logger;
_currentUser = currentUser;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
var userId = _currentUser.UserId ?? "anonymous";
_logger.LogInformation(
"Handling {RequestName} for user {UserId}: {@Request}",
requestName, userId, request);
var response = await next();
_logger.LogInformation("Handled {RequestName}", requestName);
return response;
}
}Performance Behavior
public class PerformanceBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly Stopwatch _timer = new();
private readonly ILogger<PerformanceBehaviour<TRequest, TResponse>> _logger;
public PerformanceBehaviour(ILogger<PerformanceBehaviour<TRequest, TResponse>> logger)
=> _logger = logger;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
_timer.Restart();
var response = await next();
_timer.Stop();
var elapsed = _timer.ElapsedMilliseconds;
if (elapsed > 500)
{
_logger.LogWarning(
"Slow request: {RequestName} ({Elapsed}ms) {@Request}",
typeof(TRequest).Name, elapsed, request);
}
return response;
}
}Register Behaviors
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>));
});
// FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);Notifications (Events)
MediatR also handles domain events via INotification. One event, multiple handlers.
// Define
public record OrderCreatedNotification(int OrderId, int CustomerId) : INotification;
// Handler 1: Send confirmation email
public class SendOrderConfirmationEmailHandler
: INotificationHandler<OrderCreatedNotification>
{
private readonly IEmailService _email;
public SendOrderConfirmationEmailHandler(IEmailService email) => _email = email;
public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
{
await _email.SendOrderConfirmationAsync(notification.CustomerId, notification.OrderId, ct);
}
}
// Handler 2: Update inventory
public class ReserveInventoryHandler : INotificationHandler<OrderCreatedNotification>
{
public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
{
// reduce stock levels...
}
}Publish from the command handler:
await _db.SaveChangesAsync(ct);
// After successful save, publish the event
await _mediator.Publish(new OrderCreatedNotification(order.Id, order.CustomerId), ct);
return order.Id;Folder Structure
src/
āāā Application/
āāā Common/
ā āāā Behaviours/
ā ā āāā ValidationBehaviour.cs
ā ā āāā LoggingBehaviour.cs
ā ā āāā PerformanceBehaviour.cs
ā āāā Exceptions/
ā ā āāā NotFoundException.cs
ā ā āāā ValidationException.cs
ā āāā Interfaces/
ā āāā IAppDbContext.cs
ā āāā ICurrentUserService.cs
ā āāā IEmailService.cs
āāā Orders/
āāā Commands/
ā āāā CreateOrder/
ā ā āāā CreateOrderCommand.cs
ā ā āāā CreateOrderCommandHandler.cs
ā ā āāā CreateOrderCommandValidator.cs
ā āāā DeleteOrder/
ā āāā DeleteOrderCommand.cs
ā āāā DeleteOrderCommandHandler.cs
āāā Queries/
āāā GetOrderById/
ā āāā GetOrderByIdQuery.cs
ā āāā GetOrderByIdQueryHandler.cs
āāā GetOrdersList/
āāā GetOrdersListQuery.cs
āāā GetOrdersListQueryHandler.csEach feature is fully self-contained. A new developer can navigate to Orders/Commands/CreateOrder and read the entire feature in three files.
Testing a Handler
Handlers are plain classes ā no HTTP, no framework wiring:
public class CreateOrderCommandHandlerTests
{
[Fact]
public async Task Handle_ValidCommand_ReturnsNewOrderId()
{
// Arrange
var db = new AppDbContextFactory().CreateInMemory();
db.Customers.Add(new Customer { Id = 1, Name = "Alice" });
await db.SaveChangesAsync();
var handler = new CreateOrderCommandHandler(
db,
new FakeCurrentUserService("user-1"),
new FakeDateTime(DateTime.UtcNow)
);
var command = new CreateOrderCommand(
CustomerId: 1,
Items: [new(ProductId: 10, Quantity: 2, UnitPrice: 25m)],
DeliveryAddress: "123 Main St"
);
// Act
var orderId = await handler.Handle(command, CancellationToken.None);
// Assert
orderId.Should().BeGreaterThan(0);
var order = await db.Orders.FindAsync(orderId);
order.Should().NotBeNull();
order!.TotalAmount.Should().Be(50m);
}
}Key Takeaways
- Commands change state, queries read it ā they never mix
- Each
IRequestHandler<TRequest, TResponse>does exactly one thing ā no bloated service classes - Pipeline behaviors are the right place for validation, logging, caching, and transaction management ā not inside handlers
INotification+INotificationHandlerreplace method calls for domain events, enabling multiple decoupled side effects per event- Handlers are pure classes ā test them directly without ASP.NET Core or any HTTP wiring
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.