ASP.NET Core  

Implementing CQRS and Mediator Pattern in ASP.NET Core using MediatR

Introduction

Modern enterprise applications often demand scalability, maintainability, and clear separation of concerns. As projects grow, a single service layer handling both queries and commands can become unmanageable and tightly coupled.
This is where CQRS (Command Query Responsibility Segregation) and the Mediator pattern come into play — simplifying complex logic and improving architecture clarity.

In this article, we’ll explore how to implement CQRS with the Mediator pattern in an ASP.NET Core application using the MediatR library, complete with practical examples and best practices.

1. Understanding CQRS

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read (query) and write (command) operations in your application.

  • Command → performs an action (creates, updates, deletes data)

  • Query → retrieves data (read-only operations)

By separating them, you get:

  • Better maintainability and scalability

  • Cleaner business logic

  • Easier testing and debugging

2. Mediator Pattern Overview

The Mediator pattern helps reduce direct dependencies between classes. Instead of having components call each other directly, they communicate through a mediator.

In .NET, MediatR is a popular library that provides an elegant way to implement the Mediator pattern. It allows you to handle commands and queries centrally without tight coupling.

3. Setting Up the ASP.NET Core Project

Step 1: Install MediatR

Install the required NuGet packages:

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

Step 2: Register MediatR in Program.cs

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

This registers all MediatR handlers in your current assembly.

4. Project Structure Example

A typical CQRS structure using MediatR looks like this:

/Application
   /Commands
      CreateProductCommand.cs
      UpdateProductCommand.cs
   /Queries
      GetProductByIdQuery.cs
      GetAllProductsQuery.cs
   /Handlers
      CreateProductHandler.cs
      GetProductByIdHandler.cs
/Domain
   Product.cs
/Infrastructure
   AppDbContext.cs
/Controllers
   ProductsController.cs

5. Implementing the Command

Commands are used to change state — e.g., create or update data.

CreateProductCommand.cs

using MediatR;

public record CreateProductCommand(string Name, decimal Price) : IRequest<int>;

This defines a command that creates a product and returns the new product ID.

6. Command Handler Implementation

CreateProductHandler.cs

using MediatR;

public class CreateProductHandler : IRequestHandler<CreateProductCommand, int>
{
    private readonly AppDbContext _context;

    public CreateProductHandler(AppDbContext context)
    {
        _context = context;
    }

    public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = new Product
        {
            Name = request.Name,
            Price = request.Price
        };

        _context.Products.Add(product);
        await _context.SaveChangesAsync(cancellationToken);
        return product.Id;
    }
}

7. Implementing a Query

Queries are for data retrieval only — no side effects.

GetProductByIdQuery.cs

using MediatR;

public record GetProductByIdQuery(int Id) : IRequest<Product>;

GetProductByIdHandler.cs

using MediatR;
using Microsoft.EntityFrameworkCore;

public class GetProductByIdHandler : IRequestHandler<GetProductByIdQuery, Product>
{
    private readonly AppDbContext _context;

    public GetProductByIdHandler(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Product> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
    {
        return await _context.Products.FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken);
    }
}

8. Domain and DbContext

Product.cs

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

AppDbContext.cs

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products { get; set; }
}

9. Controller Example

ProductsController.cs

using MediatR;
using Microsoft.AspNetCore.Mvc;

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

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

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

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var product = await _mediator.Send(new GetProductByIdQuery(id));
        if (product == null)
            return NotFound();

        return Ok(product);
    }
}

10. Benefits of Using CQRS + MediatR

Separation of concerns: commands and queries are cleanly divided.
Maintainable code: easier to add new handlers without breaking existing logic.
Testability: each command/query can be unit tested independently.
Scalability: different persistence or caching strategies can be applied per operation.
Extensibility: behaviors like logging, validation, and transaction management can be added using MediatR pipelines.

11. Advanced Example — Adding a Validation Behavior

You can apply cross-cutting concerns globally using MediatR Pipeline Behaviors.

ValidationBehavior.cs

using MediatR;
using FluentValidation;

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, 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(r => r.Errors)
            .Where(f => f != null)
            .ToList();

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

        return await next();
    }
}

Register in Program.cs:

builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

This automatically applies validation to all incoming MediatR requests.

12. Best Practices

  • Use commands only for state-changing operations.

  • Use queries for reads — no side effects.

  • Organize handlers by feature folders (per entity or use case).

  • Implement FluentValidation for consistent input validation.

  • Add logging and exception handling behaviors via MediatR pipelines.

  • Keep handlers thin — delegate business rules to domain services.

Conclusion

The combination of CQRS and the Mediator pattern using MediatR provides a clean, modular, and scalable architecture for ASP.NET Core applications.
It enforces a clear separation between reads and writes, removes tight coupling between layers, and enables easier maintenance as your application grows.

By adopting this approach, you can build enterprise-grade systems that are easier to test, extend, and evolve — perfectly aligning with modern architectural principles.