DEV Community

Cover image for 🧱 Mastering CQRS with MediatR in C#
Elmer Chacon
Elmer Chacon

Posted on

🧱 Mastering CQRS with MediatR in C#

CQRS (Command Query Responsibility Segregation) is a powerful architectural pattern that helps scale and organize modern applications, especially in the context of Domain-Driven Design (DDD). Combined with MediatR, a popular .NET library for implementing the Mediator pattern, CQRS becomes clean, maintainable, and testable.

In this blog, we'll explore:

  • What is CQRS?
  • Why use MediatR?
  • Implementing CQRS with MediatR in C#
  • Folder structure best practices
  • Examples: Create, Read, Update
  • Validation and error handling
  • Dependency injection and testing tips

🔍 What is CQRS?

CQRS stands for Command Query Responsibility Segregation. It separates:

  • Commands: write operations that change state (e.g., Create, Update, Delete)
  • Queries: read operations that fetch data

This separation allows each side to scale independently, apply different optimizations, and follow different architectural principles (e.g., write = strict validation, read = performance-focused).

🧭 Why MediatR?

MediatR allows decoupling request handling logic using the Mediator pattern:

  • Commands and Queries are simple C# objects (POCOs)
  • Handlers encapsulate the logic
  • No service locator, no static dependencies

dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

🏗️ Recommended Folder Structure

Folders Structure

✍️ Command Example: Create Product

Step 1 – Define the Command

public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;
Enter fullscreen mode Exit fullscreen mode

Step 2 – Create the Handler

public class CreateProductHandler : IRequestHandler<CreateProductCommand, Guid>
{
    private readonly IProductRepository _repository;

    public CreateProductHandler(IProductRepository repository)
    {
        _repository = repository;
    }

    public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = new Product
        {
            Id = Guid.NewGuid(),
            Name = request.Name,
            Price = request.Price,
            CreatedAt = DateTime.UtcNow
        };

        await _repository.AddAsync(product);
        return product.Id;
    }
}

Enter fullscreen mode Exit fullscreen mode

Step 3 – Use It in a Controller

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

Enter fullscreen mode Exit fullscreen mode

🔎 Query Example: Get Product By ID

Step 1 – Define the Query

public record GetProductByIdQuery(Guid Id) : IRequest<ProductDto>;
Enter fullscreen mode Exit fullscreen mode

Step 2 – Create the Handler

public class GetProductByIdHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
{
    private readonly IProductRepository _repository;

    public GetProductByIdHandler(IProductRepository repository)
    {
        _repository = repository;
    }

    public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
    {
        var product = await _repository.GetByIdAsync(request.Id);

        if (product == null)
            throw new NotFoundException(nameof(Product), request.Id);

        return new ProductDto(product.Id, product.Name, product.Price);
    }
}

Enter fullscreen mode Exit fullscreen mode

✅ Best Practices

1. Separation of Concerns
Don't put domain logic in handlers.

Keep handlers thin—delegate to services or aggregates if needed.

2. Validation with FluentValidation

public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Price).GreaterThan(0);
    }
}

Enter fullscreen mode Exit fullscreen mode

Register validators and use the pipeline to validate automatically:

services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

Enter fullscreen mode Exit fullscreen mode

ValidationBehavior Example:

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)
    {
        var context = new ValidationContext<TRequest>(request);

        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(result => result.Errors)
            .Where(f => f != null)
            .ToList();

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

        return await next();
    }
}

Enter fullscreen mode Exit fullscreen mode

3. Use DTOs – Don’t Expose Entities
Avoid returning domain models directly. Instead, return DTOs like:

public record ProductDto(Guid Id, string Name, decimal Price);
Enter fullscreen mode Exit fullscreen mode

4. Keep Handlers Simple and Focused
Bad:

  • Does Validation
  • Mapping
  • Domain Logic
  • Logging

Good:

  • Validate (via pipeline)
  • Call service/repo
  • Return result

5. Unit Testing Handlers

[Fact]
public async Task CreateProduct_Should_Return_Id()
{
    // Arrange
    var mockRepo = new Mock<IProductRepository>();
    var handler = new CreateProductHandler(mockRepo.Object);

    var command = new CreateProductCommand("Test", 10);

    // Act
    var result = await handler.Handle(command, CancellationToken.None);

    // Assert
    Assert.IsType<Guid>(result);
    mockRepo.Verify(r => r.AddAsync(It.IsAny<Product>()), Times.Once);
}

Enter fullscreen mode Exit fullscreen mode

🧠 Wrap-Up

CQRS + MediatR = Powerful, clean, and testable architecture.

It helps you:

  • Organize codebase by behavior
  • Reduce complexity and duplication
  • Facilitate testing and scaling
  • Introduce pipelines for validation, logging, and metrics

Start small. Even if your app doesn’t need full CQRS, using MediatR for commands and queries can enforce structure and testability from day one.

🚀 Ready to Try?
You can bootstrap this pattern in any clean architecture app using:

dotnet new webapi -n YourApp
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
dotnet add package FluentValidation

Then start building commands, queries, handlers, and enjoy a maintainable codebase!

Top comments (0)