Skip to main content

CQRS and MediatR in ASP.NET Core: Complete Guide

Learn CQRS and MediatR in ASP.NET Core with practical C# examples. Master commands, queries, and pipeline behaviors — start building cleaner APIs today.

If you have ever opened a service class in an ASP.NET Core project and found a 2,000-line file that reads data, writes data, validates input, sends emails, and logs everything in between, you already understand why CQRS and MediatR in ASP.NET Core have become two of the most searched-for patterns in the .NET ecosystem. CQRS (Command Query Responsibility Segregation) splits your application into two clean halves — operations that change state and operations that read state — while MediatR gives you a lightweight, in-process messaging library to wire it all together without controllers knowing anything about your business logic.

In this tutorial, you will learn what CQRS actually is (and what it is not), why MediatR is the most popular way to implement it in .NET, and how to build a complete, runnable example with commands, queries, handlers, and pipeline behaviors. We will also cover the licensing change in MediatR, best practices, and the pitfalls that trip up most teams.

What Is the CQRS Pattern in C#?

CQRS stands for Command Query Responsibility Segregation. It builds on a simple principle from Bertrand Meyer called Command-Query Separation (CQS): a method should either change state or return data, but never both. CQRS lifts that idea from the method level to the architecture level:

  • Commands change the state of the system: CreateOrder, UpdateCustomerEmail, CancelSubscription. They express intent and typically return nothing (or just an identifier).
  • Queries read state and return data: GetOrderById, ListActiveCustomers. They never mutate anything.

Why does this separation matter? Because reads and writes have fundamentally different needs. Writes need validation, business rules, transactions, and auditing. Reads need speed, projections shaped for the UI, and often caching. When you force both through the same service methods and the same domain models, every change to one side risks breaking the other. Splitting them means each side can evolve independently — your query side can use raw Dapper SQL against a read-optimized view while your command side uses EF Core with full change tracking, and neither cares about the other.

One important clarification: CQRS does not require separate databases, event sourcing, or eventual consistency. Those are optional extensions for high-scale systems. For most applications, CQRS is simply a code organization pattern — one class per operation — and that alone delivers most of the value.

Why Use MediatR for CQRS in ASP.NET Core?

You can implement CQRS without any library, but MediatR (by Jimmy Bogard, the author of AutoMapper) removes the boilerplate. It is an in-process implementation of the mediator pattern: instead of controllers calling services directly, they send a request object to MediatR, which routes it to exactly one handler.

The benefits are concrete:

  • Thin controllers. A controller action becomes three lines: receive the request, send it, return the result. No injected service soup.
  • One class per use case. CreateProductCommandHandler does one thing. It is easy to find, easy to test, and easy to delete.
  • Cross-cutting concerns in one place. Pipeline behaviors let you wrap every request with validation, logging, caching, or transactions — like middleware for your business logic.
  • Loose coupling. The sender never references the handler. Refactoring a handler cannot break a controller.

A note on licensing: as of version 13, MediatR moved to a commercial license under Lucky Penny Software — it remains free for smaller organizations and open-source projects, but larger companies need a paid license. Versions 12.x and earlier remain Apache 2.0 licensed. If licensing is a blocker, community alternatives like Mediator (source-generator based) or a hand-rolled dispatcher work with the same patterns shown below. The concepts in this article apply regardless of which you choose.

Setting Up MediatR in an ASP.NET Core Project

Create a Web API project and install the package:

// Terminal
// dotnet new webapi -n CqrsDemo
// dotnet add package MediatR

Register MediatR in Program.cs. A single call scans your assembly for every handler:

var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();
app.MapControllers();
app.Run();

Writing Your First Command and Handler

Let's build a product catalog. A command is a plain object describing intent — C# records are perfect because they are immutable and concise:

using MediatR;

// The command: carries everything needed to perform the write.
// IRequest<int> means "this request returns an int" (the new product's ID).
public record CreateProductCommand(string Name, decimal Price, int Stock)
    : IRequest<int>;

The handler contains the actual business logic:

public class CreateProductCommandHandler
    : IRequestHandler<CreateProductCommand, int>
{
    private readonly AppDbContext _db;

    public CreateProductCommandHandler(AppDbContext db) => _db = db;

    public async Task<int> Handle(
        CreateProductCommand request, CancellationToken cancellationToken)
    {
        if (request.Price <= 0)
            throw new ArgumentException("Price must be positive.");

        var product = new Product
        {
            Name = request.Name,
            Price = request.Price,
            Stock = request.Stock
        };

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

        return product.Id;
    }
}

Notice what is not here: no HTTP concerns, no controller references, no framework noise. This class is trivially unit-testable — construct it with an in-memory context, call Handle, assert the result.

