.NET Core
CQRS and MediatR in .NET: Separating Commands and Queries
CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates write operations (commands) from read operations (queries) into distinct models. In .NET, MediatR has become the de facto library for orchestrating this separation with explicit handlers, pipeline behaviors, and a clean in-process messaging approach. This article walks through the pattern end-to-end with real code, testing strategies, and hard-earned lessons from production systems.
Why Teams Adopt CQRS
Most applications start with a single model that handles both reads and writes. This works fine for simple CRUD, but as the domain grows, tensions emerge:
- Different optimization needs -- Your read side might benefit from denormalized projections and caching, while your write side needs strict validation and transactional consistency. A single model forces compromise on both.
- Clearer intent -- When a developer opens a
CreateOrderCommand, the intent is immediately obvious. Compare that to a genericOrderServicewith 30 methods mixing reads, writes, and side effects. - Independent scalability -- In many systems, reads outnumber writes 10:1 or more. CQRS lets you scale each side independently.
- Event-driven alignment -- CQRS pairs naturally with event-driven architectures and event sourcing, where the write side emits events and the read side builds projections from them.
In domain-heavy projects I've worked on, the biggest win was not performance -- it was developer clarity. New team members could navigate the codebase by intent rather than by guessing which service method did what.
Core Concepts
Commands
Commands represent an intention to change system state. They should be named in the imperative: CreateOrder, CancelSubscription, UpdateShippingAddress. A command either succeeds or fails -- it should not return rich data.
public record CreateOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items,
string ShippingAddress
) : IRequest<CreateOrderResult>;
public record CreateOrderResult(
Guid OrderId,
string Status
);Queries
Queries retrieve data without side effects. They are named descriptively: GetOrderById, ListActiveSubscriptions. Queries return DTOs or read models optimized for the consumer.
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDetailDto>;
public record OrderDetailDto(
Guid OrderId,
string CustomerName,
List<OrderItemDto> Items,
decimal TotalAmount,
string Status,
DateTime CreatedAt
);Handlers
Each command or query has exactly one handler. This keeps each handler focused on a single responsibility.
public class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateOrderCommandHandler(
IOrderRepository orderRepository,
IUnitOfWork unitOfWork)
{
_orderRepository = orderRepository;
_unitOfWork = unitOfWork;
}
public async Task<CreateOrderResult> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
var order = Order.Create(
request.CustomerId,
request.Items.Select(i => new OrderItem(i.ProductId, i.Quantity, i.UnitPrice)),
request.ShippingAddress
);
await _orderRepository.AddAsync(order, cancellationToken);
await _unitOfWork.CommitAsync(cancellationToken);
return new CreateOrderResult(order.Id, order.Status.ToString());
}
}public class GetOrderByIdQueryHandler
: IRequestHandler<GetOrderByIdQuery, OrderDetailDto>
{
private readonly IReadOnlyOrderRepository _readRepository;
public GetOrderByIdQueryHandler(IReadOnlyOrderRepository readRepository)
{
_readRepository = readRepository;
}
public async Task<OrderDetailDto> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
var dto = await _readRepository.GetOrderDetailAsync(
request.OrderId, cancellationToken);
return dto ?? throw new OrderNotFoundException(request.OrderId);
}
}Pipeline Behaviors
One of MediatR's most powerful features is pipeline behaviors. They act like middleware, wrapping every request that flows through the mediator. This is where cross-cutting concerns belong -- not in your handlers.
Validation Behavior
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();
}
}Logging Behavior
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}: {@Request}", requestName, request);
var stopwatch = Stopwatch.StartNew();
var response = await next();
stopwatch.Stop();
_logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName, stopwatch.ElapsedMilliseconds);
return response;
}
}Performance Monitoring Behavior
public class PerformanceBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<PerformanceBehavior<TRequest, TResponse>> _logger;
private const int WarningThresholdMs = 500;
public PerformanceBehavior(
ILogger<PerformanceBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var response = await next();
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > WarningThresholdMs)
{
_logger.LogWarning(
"Long-running request: {RequestName} ({ElapsedMs}ms) - {@Request}",
typeof(TRequest).Name,
stopwatch.ElapsedMilliseconds,
request);
}
return response;
}
}Registration and Setup
Bringing it all together in your DI container:
// Program.cs or Startup.cs
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
// Pipeline behaviors execute in registration order
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>));
});
// Register FluentValidation validators
builder.Services.AddValidatorsFromAssembly(typeof(CreateOrderCommand).Assembly);A minimal controller then looks like this:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderCommand command,
CancellationToken ct)
{
var result = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetById), new { id = result.OrderId }, result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var result = await _mediator.Send(new GetOrderByIdQuery(id), ct);
return Ok(result);
}
}Event Sourcing Introduction
CQRS and event sourcing are often mentioned together, and for good reason -- they complement each other naturally, though they are not the same thing.
In a traditional CQRS setup, the write model persists the current state to a database. With event sourcing, instead of storing the current state, you store the sequence of events that led to that state. Every OrderCreated, ItemAdded, OrderShipped event is appended to an event store, and the current state is rebuilt by replaying those events.
// Domain events emitted by the Order aggregate
public record OrderCreatedEvent(
Guid OrderId,
Guid CustomerId,
DateTime CreatedAt
) : INotification;
public record OrderItemAddedEvent(
Guid OrderId,
Guid ProductId,
int Quantity,
decimal UnitPrice
) : INotification;
public record OrderShippedEvent(
Guid OrderId,
string TrackingNumber,
DateTime ShippedAt
) : INotification;The read side subscribes to these events and builds optimized projections:
public class OrderSummaryProjection
: INotificationHandler<OrderCreatedEvent>,
INotificationHandler<OrderShippedEvent>
{
private readonly IOrderSummaryStore _store;
public OrderSummaryProjection(IOrderSummaryStore store) => _store = store;
public async Task Handle(
OrderCreatedEvent notification,
CancellationToken cancellationToken)
{
await _store.CreateSummaryAsync(new OrderSummary
{
OrderId = notification.OrderId,
CustomerId = notification.CustomerId,
Status = "Created",
CreatedAt = notification.CreatedAt
}, cancellationToken);
}
public async Task Handle(
OrderShippedEvent notification,
CancellationToken cancellationToken)
{
await _store.UpdateStatusAsync(
notification.OrderId, "Shipped", cancellationToken);
}
}The key advantage: you get a complete audit trail for free, and you can build new read models retroactively by replaying events. The trade-off is increased complexity in event versioning, snapshotting for performance, and eventual consistency between write and read sides.
In domain-heavy projects I've worked on, event sourcing proved invaluable for auditing and debugging -- being able to replay the exact sequence of events that led to a bug saved hours of investigation.
Testing CQRS Handlers
One of the strongest arguments for CQRS with MediatR is testability. Each handler is a standalone class with explicit dependencies, making unit tests straightforward.
Testing a Command Handler
public class CreateOrderCommandHandlerTests
{
private readonly Mock<IOrderRepository> _repoMock;
private readonly Mock<IUnitOfWork> _uowMock;
private readonly CreateOrderCommandHandler _handler;
public CreateOrderCommandHandlerTests()
{
_repoMock = new Mock<IOrderRepository>();
_uowMock = new Mock<IUnitOfWork>();
_handler = new CreateOrderCommandHandler(
_repoMock.Object, _uowMock.Object);
}
[Fact]
public async Task Handle_ValidCommand_CreatesOrderAndCommits()
{
// Arrange
var command = new CreateOrderCommand(
CustomerId: Guid.NewGuid(),
Items: new List<OrderItemDto>
{
new(ProductId: Guid.NewGuid(), Quantity: 2, UnitPrice: 29.99m)
},
ShippingAddress: "123 Main St"
);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.OrderId.Should().NotBeEmpty();
result.Status.Should().Be("Pending");
_repoMock.Verify(
r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
Times.Once);
_uowMock.Verify(
u => u.CommitAsync(It.IsAny<CancellationToken>()),
Times.Once);
}
}Testing a Pipeline Behavior
public class ValidationBehaviorTests
{
[Fact]
public async Task Handle_InvalidRequest_ThrowsValidationException()
{
// Arrange
var validator = new InlineValidator<CreateOrderCommand>();
validator.RuleFor(x => x.Items).NotEmpty();
var behavior = new ValidationBehavior<CreateOrderCommand, CreateOrderResult>(
new[] { validator });
var invalidCommand = new CreateOrderCommand(
Guid.NewGuid(), new List<OrderItemDto>(), "123 Main St");
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(
() => behavior.Handle(
invalidCommand,
() => Task.FromResult(new CreateOrderResult(Guid.NewGuid(), "ok")),
CancellationToken.None));
}
[Fact]
public async Task Handle_ValidRequest_CallsNext()
{
// Arrange
var behavior = new ValidationBehavior<CreateOrderCommand, CreateOrderResult>(
Enumerable.Empty<IValidator<CreateOrderCommand>>());
var expected = new CreateOrderResult(Guid.NewGuid(), "Pending");
var nextCalled = false;
// Act
var result = await behavior.Handle(
new CreateOrderCommand(Guid.NewGuid(), new List<OrderItemDto>
{
new(Guid.NewGuid(), 1, 10m)
}, "addr"),
() =>
{
nextCalled = true;
return Task.FromResult(expected);
},
CancellationToken.None);
// Assert
nextCalled.Should().BeTrue();
result.Should().Be(expected);
}
}Integration Testing with MediatR
public class OrderIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public OrderIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task CreateOrder_EndToEnd_ReturnsCreatedOrder()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var command = new CreateOrderCommand(
Guid.NewGuid(),
new List<OrderItemDto> { new(Guid.NewGuid(), 3, 15.00m) },
"456 Oak Ave"
);
// Act
var result = await mediator.Send(command);
// Assert -- verify through the query side
var order = await mediator.Send(new GetOrderByIdQuery(result.OrderId));
order.Should().NotBeNull();
order.TotalAmount.Should().Be(45.00m);
}
}When CQRS Is Overkill
CQRS adds real structural complexity. Here is an honest decision framework:
Skip CQRS when:
- Your application is straightforward CRUD with minimal business rules
- The team is small and does not need architectural boundaries to stay organized
- Read and write complexity is roughly equal -- there is no asymmetry to exploit
- You are building a prototype or MVP where speed of delivery matters more than long-term structure
- The domain has fewer than 5-10 aggregates
Consider CQRS when:
- Read and write models have fundamentally different shapes or performance needs
- You have complex business workflows with many validation rules and state transitions
- Multiple teams work on the same domain and need clear boundaries
- You need an audit trail or plan to introduce event sourcing
- Your read side requires denormalized views, search indexes, or materialized projections
In domain-heavy projects I've worked on, the tipping point was usually around 15-20 command/query pairs. Below that, the overhead of separate models, handlers, and pipeline infrastructure was not worth the clarity gains.
Common CQRS Mistakes
1. Returning Rich Data from Commands
Commands should return minimal information -- an ID, a status, or nothing at all. If your CreateOrderCommand returns the full order detail DTO, you are blurring the read/write boundary.
// Avoid this
public record CreateOrderCommand(...) : IRequest<OrderDetailDto>;
// Prefer this
public record CreateOrderCommand(...) : IRequest<CreateOrderResult>;
public record CreateOrderResult(Guid OrderId, string Status);2. Fat Handlers
If your handler is 200 lines long, the handler is doing too much. Move domain logic into domain entities or domain services. The handler is an orchestrator, not a business logic container.
// Avoid: business logic in the handler
public async Task<Result> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
// 150 lines of validation, calculation, and state management...
}
// Prefer: handler orchestrates, domain model contains logic
public async Task<Result> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Items, cmd.ShippingAddress);
await _repository.AddAsync(order, ct);
await _unitOfWork.CommitAsync(ct);
return new Result(order.Id);
}3. Using MediatR for Everything
MediatR is a tool for in-process messaging. Do not use it to replace simple method calls between classes in the same bounded context. If class A always calls class B, a direct dependency is clearer than routing through a mediator.
4. Skipping the Pipeline
Adding validation, logging, and error handling directly in each handler defeats the purpose. Use pipeline behaviors to centralize cross-cutting concerns. Otherwise, you end up with duplicated boilerplate across dozens of handlers.
5. Ignoring Eventual Consistency
If your read model is built from events or projections, it will lag behind the write model. Your UI and API consumers need to be designed for this. Returning a 200 from a command does not mean the read side is updated yet.
Conclusion
CQRS with MediatR can dramatically improve clarity, testability, and scalability in complex .NET applications. The pattern shines when there is genuine asymmetry between reads and writes, when the domain is rich enough to justify separate models, and when the team values explicit intent over implicit convention. But it is not a default choice -- it is an architectural investment that should be driven by real complexity.
Start small: introduce MediatR for a few commands in the most complex part of your domain. If the clarity gains justify the overhead, expand from there. If they do not, you have learned something valuable about your system.
I can help evaluate whether CQRS fits your domain and plan a pragmatic rollout strategy.
Related Articles
Clean Architecture in .NET: Building Scalable Project Structure
Apply Clean Architecture in .NET projects. A guide to layers, dependency management, and testable code.
Dependency Injection in .NET: Core Concepts and Implementation
Understand and implement Dependency Injection in .NET correctly. Service lifetimes, registration patterns, and best practices.
Microservices Architecture with .NET: Design and Implementation
Design microservices architecture with .NET. Service communication, Docker, and orchestration strategies.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch