CQRS & MediatR
Key Points
- CQRS = separate commands (write; mutate state) from queries (read; return data). Different objects, often different models.
- MediatR is the popular .NET in-process mediator. Send a request → handler runs → result returned. Pipeline behaviors wrap handlers (logging, validation, caching).
- CQRS doesn't require event sourcing. And event sourcing usually implies CQRS. Don't conflate them.
- For simple CRUD, plain controllers + service classes work fine. Reach for CQRS when read and write models genuinely differ, or you need cross-cutting via pipeline.
- Watch the licensing: MediatR commercial-licensed since v12 (2024). Alternatives: Wolverine, Mediator (source-gen), or hand-rolled.
Concepts (deep dive)
Commands and queries
// Command — mutates state; returns void or simple ack
public record PlaceOrder(Guid CustomerId, List<OrderLine> Lines) : IRequest<Guid>;
public class PlaceOrderHandler(IOrderRepo repo) : IRequestHandler<PlaceOrder, Guid>
{
public async Task<Guid> Handle(PlaceOrder cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Lines);
await repo.SaveAsync(order, ct);
return order.Id;
}
}
// Query — reads; returns data; doesn't mutate
public record GetOrderById(Guid Id) : IRequest<OrderDto?>;
public class GetOrderByIdHandler(AppDb db) : IRequestHandler<GetOrderById, OrderDto?>
{
public Task<OrderDto?> Handle(GetOrderById q, CancellationToken ct) =>
db.Orders.AsNoTracking()
.Where(o => o.Id == q.Id)
.Select(o => new OrderDto(o.Id, o.Status.ToString(), o.Total))
.FirstOrDefaultAsync(ct);
}
The shape: each operation is its own request type with a handler. Cleanly maps to vertical-slice organization.
Wiring up MediatR
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
app.MapPost("/orders", async (PlaceOrder cmd, IMediator mediator) =>
Results.Created($"/orders/{await mediator.Send(cmd)}", null));
app.MapGet("/orders/{id:guid}", async (Guid id, IMediator mediator) =>
await mediator.Send(new GetOrderById(id)) is { } o ? Results.Ok(o) : Results.NotFound());
The endpoint becomes thin — just send the request to the mediator.
Pipeline behaviors
public class LoggingBehavior<TReq, TRes>(ILogger<LoggingBehavior<TReq, TRes>> log)
: IPipelineBehavior<TReq, TRes> where TReq : IRequest<TRes>
{
public async Task<TRes> Handle(TReq req, RequestHandlerDelegate<TRes> next, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
var result = await next();
log.LogInformation("{Req} took {Ms}ms", typeof(TReq).Name, sw.ElapsedMilliseconds);
return result;
}
}
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
Other common behaviors: validation (FluentValidation), caching, transaction (open scope; commit on success), audit, rate limiting.
Validation behavior
public class ValidationBehavior<TReq, TRes>(IEnumerable<IValidator<TReq>> validators)
: IPipelineBehavior<TReq, TRes> where TReq : IRequest<TRes>
{
public async Task<TRes> Handle(TReq req, RequestHandlerDelegate<TRes> next, CancellationToken ct)
{
foreach (var v in validators)
{
var result = await v.ValidateAsync(req, ct);
if (!result.IsValid) throw new ValidationException(result.Errors);
}
return await next();
}
}
Combined with FluentValidation: validators auto-discovered; pipeline wraps handler. Each request type gets validation for free.
Read models vs write models
// Write model — full domain, invariants, aggregate methods
public class Order { /* aggregate root with behavior */ }
// Read model — flat DTO, denormalized, query-optimized
public record OrderListItem(Guid Id, string CustomerName, decimal Total, string StatusLabel);
// Query handler reads via projection, ignoring domain entity:
db.Orders.Select(o => new OrderListItem(o.Id, o.Customer.Name, o.Total, FormatStatus(o.Status)));
Different models for different uses. The write model enforces invariants; the read model serves the UI shape.
When CQRS pays off
- Read patterns wildly different from write (heavy reporting alongside simple writes).
- Multi-tenant, with different read concerns per tenant.
- High read RPS with materialized views (need to maintain a read store).
When CQRS is overkill
- Simple CRUD where read and write are basically the same shape.
- Small teams; the abstraction tax exceeds the win.
Notifications (multi-handler events)
public record OrderConfirmed(Guid OrderId) : INotification;
public class SendEmailHandler : INotificationHandler<OrderConfirmed> { /* ... */ }
public class UpdateStatsHandler : INotificationHandler<OrderConfirmed> { /* ... */ }
await mediator.Publish(new OrderConfirmed(id));
// Both handlers run in parallel
Publish invokes all registered handlers. Useful for in-process domain-event dispatch.
MediatR licensing & alternatives
MediatR v12+ became commercial-licensed (paid for production). Alternatives:
- Wolverine — by Jeremy Miller. More features, includes messaging.
- Mediator (martinothamar/Mediator) — source-gen-based, AOT-friendly, free.
- Hand-rolled —
IRequest<T>+IRequestHandler<T, TR>+ a smallMediatorclass is ~50 lines.
For new projects in 2026, lean toward source-gen alternatives or hand-rolled.
CQRS without MediatR
public class OrderHandlers(IOrderRepo repo, AppDb db)
{
public async Task<Guid> PlaceAsync(PlaceOrder cmd, CancellationToken ct) { /* ... */ }
public Task<OrderDto?> GetByIdAsync(Guid id, CancellationToken ct) { /* ... */ }
}
app.MapPost("/orders", async (PlaceOrder cmd, OrderHandlers h, CancellationToken ct) =>
Results.Created(/* ... */, await h.PlaceAsync(cmd, ct)));
Skip the mediator entirely. Inject the handler class directly. Simpler; no DI scanning; explicit. Pipeline behaviors become decorators (Scrutor or hand-rolled).
Code: correct vs wrong
❌ Wrong: heavy domain logic in query handler
✅ Correct: queries are simple projections
public class GetOrderByIdHandler(AppDb db)
{
public Task<OrderDto?> Handle(GetOrderById q, CancellationToken ct) =>
db.Orders.AsNoTracking().Select(o => new OrderDto(o.Id, o.Total)).FirstOrDefaultAsync(o => o.Id == q.Id, ct);
}
❌ Wrong: command handler returning huge DTO
Commands should return the minimum (Id of created entity, or unit). Read via separate query if needed.
Design patterns for this topic
Pattern 1 — "Vertical slice per request"
- Intent: request, handler, validator co-located.
Pattern 2 — "Pipeline behaviors for cross-cutting"
- Intent: logging, validation, transaction in one place.
Pattern 3 — "Read DTOs, not domain entities"
- Intent: queries return query-shaped data.
Pattern 4 — "Notifications for domain events"
- Intent: decoupled in-process broadcast.
Pattern 5 — "Source-gen mediator for AOT"
- Intent: alternative to reflection-based MediatR.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| CQRS | Clear separation; scaling | More types per feature |
| MediatR | Mature; conventional | Now commercial; reflection-based |
| Source-gen mediator | Free; AOT | Less mature |
| Hand-rolled | No deps | More boilerplate |
| Pipeline behaviors | Cross-cutting cleanly | Hidden execution order |
When to use / when to avoid
- Use CQRS when read/write models genuinely differ.
- Use MediatR alternative for new projects.
- Use pipeline behaviors for validation, logging, transactions.
- Avoid CQRS for simple CRUD.
- Avoid
MediatR.Sendfrom handlers — couples handlers to each other.
Interview Q&A
Q1. What's CQRS? Command-Query Responsibility Segregation. Separate write (command) and read (query) models, often as separate handlers.
Q2. Does CQRS require event sourcing? No. CQRS is independent. Event sourcing typically implies CQRS but not the reverse.
Q3. What's a MediatR pipeline behavior? Wraps the handler. Lets cross-cutting concerns (logging, validation, transaction) be added without touching handlers.
Q4. What's a notification? A request published to multiple handlers — fire-and-forget broadcast. Useful for in-process domain events.
Q5. Why is MediatR's licensing changing? v12+ requires paid license for commercial use. Alternatives: Wolverine, Mediator (source-gen), hand-rolled.
Q6. Should command handlers return data? Minimal — typically just an ID or success indicator. Read via separate query.
Q7. Where do validators run with MediatR + FluentValidation? In a pipeline behavior. Validators auto-discovered; behavior runs them before the handler.
Q8. Could you use CQRS without a mediator library? Yes — direct handler classes injected. Skip the IMediator.Send indirection.
Q9. When does MediatR pipeline ordering matter? Behaviors run in registration order. Logging usually first/last; validation before handler; transaction wrapping the rest.
Q10. What's a "thick" handler? Handler doing too much: validation, business logic, side effects, mapping. Refactor to keep handler focused on the operation.
Gotchas / common mistakes
- ⚠️ Mediator from handler — coupling.
- ⚠️ Heavy logic in query — keep simple projection.
- ⚠️ Missing pipeline behavior order — surprising effects.
- ⚠️ MediatR v12+ in production without license — legal risk.