CQRS Pattern in .NET 10 with MediatR — Complete Step-by-Step Tutorial

CQRS MediatR .NET 10

CQRS Pattern in .NET 10 with MediatR — Complete Step-by-Step Tutorial

Learn how to implement CQRS with MediatR 14 in ASP.NET Core 10 — from first principles to a complete Order Management API with pipeline behaviours, validation, logging, transactions, and notifications.

AS

Adnan Shaukat

June 17, 2026  ·  15 min read  ·  Senior .NET Backend Developer

If you have worked on a .NET project for more than a few months, you have probably hit the point where your service classes become bloated — hundreds of methods, every feature touching the same model, reads and writes fighting each other for the same code paths.

CQRS (Command Query Responsibility Segregation) solves this by doing one simple thing: separating reads from writes. Commands change state. Queries return data. Never the same model for both. Combined with MediatR — the library that makes CQRS feel completely natural in .NET — you get thin controllers, focused handlers, and a codebase that is a joy to test and extend.

CQRS is one of the most commonly used patterns that helps architect solutions following clean architecture principles — used across multiple production projects, it consistently delivers cleaner code separation and easier testing.

01 — Foundation What is CQRS — The Core Idea

CQRS stands for Command Query Responsibility Segregation. The idea was popularised by Greg Young and is built on a simple principle: a method should either change state OR return data — never both.

⚡ Commands (Write)

  • Change the state of the system
  • Create, Update, Delete operations
  • Return void or a simple ID
  • Always go through domain validation
  • Wrapped in database transactions

🔍 Queries (Read)

  • Return data without changing state
  • Always return a result (DTO/list)
  • Optimised for reading — AsNoTracking
  • No domain model needed — use projections
  • Can use raw SQL or read replicas

Why reads and writes need different models

In most applications, reads happen far more than writes. Reads need performance. Writes need validation and business rules. Trying to optimise both using the same model leads to complexity.

02 — Decision Why Use CQRS — and When NOT To

Use CQRS when:

  • Your service classes are bloated — one class with 30+ methods handling everything
  • You use Clean Architecture — CQRS fits naturally with Clean Architecture since reads and writes have different responsibilities
  • Read and write loads differ dramatically — many more reads than writes
  • You need independent scalability — scale your read replicas independently of write nodes
  • Complex domain logic — commands need rich domain validation, queries just need speed

Do NOT use CQRS when:

Start small — introduce MediatR for a few high-value commands and queries, keep handlers simple, and add patterns like pipeline behaviours, transactions, and separate read models only when they solve a real problem. CQRS adds structure — and structure adds complexity. Use it wisely. Avoid it for simple CRUD apps with under 5 entities or prototype projects where speed of development matters more than structure.

03 — The Library What is MediatR and How It Works

MediatR is a lightweight mediator library for .NET. MediatR makes CQRS implementation clean and decoupled. Controllers become thin. Handlers become focused and testable. Each request — command or query — has exactly one handler. The sender does not know who handles the request. This removes tight coupling between your controllers and your application logic.

The flow works like this:

  1. Controller creates a Command or Query object and calls _mediator.Send(request)
  2. MediatR finds the registered handler for that request type
  3. The request passes through Pipeline Behaviours (validation, logging, transactions)
  4. The Handler executes the business logic
  5. The result flows back to the controller

04 — Project Setup Solution Structure and Installation

We will build an Order Management API using CQRS and MediatR in .NET 10. This is a real-world example — not a todo list.

Solution
OrderManagement.sln │ ├── src/Domain ← Entities, value objects, domain events ├── src/Application ← Commands, Queries, Handlers, Behaviours │ ├── Orders/ │ │ ├── Commands/ │ │ │ ├── CreateOrder/ │ │ │ │ ├── CreateOrderCommand.cs │ │ │ │ ├── CreateOrderCommandHandler.cs │ │ │ │ └── CreateOrderCommandValidator.cs │ │ └── Queries/ │ │ ├── GetOrderById/ │ │ │ ├── GetOrderByIdQuery.cs │ │ │ └── GetOrderByIdQueryHandler.cs │ │ └── GetOrders/ │ └── Common/Behaviours/ │ ├── ValidationBehaviour.cs │ ├── LoggingBehaviour.cs │ └── TransactionBehaviour.cs ├── src/Infrastructure ← EF Core, repositories └── src/API ← Controllers, Program.cs

