CQRS (Command Query Responsibility Segregation) separates the application into two distinct paths: queries (reading data — return DTOs via projection, no entity tracking, optimised for reading) and commands (writing data — operate on tracked domain entities, apply business rules, raise domain events). This separation allows each path to be optimised independently. Queries can bypass the repository and project directly from DbContext to DTOs; commands load entities and enforce domain invariants. MediatR is the library that dispatches queries and commands to their handlers, keeping controllers thin.
CQRS with MediatR
// dotnet add package MediatR
// ── Query — returns data (no side effects) ────────────────────────────────
public record GetPostBySlugQuery(string Slug) : IRequest<PostDto?>;
public class GetPostBySlugHandler(AppDbContext db) : IRequestHandler<GetPostBySlugQuery, PostDto?>
{
public async Task<PostDto?> Handle(
GetPostBySlugQuery request, CancellationToken ct)
=> await db.Posts
.AsNoTracking()
.Where(p => p.Slug == request.Slug && p.IsPublished)
.Select(p => new PostDto // project to DTO in SQL
{
Id = p.Id,
Title = p.Title,
Slug = p.Slug,
Body = p.Body,
AuthorName = p.Author!.DisplayName,
PublishedAt = p.PublishedAt,
})
.FirstOrDefaultAsync(ct);
}
// ── Command — changes state (has side effects) ────────────────────────────
public record CreatePostCommand(string Title, string Slug, string Body, string AuthorId)
: IRequest<PostDto>;
public class CreatePostHandler(
AppDbContext db,
ILogger<CreatePostHandler> logger) : IRequestHandler<CreatePostCommand, PostDto>
{
public async Task<PostDto> Handle(CreatePostCommand command, CancellationToken ct)
{
if (await db.Posts.AnyAsync(p => p.Slug == command.Slug, ct))
throw new ConflictException("Slug already in use.");
var post = Post.Create(command.Title, command.Slug, command.Body, command.AuthorId);
db.Posts.Add(post);
await db.SaveChangesAsync(ct);
logger.LogInformation("Post created: {Id}", post.Id);
return post.ToDto();
}
}
// ── Controller — thin, just dispatches ────────────────────────────────────
[ApiController, Route("api/posts")]
public class PostsController(IMediator mediator) : ControllerBase
{
[HttpGet("{slug}")]
public async Task<ActionResult<PostDto>> GetBySlug(string slug, CancellationToken ct)
{
var post = await mediator.Send(new GetPostBySlugQuery(slug), ct);
return post is null ? NotFound() : Ok(post);
}
[HttpPost]
public async Task<ActionResult<PostDto>> Create(
CreatePostRequest request, CancellationToken ct)
{
var post = await mediator.Send(
new CreatePostCommand(request.Title, request.Slug, request.Body, User.GetUserId()), ct);
return CreatedAtAction(nameof(GetBySlug), new { slug = post.Slug }, post);
}
}
// ── Registration ──────────────────────────────────────────────────────────
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreatePostHandler>());
DbContext directly with AsNoTracking() and projection — bypassing repositories entirely for reads. This is intentional: query handlers are optimised read paths that do not need the repository abstraction. Command handlers use the full domain model (tracked entities, domain invariants). This separation lets you optimise reads (add caching, split queries, use read replicas) without touching command logic, and optimise commands (domain events, validation) without touching read performance.IPipelineBehavior<TRequest, TResponse>) that logs every command with its type and execution time. Add a validation behaviour that runs FluentValidation validators before the handler executes. Add a transaction behaviour that wraps commands in a database transaction. These behaviours are registered once and applied automatically to every handler, keeping individual handlers clean.CQRS vs Repository — When to Use Each
| Pattern | Best For | Avoid When |
|---|---|---|
| Direct DbContext | Small projects, rapid prototyping | Large teams requiring clear boundaries |
| Repository | Testability, technology abstraction | Simple CRUD with no swapping planned |
| Repository + UoW | Multi-entity atomic operations | Single-entity CRUD without business logic |
| CQRS + MediatR | Complex domain, separate read/write models | Simple CRUD where it adds unnecessary classes |
Common Mistakes
Mistake 1 — Using repositories in query handlers (extra indirection for reads)
❌ Wrong — query handler calls IPostRepository.GetBySlugAsync() which loads entity, then maps to DTO.
✅ Correct — query handlers project directly to DTOs from DbContext with AsNoTracking(); no entity loading needed.
Mistake 2 — Applying CQRS to every project regardless of complexity (over-engineering)
❌ Wrong — 5-endpoint CRUD API with full CQRS + MediatR; more code than business logic.
✅ Correct — evaluate complexity; use the simplest pattern that meets your team’s requirements.