Skip to main content

CQRS with MediatR in ASP.NET Core — Full Guide 2026

Learn CQRS with MediatR in ASP.NET Core. Step-by-step tutorial with code examples, best practices, and real-world patterns. Start building today!

CQRS with MediatR in ASP.NET Core — The Complete Guide

If you've been building ASP.NET Core applications for a while, you've probably noticed how controllers bloat with business logic over time. MediatR in ASP.NET Core solves this by implementing the mediator pattern, which pairs perfectly with the CQRS pattern in C# to create clean, testable, and maintainable applications. In this tutorial, you'll learn how to implement command query separation from scratch with practical, runnable code examples.

CQRS — Command Query Responsibility Segregation — is an architectural pattern that separates read operations (queries) from write operations (commands). Instead of using the same model and service layer for both reads and writes, you create dedicated paths for each. MediatR acts as the glue, routing commands and queries to their respective handlers without your controllers needing to know the details.

Why Use CQRS with MediatR in ASP.NET Core?

Before we dive into code, let's understand why this pattern exists and when it makes sense.

In a traditional CRUD application, your controller calls a service, which calls a repository, all using the same model. This works fine for simple apps. But as complexity grows, you run into problems:

  • Fat controllers — Business logic leaks into controllers, making them untestable
  • Rigid service layers — A single service class handling reads and writes becomes a God class
  • Cross-cutting concerns — Logging, validation, and caching get duplicated everywhere
  • Scaling bottlenecks — You can't optimize reads and writes independently

The CQRS pattern addresses all of these. Commands handle writes (create, update, delete) and return minimal data. Queries handle reads and can be optimized independently — different database views, caching strategies, or even separate read databases. MediatR provides the in-process messaging infrastructure to wire it all together.

When CQRS Is Worth It (and When It Isn't)

CQRS adds structure, but it also adds files. For a simple CRUD app with five entities and no complex business rules, it's overkill. Use it when:

  • Your domain has complex business logic that differs between reads and writes
  • Multiple developers work on the same codebase and need clear boundaries
  • You need different optimization strategies for reads vs. writes
  • You want pipeline behaviors (logging, validation, caching) applied consistently

Setting Up MediatR in ASP.NET Core

Let's build a product management API step by step. First, create a new ASP.NET Core Web API project and install the required NuGet package:

// Install via Package Manager Console
// Install-Package MediatR

// Or via .NET CLI
// dotnet add package MediatR

Register MediatR in your Program.cs:

using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddMediatR(cfg => 
    cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthorization();
app.MapControllers();
app.Run();

The RegisterServicesFromAssembly call scans the assembly for all IRequest and IRequestHandler implementations and registers them in the DI container automatically.

Implementing Commands — The Write Side of CQRS

Commands represent intentions to change state. They should be named as imperative verbs: CreateProduct, UpdatePrice, DeleteOrder. Let's start with the domain model and a create command.

// Models/Product.cs
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string Category { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
}
// Commands/CreateProductCommand.cs
using MediatR;

public record CreateProductCommand(
    string Name,
    string Description,
    decimal Price,
    string Category
) : IRequest<int>;  // Returns the new product ID

Notice we're using a C# record for the command. Records are ideal here because commands should be immutable — once created, their data shouldn't change. The IRequest<int> interface tells MediatR this request returns an int (the new product's ID).

Now the handler:

// Handlers/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,
            Description = request.Description,
            Price = request.Price,
            Category = request.Category,
            CreatedAt = DateTime.UtcNow
        };

        _context.Products.Add(product);
        await _context.SaveChangesAsync(cancellationToken);

        return product.Id;
    }
}

Each handler has a single responsibility: one command, one handler, one action. This is the core benefit — your business logic lives in focused, testable classes instead of bloated services.

Update and Delete Commands

// Commands/UpdateProductCommand.cs
public record UpdateProductCommand(
    int Id,
    string Name,
    string Description,
    decimal Price,
    string Category
) : IRequest<bool>;

// Handlers/UpdateProductHandler.cs
public class UpdateProductHandler : IRequestHandler<UpdateProductCommand, bool>
{
    private readonly AppDbContext _context;

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

    public async Task<bool> Handle(
        UpdateProductCommand request, 
        CancellationToken cancellationToken)
    {
        var product = await _context.Products
            .FindAsync(new object[] { request.Id }, cancellationToken);

        if (product is null)
            return false;

        product.Name = request.Name;
        product.Description = request.Description;
        product.Price = request.Price;
        product.Category = request.Category;
        product.UpdatedAt = DateTime.UtcNow;

        await _context.SaveChangesAsync(cancellationToken);
        return true;
    }
}

Implementing Queries — The Read Side of CQRS