Install packages

dotnet add src/Application package MediatR
dotnet add src/Application package FluentValidation
dotnet add src/Application package FluentValidation.DependencyInjectionExtensions
dotnet add src/Infrastructure package Microsoft.EntityFrameworkCore.SqlServer

Register in Program.cs

C#
// Program.cs builder.Services.AddMediatR(cfg => { cfg.RegisterServicesFromAssembly( typeof(CreateOrderCommandHandler).Assembly); // Register pipeline behaviours — order matters! cfg.AddOpenBehavior(typeof(LoggingBehaviour<,>)); cfg.AddOpenBehavior(typeof(ValidationBehaviour<,>)); cfg.AddOpenBehavior(typeof(TransactionBehaviour<,>)); }); builder.Services.AddValidatorsFromAssembly( typeof(CreateOrderCommandValidator).Assembly);

05 — Write Side Commands — Write Operations

A Command represents the intention to change state. It is a plain C# record with the data needed to perform the operation. The Command Handler executes the business logic.

Create Order Command

C# — Application
// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs // The command — what the user wants to do public record CreateOrderCommand( Guid CustomerId, string ShippingAddress, List<CreateOrderItemDto> Items ) : IRequest<Guid>; // returns the new Order ID public record CreateOrderItemDto( Guid ProductId, int Quantity, decimal UnitPrice ); // The validator — runs BEFORE the handler via pipeline public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand> { public CreateOrderCommandValidator() { RuleFor(x => x.CustomerId) .NotEmpty().WithMessage("Customer ID is required"); RuleFor(x => x.ShippingAddress) .NotEmpty().WithMessage("Shipping address is required") .MaximumLength(500); RuleFor(x => x.Items) .NotEmpty().WithMessage("Order must have at least one item"); RuleForEach(x => x.Items).ChildRules(item => { item.RuleFor(i => i.Quantity) .GreaterThan(0).WithMessage("Quantity must be greater than zero"); item.RuleFor(i => i.UnitPrice) .GreaterThan(0).WithMessage("Unit price must be greater than zero"); }); } } // The handler — executes the business logic public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid> { private readonly IOrderRepository _repo; private readonly IPublisher _publisher; public CreateOrderCommandHandler( IOrderRepository repo, IPublisher publisher) { _repo = repo; _publisher = publisher; } public async Task<Guid> Handle( CreateOrderCommand cmd, CancellationToken ct) { // Create domain entity — domain rules enforced here var order = Order.Create( cmd.CustomerId, cmd.ShippingAddress, cmd.Items.Select(i => OrderItem.Create(i.ProductId, i.Quantity, i.UnitPrice)) .ToList() ); // Persist await _repo.AddAsync(order, ct); // Publish domain event — notifies other handlers asynchronously await _publisher.Publish( new OrderCreatedNotification(order.Id, order.CustomerId), ct); return order.Id; } }

Update and Delete Commands

C#
// Update command — returns Unit (void equivalent in MediatR) public record UpdateOrderStatusCommand( Guid OrderId, OrderStatus NewStatus ) : IRequest; public class UpdateOrderStatusCommandHandler : IRequestHandler<UpdateOrderStatusCommand> { private readonly IOrderRepository _repo; public UpdateOrderStatusCommandHandler(IOrderRepository repo) => _repo = repo; public async Task Handle( UpdateOrderStatusCommand cmd, CancellationToken ct) { var order = await _repo.GetByIdAsync(cmd.OrderId, ct) ?? throw new NotFoundException("Order", cmd.OrderId); order.UpdateStatus(cmd.NewStatus); // domain method await _repo.UpdateAsync(order, ct); } } // Delete command public record CancelOrderCommand(Guid OrderId) : IRequest; public class CancelOrderCommandHandler : IRequestHandler<CancelOrderCommand> { private readonly IOrderRepository _repo; public CancelOrderCommandHandler(IOrderRepository repo) => _repo = repo; public async Task Handle( CancelOrderCommand cmd, CancellationToken ct) { var order = await _repo.GetByIdAsync(cmd.OrderId, ct) ?? throw new NotFoundException("Order", cmd.OrderId); order.Cancel(); // domain enforces business rules await _repo.UpdateAsync(order, ct); } }

06 — Read Side Queries — Read Operations

Queries never change state. They are optimised purely for reading — use AsNoTracking, project directly to DTOs, and never load full domain entities when reading.

C# — Application
// DTOs — what the query returns public record OrderDto( Guid Id, string CustomerName, string ShippingAddress, OrderStatus Status, decimal TotalAmount, DateTime CreatedAt, List<OrderItemDto> Items ); public record OrderItemDto( Guid ProductId, string ProductName, int Quantity, decimal UnitPrice, decimal TotalPrice ); // Get single order query public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>; public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?> { private readonly AppDbContext _db; public GetOrderByIdQueryHandler(AppDbContext db) => _db = db; public async Task<OrderDto?> Handle( GetOrderByIdQuery query, CancellationToken ct) { // Direct projection — no domain entity loaded, AsNoTracking return await _db.Orders .AsNoTracking() .Where(o => o.Id == query.OrderId) .Select(o => new OrderDto( o.Id, o.Customer.FullName, o.ShippingAddress, o.Status, o.Items.Sum(i => i.Quantity * i.UnitPrice), o.CreatedAt, o.Items.Select(i => new OrderItemDto( i.ProductId, i.Product.Name, i.Quantity, i.UnitPrice, i.Quantity * i.UnitPrice )).ToList() )) .FirstOrDefaultAsync(ct); } } // Get paginated orders query public record GetOrdersQuery( Guid? CustomerId = null, OrderStatus? Status = null, int Page = 1, int PageSize = 20 ) : IRequest<PagedResponse<OrderDto>>; public class GetOrdersQueryHandler : IRequestHandler<GetOrdersQuery, PagedResponse<OrderDto>> { private readonly AppDbContext _db; public GetOrdersQueryHandler(AppDbContext db) => _db = db; public async Task<PagedResponse<OrderDto>> Handle( GetOrdersQuery query, CancellationToken ct) { var q = _db.Orders.AsNoTracking().AsQueryable(); if (query.CustomerId.HasValue) q = q.Where(o => o.CustomerId == query.CustomerId); if (query.Status.HasValue) q = q.Where(o => o.Status == query.Status); var total = await q.CountAsync(ct); var items = await q .OrderByDescending(o => o.CreatedAt) .Skip((query.Page - 1) * query.PageSize) .Take(query.PageSize) .Select(o => new OrderDto( o.Id, o.Customer.FullName, o.ShippingAddress, o.Status, o.Items.Sum(i => i.Quantity * i.UnitPrice), o.CreatedAt, new List<OrderItemDto>())) .ToListAsync(ct); return new PagedResponse<OrderDto>( items, query.Page, query.PageSize, total, HasNextPage: query.Page * query.PageSize < total, HasPreviousPage: query.Page > 1); } }

07 — Cross-Cutting Concerns Pipeline Behaviours — Validation, Logging, Transactions

Do not put validation inside your handlers — register a MediatR IPipelineBehavior instead. Pipeline behaviours are middleware that wrap every request — perfect for cross-cutting concerns that you do not want scattered across every handler.

Validation Behaviour

C#
// Runs FluentValidation before EVERY command/query handler public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull { private readonly IEnumerable<IValidator<TRequest>> _validators; public ValidationBehaviour( IEnumerable<IValidator<TRequest>> validators) => _validators = validators; public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct) { if (!_validators.Any()) return await next(); var context = new ValidationContext<TRequest>(request); var failures = _validators .Select(async v => await v.ValidateAsync(context, ct)) .SelectMany(r => r.Result.Errors) .Where(f => f != null) .ToList(); if (failures.Any()) throw new ValidationException(failures); return await next(); } }

