.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

BenefitDescription
Separation of ConcernsCommands modify, Queries read
TestabilityEasy to unit test each component
ExtensibilityAdd behaviors without changing handlers
Single ResponsibilityEach handler does one thing
Clear IntentMethod names clearly show intent
Reduced CouplingController doesn't know handler details

Azure Integration Hub - Advanced Level