As applications scale, controllers often fall victim to the "fat controller" anti-pattern. They accumulate hundreds of lines of code, mixing business logic, database queries, DTO mapping, and validation rules into a single unmaintainable file. Furthermore, traditional CRUD approaches treat reads and writes the same way, even though reads usually require optimized projections and writes require strict business rule validation.

To solve this, modern enterprise systems pair Clean Architecture with CQRS (Command Query Responsibility Segregation) and MediatR.

In this comprehensive guide, we will explore how CQRS and MediatR work together to decouple your application layers, keep your controllers razor-thin, and make your codebase exceptionally testable and scalable.

What is CQRS and MediatR?

  • CQRS: Splits your data operations into two distinct categories:

    • Commands: Write operations that mutate state (Create, Update, Delete) and typically return nothing or an ID.

    • Queries: Read operations that fetch data without modifying state and return DTOs.

  • MediatR: An implementation of the Mediator pattern in .NET. It facilitates in-process messaging, decoupling your API controllers from your business logic handlers so that controllers simply act as thin dispatchers.

Step 1: Install MediatR in the Application Layer

Commands and queries belong in your Application layer because they represent business use cases.

Navigate to your YourSolution.Application project and install the core MediatR package:

Bash

dotnet add package MediatR

(Note: In modern versions of MediatR, dependency injection extensions are bundled directly into the core package).

Step 2: Organize by Feature Folders

Instead of grouping files by technical role (putting all services together or all classes together), organize your Application layer by features. This keeps everything related to a specific domain action localized:

Plaintext

YourSolution.Application/
├── Features/
│   └── Products/
│       ├── Commands/
│       │   ├── CreateProductCommand.cs
│       │   └── CreateProductCommandHandler.cs
│       └── Queries/
│           ├── GetProductByIdQuery.cs
│           └── GetProductByIdQueryHandler.cs
└── DependencyInjection.cs

Step 3: Implement a Command (Write Operation)

Commands represent actions that change state. We use C# record types for immutability, paired with a dedicated handler that executes the logic.

1. The Command Record

C#

using MediatR;

namespace YourSolution.Application.Features.Products.Commands
{
    public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;
}

2. The Command Handler

The handler contains the business rules and interacts with domain entities and repository interfaces.

C#

using MediatR;
using YourSolution.Domain.Entities;
using YourSolution.Domain.Interfaces;

namespace YourSolution.Application.Features.Products.Commands
{
    public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Guid>
    {
        private readonly IProductRepository _productRepository;

        public CreateProductCommandHandler(IProductRepository productRepository)
        {
            _productRepository = productRepository;
        }

        public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
        {
            // 1. Instantiate domain entity (encapsulating business rules)
            var product = new Product(request.Name, request.Price);

            // 2. Persist via repository interface
            await _productRepository.AddAsync(product);
            await _productRepository.UnitOfWork.SaveChangesAsync(cancellationToken);

            // 3. Return the generated ID
            return product.Id;
        }
    }
}

Step 4: Implement a Query (Read Operation)

Queries fetch data without mutating state and typically return lightweight DTOs.

1. The Query Record

C#

using MediatR;
using YourSolution.Application.DTOs;

namespace YourSolution.Application.Features.Products.Queries
{
    public record GetProductByIdQuery(Guid Id) : IRequest<ProductDto>;
}

2. The Query Handler

C#

using MediatR;
using YourSolution.Application.DTOs;
using YourSolution.Domain.Interfaces;

namespace YourSolution.Application.Features.Products.Queries
{
    public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
    {
        private readonly IProductRepository _productRepository;

        public GetProductByIdQueryHandler(IProductRepository productRepository)
        {
            _productRepository = productRepository;
        }

        public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
        {
            var product = await _productRepository.GetByIdAsync(request.Id);
            
            if (product == null)
                throw new KeyNotFoundException($"Product with ID {request.Id} was not found.");

            // Map domain model to a lightweight DTO
            return new ProductDto(product.Id, product.Name, product.Price);
        }
    }
}

Step 5: Register MediatR in Dependency Injection

To make MediatR discover your command and query handlers automatically, add an extension method inside your Application layer:

C#

using Microsoft.Extensions.DependencyInjection;

namespace YourSolution.Application
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddApplication(this IServiceCollection services)
        {
            // Scans the assembly and registers all IRequestHandler implementations
            services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

            return services;
        }
    }
}

Ensure this is invoked in your API's Program.cs: builder.Services.AddApplication();.

Step 6: Keep API Controllers Thin

With CQRS and MediatR handling the heavy lifting, your API controllers become remarkably streamlined. They no longer contain business logic or database dependencies; instead, they simply inject IMediator and dispatch requests.

C#

using MediatR;
using Microsoft.AspNetCore.Mvc;
using YourSolution.Application.Features.Products.Commands;
using YourSolution.Application.Features.Products.Queries;

namespace YourSolution.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        private readonly IMediator _mediator;

        public ProductsController(IMediator mediator)
        {
            _mediator = mediator;
        }

        [HttpPost]
        public async Task<IActionResult> Create([FromBody] CreateProductCommand command)
        {
            var productId = await _mediator.Send(command);
            return CreatedAtAction(nameof(GetById), new { id = productId }, productId);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetById(Guid id)
        {
            var product = await _mediator.Send(new GetProductByIdQuery(id));
            return Ok(product);
        }
    }
}

Summary of Benefits

By combining Clean Architecture with CQRS and MediatR, you achieve:

  • Single Responsibility Principle: Every command and query handler has exactly one job and one reason to change.

  • Extreme Testability: You can unit test any command or query handler in isolation without spinning up an ASP.NET Core web server or database.

  • Scalability: Read models and write models can evolve independently as your application requirements grow.