Implementing Command and Query Responsibility Segregation (CQRS) in .NET Core with MediatR

In modern scalable applications, separating commands (operations that change state) from queries (operations that read state) is a proven approach to improve maintainability, scalability, and clarity. This pattern is known as Command and Query Responsibility Segregation (CQRS).

In this post, we’ll explore how to implement CQRS in .NET Core applications using MediatR, a popular in-process messaging library that simplifies building this pattern cleanly.


What is CQRS and Why Use It?

Traditional CRUD apps often mix read and write logic in the same models and services, which can lead to:

  • Complex code that’s hard to maintain.
  • Difficult scaling when reads and writes have different load patterns.
  • Challenges in optimizing queries or commands independently.

CQRS solves this by segregating the models:

  • Commands: Handle state changes (Create, Update, Delete).
  • Queries: Handle data retrieval only.

This separation allows optimizing each side independently, improves clarity, and often pairs well with event sourcing or eventual consistency in complex domains.


Introducing MediatR

MediatR is a simple mediator library for .NET that helps implement CQRS by:

  • Defining requests (commands/queries) as objects.
  • Decoupling sender and handler via mediator.
  • Simplifying pipeline behaviors (logging, validation, etc.).

Example: Building a CQRS-based Product API

Let’s create a minimal example where we separate commands and queries for a product catalog.

1. Setup MediatR

Add MediatR to your .NET Core project:

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

In Startup.cs or Program.cs (for minimal APIs):

builder.Services.AddMediatR(typeof(Program));  // Registers MediatR handlers from this assembly

2. Define Commands and Queries

Command: CreateProduct

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

This command carries data for creating a product and expects the created product’s ID as a result.

Query: GetProductById

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

This query requests product data by ID.


3. Implement Handlers

Handlers contain business logic and data access.

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

    public CreateProductHandler(AppDbContext dbContext) => _dbContext = dbContext;

    public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = new Product { Name = request.Name, Price = request.Price };
        _dbContext.Products.Add(product);
        await _dbContext.SaveChangesAsync(cancellationToken);
        return product.Id;
    }
}

public class GetProductByIdHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
{
    private readonly AppDbContext _dbContext;

    public GetProductByIdHandler(AppDbContext dbContext) => _dbContext = dbContext;

    public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
    {
        var product = await _dbContext.Products.FindAsync(request.Id);
        if (product == null) return null;

        return new ProductDto { Id = product.Id, Name = product.Name, Price = product.Price };
    }
}

4. Use MediatR in Controllers or Minimal APIs

In a controller:

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

    public ProductsController(IMediator mediator) => _mediator = mediator;

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

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

Benefits in Larger Systems

CQRS scales well when:

  • Reads and writes have different performance/scaling requirements.
  • Complex domains require event sourcing or domain-driven design.
  • Multiple read models are needed for different UI views or APIs.

Using MediatR keeps your codebase clean, testable, and decoupled, simplifying feature evolution.


Summary

CQRS is a powerful pattern to manage complexity and scalability in modern .NET applications. Leveraging MediatR makes implementing CQRS straightforward, with minimal ceremony and excellent integration into .NET Core’s DI and async workflows.

If your application is growing in complexity or facing scaling challenges, introducing CQRS with MediatR could be the clean architectural upgrade you need.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *