Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 024 / 100

CQRS with MediatR in practice: best practices explained

Separate commands from queries without building two databases.

Expona Admin8 min read

CQRS with MediatR in practice: best practices explained. Separate commands from queries without building two databases.

Why CQRS matters for .NET teams

Command Query Responsibility Segregation (CQRS) with MediatR in practice represents one of the most effective ways to keep handler logic clean and focused as your application grows. Instead of creating massive repository methods that do both reading and writing, you split them into two distinct paths: commands that change state, and queries that read it. This separation doesn't require two databases or complex infrastructure—just a disciplined folder structure and a lightweight dispatcher like MediatR to route requests to the right handler.

Over the past few years, we've watched teams adopt CQRS patterns across e-commerce platforms, CRM systems, and trading applications. The pattern works because it forces you to think clearly: does this operation mutate data or just fetch it? Once you answer that question, the handler design almost writes itself.

Commands: state changes, minimal returns

A command is an imperative instruction—create an order, cancel a subscription, update an inventory count. Commands always return something small: typically just an ID, a success flag, or nothing at all. The handler's job is to validate input, execute the change, and persist it. No queries. No side-channel reads. Just focused mutation.

Here's a real example from an e-commerce context:

csharp
public record CreateOrder(int CustomerId, List<OrderLineDto> Lines)
  : IRequest<int>;

public class CreateOrderHandler(AppDb db, ILogger<CreateOrderHandler> log)
  : IRequestHandler<CreateOrder, int>
{
  public async Task<int> Handle(CreateOrder cmd, CancellationToken ct)
  {
    // Validate
    if (cmd.Lines.Count == 0)
      throw new InvalidOperationException("Order must have at least one line");

    // Create aggregate
    var order = new Order
    {
      CustomerId = cmd.CustomerId,
      CreatedAt = DateTime.UtcNow,
      Status = OrderStatus.Pending
    };

    foreach (var line in cmd.Lines)
    {
      order.AddLine(line.ProductId, line.Quantity, line.UnitPrice);
    }

    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);

    log.LogInformation("Order {OrderId} created for customer {CustomerId}",
      order.Id, cmd.CustomerId);

    return order.Id;
  }
}

Notice what this handler does: it validates, constructs an aggregate, adds lines, saves, and returns only the ID. It doesn't check inventory levels by itself, doesn't email the customer, doesn't query related tables. A command handler is laser-focused. Side effects—like sending emails or publishing events—belong in pipeline behaviors, not in the command handler itself.

Common command patterns we use:

  • CreateX — Inserts a new aggregate, returns its ID
  • UpdateX — Modifies an existing aggregate, returns void or a status
  • DeleteX — Removes data, often returns a boolean success flag
  • ApproveX — State transitions with validation, returns void or a new status

Queries: optimized reads, no side effects

Queries are the complement: fetch data without changing anything. A query handler reads from whatever source is most efficient—often a denormalized projection, a cached view, or even a separate read database if you've grown to that point. The beauty is that query handlers don't care about domain rules the way command handlers do. If you need to return a flat DTO with nested relationships already flattened, go ahead. That's exactly what queries are for.

Here's a query handler following the code idea from the outline:

csharp
public record GetOrder(int Id) : IRequest<OrderDto?>;

public class GetOrderHandler(AppDb db)
  : IRequestHandler<GetOrder, OrderDto?>
{
  public Task<OrderDto?> Handle(GetOrder q, CancellationToken ct) =>
    db.Orders
      .Where(o => o.Id == q.Id)
      .Select(o => new OrderDto
      {
        Id = o.Id,
        CustomerId = o.CustomerId,
        CreatedAt = o.CreatedAt,
        Total = o.Lines.Sum(l => l.Quantity * l.UnitPrice),
        LineCount = o.Lines.Count,
        Status = o.Status.ToString()
      })
      .FirstOrDefaultAsync(ct);
}

public record OrderDto(
  int Id,
  int CustomerId,
  DateTime CreatedAt,
  decimal Total,
  int LineCount,
  string Status);

This query is pure: it reads, it projects to a DTO, and it returns a result or null. No exceptions thrown for validation failures—if the order doesn't exist, you get null. The caller decides what to do with that. Query handlers scale well because you can add caching, add database indexes, or even swap out the entire data source without changing the calling code.

List queries are equally simple:

csharp
public record ListOrders(int CustomerId, int PageSize = 20, int Page = 1)
  : IRequest<PagedResult<OrderSummaryDto>>;

public class ListOrdersHandler(AppDb db)
  : IRequestHandler<ListOrders, PagedResult<OrderSummaryDto>>
{
  public async Task<PagedResult<OrderSummaryDto>> Handle(
    ListOrders q, CancellationToken ct)
  {
    var query = db.Orders
      .Where(o => o.CustomerId == q.CustomerId)
      .OrderByDescending(o => o.CreatedAt);

    var total = await query.CountAsync(ct);

    var items = await query
      .Skip((q.Page - 1) * q.PageSize)
      .Take(q.PageSize)
      .Select(o => new OrderSummaryDto(
        o.Id,
        o.CreatedAt,
        o.Status.ToString(),
        o.Lines.Sum(l => l.Quantity * l.UnitPrice)))
      .ToListAsync(ct);

    return new PagedResult<OrderSummaryDto>(items, total, q.Page, q.PageSize);
  }
}