Writing a Query and Handler

Queries follow the same shape but return data. A key CQRS practice: return a DTO shaped for the consumer, never your EF Core entity. This prevents over-fetching and stops your database schema from leaking into your API contract.

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

public record ProductDto(int Id, string Name, decimal Price, bool InStock);

public class GetProductByIdQueryHandler
    : IRequestHandler<GetProductByIdQuery, ProductDto?>
{
    private readonly AppDbContext _db;

    public GetProductByIdQueryHandler(AppDbContext db) => _db = db;

    public async Task<ProductDto?> Handle(
        GetProductByIdQuery request, CancellationToken cancellationToken)
    {
        return await _db.Products
            .AsNoTracking() // read-only: skip change tracking for speed
            .Where(p => p.Id == request.Id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Stock > 0))
            .FirstOrDefaultAsync(cancellationToken);
    }
}

AsNoTracking() matters here: because queries never write, there is no reason to pay EF Core's change-tracking cost. This is CQRS paying off already — the read path gets optimized without touching the write path.

The Controller: Thin by Design

[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 id = await _mediator.Send(command);
        return CreatedAtAction(nameof(GetById), new { id }, new { id });
    }

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

The controller injects one dependency no matter how many use cases you add. Adding a feature never means editing constructor signatures across your API layer.

Pipeline Behaviors: Cross-Cutting Concerns Done Right

This is where MediatR earns its keep. An IPipelineBehavior wraps every request like ASP.NET Core middleware wraps every HTTP call. Here is a validation behavior using FluentValidation that runs before any handler:

using FluentValidation;
using MediatR;

public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    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 failures = _validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

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

        return await next(); // continue to the handler
    }
}

Define a validator per command:

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

And register everything once:

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

Now every command in your entire application is validated automatically. The same pattern gives you request logging, performance timing, response caching for queries, and database transactions for commands — each concern written once, applied everywhere. That is the "why" behind MediatR: it turns cross-cutting code from something you remember to add into something you cannot forget.

CQRS and MediatR Best Practices

  • One handler per file, named after the use case. Organize by feature folder (Features/Products/CreateProduct.cs), not by technical layer. This "vertical slice" layout makes features discoverable.
  • Commands express intent, not CRUD. Prefer DeactivateCustomerCommand over UpdateCustomerCommand with a status flag. Intent-named commands document your business.
  • Keep queries side-effect free — enforce it. Use AsNoTracking(), or route queries through a read-only Dapper connection so mutation is impossible by construction.
  • Return DTOs, never entities. Entities are your persistence model; DTOs are your contract.
  • Always pass the CancellationToken through. ASP.NET Core cancels it when the client disconnects; honoring it stops wasted database work.
  • Use records for requests. Immutability guarantees the request cannot be mutated mid-pipeline by a behavior.

Common Pitfalls to Avoid

  • Handlers calling other handlers. Sending a command from inside a handler creates hidden call chains that are painful to debug. Extract shared logic into a plain service both handlers use, or publish a notification (INotification) for genuine follow-on events.
  • Treating CQRS as all-or-nothing. You do not need separate read/write databases on day one. Start with separated classes over one database; split storage only when read scaling actually demands it.
  • Anemic handlers. If every handler is three lines that forward to a service, you have added indirection without value. The handler is the right home for the use-case logic.
  • Queries that write. A query that updates a "last viewed" timestamp violates the contract and will eventually surprise someone. If you need the side effect, make it explicit with a separate command or notification.
  • Using MediatR everywhere. A simple health-check endpoint or a static lookup does not need a command/handler pair. Apply the pattern where use cases have real logic.

Conclusion: Key Takeaways on CQRS and MediatR in ASP.NET Core

Adopting CQRS and MediatR in ASP.NET Core is less about architecture astronautics and more about everyday maintainability: every use case lives in one small, testable class; controllers shrink to routing glue; and cross-cutting concerns like validation and logging are written once as pipeline behaviors instead of scattered across services.

  • CQRS separates commands (writes) from queries (reads) so each side can be optimized and evolved independently.
  • MediatR implements the mediator pattern in-process: controllers Send() a request, exactly one handler responds.
  • Pipeline behaviors are the killer feature — validation, logging, caching, and transactions applied uniformly.
  • You do not need event sourcing or separate databases to benefit; start simple and scale the pattern with your needs.
  • Check MediatR's licensing for your organization size (v13+ is commercial for larger companies; v12 remains Apache 2.0, and alternatives exist).

The best next step is hands-on: take one bloated service class in a project you maintain and refactor its methods into commands and queries with handlers. Within an hour you will feel the difference — and your next code review will show it.

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

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

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...

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...