Logging Behaviour

C#
// Logs every request with execution time — centralised, zero handler pollution public class LoggingBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull { private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger; public LoggingBehaviour( ILogger<LoggingBehaviour<TRequest, TResponse>> logger) => _logger = logger; public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct) { var requestName = typeof(TRequest).Name; var sw = Stopwatch.StartNew(); _logger.LogInformation( "Handling {RequestName}", requestName); try { var response = await next(); sw.Stop(); _logger.LogInformation( "Handled {RequestName} in {ElapsedMs}ms", requestName, sw.ElapsedMilliseconds); return response; } catch (Exception ex) { sw.Stop(); _logger.LogError(ex, "Error handling {RequestName} after {ElapsedMs}ms", requestName, sw.ElapsedMilliseconds); throw; } } }

Transaction Behaviour — Commands Only

C#
// Wraps ALL command handlers in a DB transaction automatically // Queries skip this because they implement IQuery marker interface public interface ICommand { } public interface ICommand<TResponse> : IRequest<TResponse>, ICommand { } public interface IQuery<TResponse> : IRequest<TResponse> { } public class TransactionBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : ICommand { private readonly AppDbContext _db; public TransactionBehaviour(AppDbContext db) => _db = db; public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct) { // Already in a transaction — just execute if (_db.Database.CurrentTransaction != null) return await next(); // Wrap in transaction — auto commit on success, rollback on failure await using var transaction = await _db.Database.BeginTransactionAsync(ct); try { var response = await next(); await _db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); return response; } catch { await transaction.RollbackAsync(ct); throw; } } }

08 — Domain Events Notifications — Domain Events with MediatR

MediatR supports notifications — one event published to multiple handlers. This is perfect for domain events where one action triggers several side effects.

C#
// Define the notification (domain event) public record OrderCreatedNotification( Guid OrderId, Guid CustomerId ) : INotification; // Handler 1 — sends confirmation email public class SendOrderConfirmationEmailHandler : INotificationHandler<OrderCreatedNotification> { private readonly IEmailService _email; public SendOrderConfirmationEmailHandler(IEmailService email) => _email = email; public async Task Handle( OrderCreatedNotification notification, CancellationToken ct) => await _email.SendOrderConfirmationAsync( notification.CustomerId, notification.OrderId, ct); } // Handler 2 — updates inventory public class UpdateInventoryHandler : INotificationHandler<OrderCreatedNotification> { private readonly IInventoryService _inventory; public UpdateInventoryHandler(IInventoryService inventory) => _inventory = inventory; public async Task Handle( OrderCreatedNotification notification, CancellationToken ct) => await _inventory.ReserveItemsAsync(notification.OrderId, ct); } // Handler 3 — notifies analytics public class TrackOrderAnalyticsHandler : INotificationHandler<OrderCreatedNotification> { public async Task Handle( OrderCreatedNotification notification, CancellationToken ct) { // All 3 handlers run for the same notification // Add, remove handlers without touching CreateOrderCommandHandler } }

09 — Thin Controllers Wiring It All Together in ASP.NET Core 10

Controllers become thin — they receive a request, dispatch to MediatR, and return the result. No business logic in controllers. Ever.

C#
// OrdersController.cs — thin, clean, zero business logic [ApiController] [Route("api/[controller]")] public class OrdersController(IMediator mediator) : ControllerBase { // GET /api/orders?page=1&pageSize=20 [HttpGet] public async Task<IActionResult> GetAll( [FromQuery] GetOrdersQuery query, CancellationToken ct) => Ok(await mediator.Send(query, ct)); // GET /api/orders/{id} [HttpGet("{id:guid}")] public async Task<IActionResult> GetById( Guid id, CancellationToken ct) { var order = await mediator.Send( new GetOrderByIdQuery(id), ct); return order == null ? NotFound() : Ok(order); } // POST /api/orders [HttpPost] public async Task<IActionResult> Create( CreateOrderCommand cmd, CancellationToken ct) { var id = await mediator.Send(cmd, ct); return CreatedAtAction(nameof(GetById), new { id }, new { id }); } // PUT /api/orders/{id}/status [HttpPut("{id:guid}/status")] public async Task<IActionResult> UpdateStatus( Guid id, [FromBody] OrderStatus status, CancellationToken ct) { await mediator.Send( new UpdateOrderStatusCommand(id, status), ct); return NoContent(); } // DELETE /api/orders/{id} [HttpDelete("{id:guid}")] public async Task<IActionResult> Cancel( Guid id, CancellationToken ct) { await mediator.Send(new CancelOrderCommand(id), ct); return NoContent(); } }