Queries retrieve data without modifying state. In a CQRS example in ASP.NET, queries often return DTOs (Data Transfer Objects) rather than domain entities. This lets you shape the response specifically for the consumer without exposing internal details.

// DTOs/ProductDto.cs
public record ProductDto(
    int Id,
    string Name,
    string Description,
    decimal Price,
    string Category,
    DateTime CreatedAt
);

// Queries/GetProductByIdQuery.cs
using MediatR;

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

// Handlers/GetProductByIdHandler.cs
public class GetProductByIdHandler 
    : IRequestHandler<GetProductByIdQuery, ProductDto?>
{
    private readonly AppDbContext _context;

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

    public async Task<ProductDto?> Handle(
        GetProductByIdQuery request, 
        CancellationToken cancellationToken)
    {
        var product = await _context.Products
            .FindAsync(new object[] { request.Id }, cancellationToken);

        if (product is null)
            return null;

        return new ProductDto(
            product.Id,
            product.Name,
            product.Description,
            product.Price,
            product.Category,
            product.CreatedAt
        );
    }
}
// Queries/GetAllProductsQuery.cs
using MediatR;
using Microsoft.EntityFrameworkCore;

public record GetAllProductsQuery(string? Category = null) 
    : IRequest<List<ProductDto>>;

// Handlers/GetAllProductsHandler.cs
public class GetAllProductsHandler 
    : IRequestHandler<GetAllProductsQuery, List<ProductDto>>
{
    private readonly AppDbContext _context;

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

    public async Task<List<ProductDto>> Handle(
        GetAllProductsQuery request, 
        CancellationToken cancellationToken)
    {
        var query = _context.Products.AsQueryable();

        if (!string.IsNullOrWhiteSpace(request.Category))
            query = query.Where(p => p.Category == request.Category);

        return await query
            .Select(p => new ProductDto(
                p.Id, p.Name, p.Description,
                p.Price, p.Category, p.CreatedAt))
            .ToListAsync(cancellationToken);
    }
}

The Controller — Thin and Clean

With MediatR handling the heavy lifting, your controller becomes a thin routing layer:

// Controllers/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;
    }

    [HttpGet]
    public async Task<IActionResult> GetAll(
        [FromQuery] string? category = null)
    {
        var products = await _mediator.Send(
            new GetAllProductsQuery(category));
        return Ok(products);
    }

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

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

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(
        int id, [FromBody] UpdateProductCommand command)
    {
        if (id != command.Id)
            return BadRequest("Route ID and body ID must match.");

        var result = await _mediator.Send(command);
        return result ? NoContent() : NotFound();
    }
}

Look how clean that is. The controller knows nothing about Entity Framework, business rules, or data mapping. It sends a request through MediatR and returns the result. This is the mediator pattern in C# at its best.

Pipeline Behaviors — Cross-Cutting Concerns Made Easy

One of MediatR's most powerful features is pipeline behaviors. These are middleware-like components that wrap every request, letting you add logging, validation, or caching without modifying a single handler.

Logging Behavior

// Behaviors/LoggingBehavior.cs
using MediatR;
using System.Diagnostics;

public class LoggingBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(
        ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Handling {RequestName}", requestName);

        var stopwatch = Stopwatch.StartNew();
        var response = await next();
        stopwatch.Stop();

        _logger.LogInformation(
            "Handled {RequestName} in {ElapsedMs}ms",
            requestName, stopwatch.ElapsedMilliseconds);

        return response;
    }
}

Validation Behavior with FluentValidation

// Install-Package FluentValidation.DependencyInjectionExtensions

// Validators/CreateProductValidator.cs
using FluentValidation;

public class CreateProductValidator 
    : AbstractValidator<CreateProductCommand>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Product name is required.")
            .MaximumLength(200);

        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Price must be positive.");

        RuleFor(x => x.Category)
            .NotEmpty().WithMessage("Category is required.");
    }
}

// Behaviors/ValidationBehavior.cs
using FluentValidation;
using MediatR;

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)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var failures = (await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, cancellationToken))))
            .SelectMany(result => result.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Register the behaviors in Program.cs:

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});

builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

Now every command and query automatically gets logged and validated — zero changes to existing handlers.

Notifications — Publishing Domain Events

MediatR also supports notifications, which are one-to-many messages. Unlike requests (one handler), a notification can have multiple handlers — perfect for domain events.

// Notifications/ProductCreatedNotification.cs
using MediatR;

public record ProductCreatedNotification(
    int ProductId, 
    string ProductName) : INotification;

