.NET Microservices — CQRS + MediatR in .NET 8
Commands, Queries, Handlers, Pipeline Behaviors
Introduction
CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read and write operations into different models. MediatR is a mediator pattern implementation that helps decouple request handlers from the controllers, enabling clean, testable code.
This combination provides:
- Clear separation — Commands modify state, Queries read state
- Single responsibility — Each handler does one thing
- Testability — Easy to unit test handlers
- Extensibility — Pipeline behaviors add cross-cutting concerns
- Mediator pattern — Loose coupling between components
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT REQUEST │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ CONTROLLER │
│ │
│ [HttpPost] CreateOrder │
│ [HttpGet] GetOrder │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ MEDIATR │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ COMMAND / QUERY DISPATCHER │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ PIPELINE BEHAVIORS │ │
│ │ • LoggingBehavior │ │
│ │ • ValidationBehavior │ │
│ │ • TransactionBehavior │ │
│ │ • ExceptionHandlingBehavior │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────┼───────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ HANDLER │ │ HANDLER │
│ (Command) │ │ (Query) │
│ │ │ │
│ CreateOrderHandler │ │ GetOrderHandler │
└─────────────────────┘ └─────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ DATABASE │ │ DATABASE │
│ (Write Model) │ │ (Read Model) │
└─────────────────────┘ └─────────────────────┘
Project Setup
Install Packages
# Core MediatR
dotnet add package MediatR
# DI Container
dotnet add package Microsoft.Extensions.DependencyInjection
# Validation
dotnet add package FluentValidation
# ASP.NET Core integration
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Define Commands and Queries
Commands (Write Operations)
// Command: Create a new order
public record CreateOrderCommand(
string CustomerId,
List<OrderItemDto> Items,
string ShippingAddress,
string Notes
) : IRequest<OrderDto>;
// Command: Update order status
public record UpdateOrderStatusCommand(
Guid OrderId,
OrderStatus NewStatus,
string Reason
) : IRequest<bool>;
// Command: Cancel order
public record CancelOrderCommand(
Guid OrderId,
string Reason
) : IRequest<bool>;
// Command: Add item to order
public record AddOrderItemCommand(
Guid OrderId,
string ProductId,
int Quantity,
decimal UnitPrice
) : IRequest<OrderDto>;
Queries (Read Operations)
// Query: Get order by ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;
// Query: Get all orders for customer
public record GetCustomerOrdersQuery(
string CustomerId,
int Page = 1,
int PageSize = 20
) : IRequest<PagedResult<OrderDto>>;
// Query: Get order with items
public record GetOrderWithItemsQuery(Guid OrderId) : IRequest<OrderDetailsDto?>;
// Query: Search orders
public record SearchOrdersQuery(
string SearchTerm,
OrderStatus? Status,
DateTime? FromDate,
DateTime? ToDate
) : IRequest<List<OrderDto>>;
Command Handlers
Create Order Handler
public class CreateOrderHandler :
IRequestHandler<CreateOrderCommand, OrderDto>
{
private readonly IOrderRepository _orderRepository;
private readonly IInventoryService _inventoryService;
private readonly IEventPublisher _eventPublisher;
private readonly ILogger<CreateOrderHandler> _logger;
public CreateOrderHandler(
IOrderRepository orderRepository,
IInventoryService inventoryService,
IEventPublisher eventPublisher,
ILogger<CreateOrderHandler> logger)
{
_orderRepository = orderRepository;
_inventoryService = inventoryService;
_eventPublisher = eventPublisher;
_logger = logger;
}
public async Task<OrderDto> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Creating order for customer {CustomerId}",
request.CustomerId);
// 1. Validate inventory availability
var inventoryChecks = await _inventoryService
.CheckAvailabilityAsync(request.Items.Select(i => i.ProductId).ToList());
if (!inventoryChecks.All(c => c.Available))
{
var unavailable = inventoryChecks.Where(c => !c.Available)
.Select(c => c.ProductId);
throw new ValidationException($"Products not available: {string.Join(", ", unavailable)}");
}
// 2. Create order entity
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
ShippingAddress = request.ShippingAddress,
Notes = request.Notes,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
// 3. Add items
foreach (var item in request.Items)
{
order.Items.Add(new OrderItem
{
Id = Guid.NewGuid(),
ProductId = item.ProductId,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice,
LineTotal = item.Quantity * item.UnitPrice
});
}
order.TotalAmount = order.Items.Sum(i => i.LineTotal);
// 4. Save to database
await _orderRepository.CreateAsync(order);
// 5. Reserve inventory
await _inventoryService.ReserveInventoryAsync(
order.Id,
order.Items.Select(i => new Reservation { ProductId = i.ProductId, Quantity = i.Quantity }).ToList());
// 6. Publish domain event
await _eventPublisher.PublishAsync(new OrderCreatedEvent(order.Id, order.CustomerId));
_logger.LogInformation("Order {OrderId} created successfully", order.Id);
// 7. Return order DTO
return MapToDto(order);
}
private static OrderDto MapToDto(Order order)
{
return new OrderDto
{
Id = order.Id,
CustomerId = order.CustomerId,
Status = order.Status,
TotalAmount = order.TotalAmount,
CreatedAt = order.CreatedAt,
Items = order.Items.Select(i => new OrderItemDto
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
LineTotal = i.LineTotal
}).ToList()
};
}
}
Query Handlers
Get Order Handler
public class GetOrderByIdHandler :
IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IOrderReadRepository _readRepository;
public GetOrderByIdHandler(IOrderReadRepository readRepository)
{
_readRepository = readRepository;
}
public async Task<OrderDto?> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
var order = await _readRepository.GetByIdAsync(request.OrderId);
if (order == null)
{
return null;
}
return new OrderDto
{
Id = order.Id,
CustomerId = order.CustomerId,
Status = order.Status,
TotalAmount = order.TotalAmount,
CreatedAt = order.CreatedAt,
Items = order.Items.Select(i => new OrderItemDto
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
LineTotal = i.LineTotal
}).ToList()
};
}
}
Pipeline Behaviors
Logging Behavior
public class LoggingBehavior<TRequest, TResponse> :
IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
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();
try
{
var response = await next();
stopwatch.Stop();
_logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName,
stopwatch.ElapsedMilliseconds);
return response;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(
ex,
"Error handling {RequestName} after {ElapsedMs}ms",
requestName,
stopwatch.ElapsedMilliseconds);
throw;
}
}
}
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 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.Any())
{
_logger.LogWarning(
"Validation failed for {RequestName}: {Failures}",
typeof(TRequest).Name,
string.Join(", ", failures.Select(f => f.ErrorMessage)));
throw new ValidationException(failures);
}
return await next();
}
}
Transaction Behavior
public class TransactionBehavior<TRequest, TResponse> :
IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
IUnitOfWork unitOfWork,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Only use transaction for commands (not queries)
if (typeof(TRequest).Name.EndsWith("Query"))
{
return await next();
}
try
{
await _unitOfWork.BeginTransactionAsync(cancellationToken);
var response = await next();
await _unitOfWork.CommitTransactionAsync(cancellationToken);
return response;
}
catch (Exception ex)
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
_logger.LogError(ex, "Transaction failed for {RequestName}",
typeof(TRequest).Name);
throw;
}
}
}
Register in DI Container
Program.cs / Startup.cs
// Configure MediatR
services.AddMediatR(cfg =>
{
// Register from assembly containing handlers
cfg.RegisterServicesFromAssembly(typeof(CreateOrderHandler).Assembly);
// Add pipeline behaviors (in order)
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ExceptionHandlingBehavior<,>));
});
// Add FluentValidation
services.AddValidatorsFromAssembly(typeof(CreateOrderValidator).Assembly);
Use in Controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder(
[FromBody] CreateOrderCommand command)
{
var result = await _mediator.Send(command);
return CreatedAtAction(
nameof(GetOrder),
new { id = result.Id },
result);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
{
var result = await _mediator.Send(new GetOrderByIdQuery(id));
if (result == null)
{
return NotFound();
}
return Ok(result);
}
[HttpPut("{id:guid}/status")]
public async Task<IActionResult> UpdateStatus(
Guid id,
[FromBody] UpdateOrderStatusCommand command)
{
if (id != command.OrderId)
{
return BadRequest();
}
var result = await _mediator.Send(command);
return result ? NoContent() : NotFound();
}
[HttpGet("customer/{customerId}")]
public async Task<ActionResult<PagedResult<OrderDto>>> GetCustomerOrders(
string customerId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await _mediator.Send(new GetCustomerOrdersQuery(
customerId, page, pageSize));
return Ok(result);
}
}
Validation
FluentValidation Setup
public class CreateOrderCommandValidator :
AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.WithMessage("Customer ID is required");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must have at least one item");
RuleForEach(x => x.Items)
.SetValidator(new OrderItemDtoValidator());
RuleFor(x => x.ShippingAddress)
.NotEmpty()
.MaximumLength(500);
RuleFor(x => x.Items.Sum(i => i.Quantity * i.UnitPrice))
.LessThan(100000)
.When(x => x.Items.Any())
.WithMessage("Order total cannot exceed $100,000");
}
}
public class OrderItemDtoValidator :
AbstractValidator<OrderItemDto>
{
public OrderItemDtoValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty();
RuleFor(x => x.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(1000);
RuleFor(x => x.UnitPrice)
.GreaterThan(0);
}
}
Benefits of CQRS + MediatR
| Benefit | Description |
|---|---|
| Separation of Concerns | Commands modify, Queries read |
| Testability | Easy to unit test each component |
| Extensibility | Add behaviors without changing handlers |
| Single Responsibility | Each handler does one thing |
| Clear Intent | Method names clearly show intent |
| Reduced Coupling | Controller doesn't know handler details |
Azure Integration Hub - Advanced Level