Back to blog
Backend Systemsintermediate

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.

LearnixoApril 13, 20267 min read
.NETC#CQRSMediatRClean ArchitectureASP.NET Core
Share:š•

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      → GetOrdersQueryHandler

The 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

Bash
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection  # MediatR < 12
# MediatR 12+ includes DI support natively

Register in Program.cs:

C#
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)
);

For multi-project solutions (e.g., Application layer in a separate project):

C#
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

C#
// 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

C#
// 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

C#
[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

C#
// 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

C#
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)

Bash
dotnet add package FluentValidation.DependencyInjectionExtensions
C#
// 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:

C#
// 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

C#
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

C#
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

C#
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.

C#
// 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:

C#
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.cs

Each 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:

C#
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 + INotificationHandler replace 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.