// Handlers/SendWelcomeEmailHandler.cs
public class SendWelcomeEmailHandler 
    : INotificationHandler<ProductCreatedNotification>
{
    private readonly ILogger<SendWelcomeEmailHandler> _logger;

    public SendWelcomeEmailHandler(
        ILogger<SendWelcomeEmailHandler> logger)
    {
        _logger = logger;
    }

    public Task Handle(
        ProductCreatedNotification notification, 
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Product created: {Name} (ID: {Id})",
            notification.ProductName, notification.ProductId);
        return Task.CompletedTask;
    }
}

// Handlers/InvalidateCacheHandler.cs
public class InvalidateCacheHandler 
    : INotificationHandler<ProductCreatedNotification>
{
    public Task Handle(
        ProductCreatedNotification notification, 
        CancellationToken cancellationToken)
    {
        // Invalidate product list cache
        return Task.CompletedTask;
    }
}

Publish notifications from your command handler after saving:

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

    public CreateProductHandler(AppDbContext context, IPublisher publisher)
    {
        _context = context;
        _publisher = publisher;
    }

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

        _context.Products.Add(product);
        await _context.SaveChangesAsync(cancellationToken);

        await _publisher.Publish(
            new ProductCreatedNotification(product.Id, product.Name),
            cancellationToken);

        return product.Id;
    }
}

Common Pitfalls and Best Practices

After implementing CQRS with MediatR in production systems, these are the mistakes I see most often:

1. Don't Inject Handlers Directly

Never bypass MediatR by injecting IRequestHandler directly into a class. You'll skip all pipeline behaviors (validation, logging, etc.) and defeat the purpose of the mediator pattern.

2. Keep Commands and Queries Separate

A query should never modify state. A command should return only what the caller needs to proceed (usually an ID or success flag) — not a full entity. Mixing concerns erodes the benefits of command query separation.

3. Don't Over-Architect

You don't need separate read/write databases to use CQRS. Start simple with the same database and separate command/query models. Add complexity (event sourcing, read replicas) only when you have a concrete need.

4. Organize by Feature, Not by Type

Instead of folders like /Commands, /Queries, /Handlers, group by feature: /Features/Products/CreateProduct/ containing the command, handler, and validator together. This scales much better as your application grows.

5. Use CancellationTokens

Always pass the CancellationToken through to async operations. MediatR provides it — use it. This allows graceful request cancellation when clients disconnect.

Recommended Folder Structure

// Feature-based organization
/Features
  /Products
    /CreateProduct
      CreateProductCommand.cs
      CreateProductHandler.cs
      CreateProductValidator.cs
    /GetProducts
      GetAllProductsQuery.cs
      GetAllProductsHandler.cs
    /GetProductById
      GetProductByIdQuery.cs
      GetProductByIdHandler.cs
  /Orders
    /PlaceOrder
      PlaceOrderCommand.cs
      PlaceOrderHandler.cs
/Behaviors
  LoggingBehavior.cs
  ValidationBehavior.cs
/Common
  ProductDto.cs

Conclusion — CQRS and MediatR in ASP.NET Core

Implementing CQRS with MediatR in ASP.NET Core gives you clean separation of concerns, thin controllers, and a powerful pipeline for cross-cutting concerns. The pattern scales from simple APIs to complex domain-driven designs without requiring architectural rewrites along the way.

Key takeaways:

  • Commands handle writes; Queries handle reads — never mix them
  • MediatR routes requests to handlers and provides pipeline behaviors for validation, logging, and caching
  • Pipeline behaviors eliminate cross-cutting concern duplication across your entire application
  • Notifications enable domain event publishing with multiple subscribers
  • Start simple — same database, separate models — and add complexity only when needed
  • Organize code by feature, not by technical layer, for better maintainability

The combination of CQRS and MediatR is one of the most popular architectural patterns in modern .NET development. Once you structure your first project this way, you'll find it hard to go back to fat controllers and monolithic service layers.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Send an Email via SMTP with MailKit Using .NET 6

How to Send an Email in .NET Core This tutorial show you how to send an email in .NET 6.0 using the MailKit email client library. Install MailKit via NuGet Visual Studio Package Manager Console: Install-Package MailKit How to Send an HTML Email in .NET 6.0 This code sends a simple HTML email using the Gmail SMTP service. There are instructions further below on how to use a few other popular SMTP providers - Gmail, Hotmail, Office 365. // create email message var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse("from_address@example.com")); email.To.Add(MailboxAddress.Parse("to_address@example.com")); email.Subject = "Email Subject"; email.Body = new TextPart(TextFormat.Html) { Text = "<h1>Test HTML Message Body</h1>" }; // send email using var smtp = new SmtpClient(); smtp.Connect("smtp.gmail.com", 587, SecureSocketOptions.StartTls); smtp.Authenticate("[Username]", "[Password]"); smtp.Se...