Blog Backend & APIs
ARCHITECTURE

Implementing MediatR & CQRS Pattern with .NET 6 API

Separating reads from writes with CQRS and wiring it up cleanly using MediatR — the pattern we reach for once a .NET API's business logic starts outgrowing its controllers.

Mar 21, 2024

Controllers have a way of quietly turning into dumping grounds. A method starts as five lines calling a repository and ends up, six months later, orchestrating validation, three service calls, and a couple of side effects nobody wants to touch. CQRS gives us a structural answer to that drift, and MediatR is the library that makes wiring it up in a .NET 6 API almost boilerplate-free.

CQRS in a Sentence

Command Query Responsibility Segregation just means you stop treating "read" and "write" operations as the same kind of thing. Commands change state and typically return little more than an ID or a success signal; queries read state and never mutate it. Once you separate them explicitly, each side is free to evolve independently — queries can be optimized with projections or a read-only replica, commands can carry validation and domain rules, and neither has to compromise to share a code path with the other.

Where MediatR Fits

MediatR implements the mediator pattern: instead of a controller calling a service directly, it sends a request object into a mediator, which finds the one handler registered for that request's type and invokes it. The controller never knows or cares what class handles the work. Registration is one line in Program.cs.

csharp
// Program.cs
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

// Commands/CreateOrderCommand.cs
public record CreateOrderCommand(int CustomerId, List<OrderLineDto> Lines)
    : IRequest<int>;

public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
    private readonly AppDbContext _db;

    public CreateOrderCommandHandler(AppDbContext db) => _db = db;

    public async Task<int> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Lines = request.Lines.Select(l => new OrderLine
            {
                ProductId = l.ProductId,
                Quantity = l.Quantity
            }).ToList()
        };

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

        return order.Id;
    }
}

// Queries/GetOrderByIdQuery.cs
public record GetOrderByIdQuery(int OrderId) : IRequest<OrderDto?>;

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly AppDbContext _db;

    public GetOrderByIdQueryHandler(AppDbContext db) => _db = db;

    public Task<OrderDto?> Handle(GetOrderByIdQuery request, CancellationToken ct) =>
        _db.Orders
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDto(o.Id, o.CustomerId, o.Lines.Count))
            .FirstOrDefaultAsync(ct);
}

The controller (or minimal API endpoint) collapses down to a couple of lines that just forward the request and translate the result into an HTTP response.

csharp
[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)
    {
        var orderId = await _mediator.Send(command);
        return CreatedAtAction(nameof(GetById), new { id = orderId }, null);
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id)
    {
        var order = await _mediator.Send(new GetOrderByIdQuery(id));
        return order is not null ? Ok(order) : NotFound();
    }
}

Why Bother

The payoff is not the two lines you save in the controller, it is what this structure forces onto every new feature: one request, one handler, one clearly named file, discoverable by convention. Cross-cutting concerns like logging, validation, and transaction wrapping become MediatR pipeline behaviors that run around every handler without any of them knowing about it, instead of getting copy-pasted into every controller method by hand.

  • Commands mutate state and return minimal data (an ID, a bool, or void); queries read state and never mutate it
  • One IRequestHandler per request type keeps business logic out of controllers entirely
  • IPipelineBehavior<TRequest,TResponse> is where validation, logging, and transaction handling live without touching individual handlers
  • FluentValidation integrates cleanly as a pipeline behavior that runs before the handler executes

Do not reach for MediatR on a CRUD service with five endpoints and no real business logic — you will just be adding a layer of indirection over what used to be a direct repository call. It earns its keep once handlers start doing more than a single SaveChangesAsync, or once you need shared behavior like validation and logging across dozens of request types.

Wrapping Up

CQRS plus MediatR will not fix a bad domain model, but it gives a growing codebase a consistent place to put logic instead of letting it accumulate wherever a controller method happens to live. Once the team gets used to "new feature means new command or query file," onboarding and code review both get noticeably faster.

CQRS MediatR .NET 6

More from the blog

View all articles

Read something that fits your problem?

Tell us what you're building. We'll come back with an honest, scoped recommendation — not a sales pitch.

Start a Project