10 — Testing Testing CQRS Handlers

This is where CQRS pays back enormously. Because handlers have a single focused responsibility and depend only on interfaces, unit tests are extremely clean.

C# — Tests
public class CreateOrderCommandHandlerTests { private readonly Mock<IOrderRepository> _repoMock; private readonly Mock<IPublisher> _publisherMock; private readonly CreateOrderCommandHandler _handler; public CreateOrderCommandHandlerTests() { _repoMock = new Mock<IOrderRepository>(); _publisherMock = new Mock<IPublisher>(); _handler = new CreateOrderCommandHandler( _repoMock.Object, _publisherMock.Object); } [Fact] public async Task Handle_ValidCommand_CreatesOrderAndPublishesEvent() { // Arrange var command = new CreateOrderCommand( CustomerId: Guid.NewGuid(), ShippingAddress: "123 Main St, Karachi", Items: new List<CreateOrderItemDto> { new(Guid.NewGuid(), Quantity: 2, UnitPrice: 99.99m) } ); // Act var result = await _handler.Handle( command, CancellationToken.None); // Assert result.Should().NotBeEmpty(); _repoMock.Verify(r => r.AddAsync(It.IsAny<Order>(), default), Times.Once); _publisherMock.Verify(p => p.Publish( It.IsAny<OrderCreatedNotification>(), default), Times.Once); } [Fact] public async Task Handle_EmptyItems_ThrowsDomainException() { var command = new CreateOrderCommand( Guid.NewGuid(), "Address", Items: new List<CreateOrderItemDto>()); // Validation behaviour catches this before handler runs await FluentActions .Invoking(() => _handler.Handle(command, default)) .Should().ThrowAsync<ArgumentException>(); } }

11 — Watch Out Common CQRS Mistakes to Avoid

❌ Putting validation inside handlers

Use the ValidationBehaviour pipeline instead. Validation logic inside handlers is duplicated every time you call the same command from different places.

❌ Returning domain entities from query handlers

Always project to DTOs. Returning domain entities from queries exposes your domain model, causes lazy loading issues, and serialises navigation properties you do not want.

❌ Putting business logic in controllers

Controllers dispatch to MediatR and return results. Period. Any if/else business logic in a controller means it belongs in a Command Handler or the Domain layer.

❌ Using CQRS for a 5-entity CRUD app

In many .NET Core applications, CQRS starts as a simple internal organisation style. Start small — introduce MediatR for a few high-value commands and queries, and add patterns only when they solve a real problem.

✅ Always pass CancellationToken through every handler

Every Handle method receives a CancellationToken — pass it to every async call (database queries, HTTP calls, file operations) so cancelled requests do not waste resources.

Final Thoughts

CQRS helps you build clearer, more maintainable systems by separating reads from writes. When combined with MediatR in .NET 10, it promotes clean architecture, focused handlers, and thin controllers — making your code easier to test, scale, and evolve. Start with one command and one query. Add pipeline behaviours one at a time. The pattern will grow naturally with your application.

CQRS MediatR .NET 10 ASP.NET Core Clean Architecture C# Backend Tutorial
AS

Written by

Adnan Shaukat

Senior .NET Backend Developer with 8+ years building enterprise systems. Writing daily at codewithadnanshaukat.blogspot.com

Comments

Popular posts from this blog

What is Agentic AI? A Complete Developer Guide for 2026

EF Core 10 Performance Tips — 12 Ways to Make Your Queries Blazing Fast

.NET 10 New Features Performance and Changes — Complete Guide