Notice pagination is built into the query itself. Filtering, sorting, paging—all declarative and testable. This is where CQRS shines: your read-side logic is decoupled from write-side validation, so you can optimize freely.

The real benefit: focused handlers and pipeline behaviors

The core value of CQRS with MediatR in practice isn't the pattern itself—it's what the pattern forces you to do. When every handler is small and single-purpose, you can understand it in 30 seconds. Tests are trivial: mock the database, call Handle, assert the result. No hidden side effects to debug.

Cross-cutting concerns belong in pipeline behaviors, not sprinkled through handler code. Here's a validation behavior that works for all commands:

csharp
public class ValidationBehavior<TRequest, TResponse>
  : IPipelineBehavior<TRequest, TResponse>
  where TRequest : notnull
{
  private readonly IEnumerable<IValidator<TRequest>> validators;

  public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
  {
    this.validators = validators;
  }

  public async Task<TResponse> Handle(
    TRequest request,
    RequestHandlerDelegate<TResponse> next,
    CancellationToken ct)
  {
    var context = new ValidationContext<TRequest>(request);
    var failures = new List<ValidationFailure>();

    foreach (var validator in validators)
    {
      var result = await validator.ValidateAsync(context, ct);
      failures.AddRange(result.Errors);
    }

    if (failures.Count != 0)
      throw new ValidationException(failures);

    return await next();
  }
}

Register it once in the DI container, and every request flows through it. If you need logging, tracing, or transaction wrapping, another behavior handles it. The command handler stays clean. Test each behavior in isolation. This layered approach keeps your application maintainable as it grows.

Common mistakes

Mistake 1: Query handlers that do writes. We've seen teams query inside a query handler to "check" something, then update a flag based on the result. That breaks the contract. If you need to mutate data, create a command. If the query handler is reading to compute a derived value for the response, that's fine—but don't change the database.

Mistake 2: Massive command handlers. If a command handler is over 50 lines, it's probably doing too much. Break it into smaller handlers or extract logic to domain services. A command handler should read like a screenplay: validate, construct, save, done.

Mistake 3: Ignoring CancellationToken. Every async operation should flow the CancellationToken through. It costs nothing and saves you from hung requests in production. The pattern makes this obvious: every Handle signature includes it.

Mistake 4: Treating queries like commands. Query handlers should be lightweight and idempotent. If running the same query twice gives different results because of side effects, you've built a command in disguise.

Mistake 5: Skipping domain validation. Commands are where business rules live. Don't push validation to the behavior pipeline and leave command handlers naive. The handler should validate its own input and enforce invariants.

The licensing question: MediatR and alternatives

MediatR moved to a commercial license in 2025. If your team is already on older versions or if licensing is a barrier, a lightweight in-house dispatcher is entirely viable. The core pattern—routing requests to handlers via a registry—is simple enough to implement in a few dozen lines:

csharp
public interface IRequestHandler<TRequest, TResponse>
{
  Task<TResponse> Handle(TRequest request, CancellationToken ct);
}

public class SimpleDispatcher
{
  private readonly IServiceProvider services;

  public SimpleDispatcher(IServiceProvider services)
  {
    this.services = services;
  }

  public async Task<TResponse> Send<TRequest, TResponse>(
    TRequest request, CancellationToken ct = default)
  {
    var handlerType = typeof(IRequestHandler<,>)
      .MakeGenericType(typeof(TRequest), typeof(TResponse));

    var handler = services.GetRequiredService(handlerType);
    var method = handlerType.GetMethod("Handle");
    var result = method?.Invoke(handler, new object[] { request, ct });

    return await (Task<TResponse>)result!;
  }
}

If you don't need behaviors or decorators, this is all you need. Your command and query handlers remain identical—only the dispatcher changes. Many teams, especially those on budget-conscious deployments, build this route. The pattern is what matters; the framework is secondary.

Key takeaways

  • Commands mutate state and return minimal data; queries read state with no side effects. The separation forces clarity on every operation.
  • Keep command handlers focused: validate, mutate, save, return. Extract side effects to pipeline behaviors or domain services.
  • Query handlers should be optimized for read performance. Flatten DTOs, use projections, add indexes—no apologies needed.
  • Pipeline behaviors handle cross-cutting concerns (validation, logging, transactions, tracing) at the framework level, keeping handlers pristine.
  • Handler tests are fast and straightforward: mock dependencies, call Handle, assert output. No framework magic to debug.
  • CQRS with MediatR in practice isn't about building complex systems—it's about building systems that scale in code clarity, not infrastructure.
  • If MediatR's licensing is a concern, a simple dispatcher is a legitimate alternative. The pattern transcends any single framework.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles