CQRS concept, IRequest, IRequestHandler, pipeline behaviors, MediatR setup, CQRS vs CRUD
CQRS and the Mediator pattern are two of the most widely used architectural patterns in modern .NET systems.
CQRS gives you the why — separate reads from writes.
MediatR gives you the how — a clean, decoupled way to dispatch commands and queries.
What Is CQRS?
CQRS stands for Command Query Responsibility Segregation.
Reads and writes are fundamentally different operations. Treat them differently.
Write operations → Commands (change state, return nothing or an ID)
Read operations → Queries (return data, change nothing)
Before CQRS
public class OrderService
{
public async Task CreateOrder(CreateOrderDto dto) { }
public async Task<Order> GetOrder(Guid id) { }
public async Task<List<Order>> GetOrdersByUser(Guid userId) { }
public async Task CancelOrder(Guid id) { }
public async Task UpdateOrder(Guid id, UpdateOrderDto dto) { }
}
This service grows without limit. Every developer adds to it. It becomes untestable.
After CQRS
Commands: CreateOrderCommand, CancelOrderCommand, UpdateOrderCommand
Queries: GetOrderQuery, GetOrdersByUserQuery
Each operation is its own class. Each has its own handler. They are independent.
What Is the Mediator Pattern?
The Mediator pattern reduces coupling by routing messages through a central mediator.
// ❌ Controller directly calls service — tightly coupled
var service = new OrderService(db, email, logger);
service.CreateOrder(dto);
// ✅ Controller sends a command — doesn't know who handles it
await _mediator.Send(new CreateOrderCommand(dto));
MediatR in Practice
Installation
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Registration
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
Commands — Write Operations
Define the command
public record CreateOrderCommand(string CustomerId, string Product, int Quantity)
: IRequest<Guid>;
Write the handler
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly AppDbContext _db;
private readonly IEmailService _email;
public CreateOrderCommandHandler(AppDbContext db, IEmailService email)
{
_db = db;
_email = email;
}
public async Task<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = cmd.CustomerId,
Product = cmd.Product,
Quantity = cmd.Quantity,
Status = OrderStatus.Pending
};
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
await _email.SendConfirmationAsync(order);
return order.Id;
}
}
Send from the controller
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderCommand command)
{
var id = await _mediator.Send(command);
return CreatedAtAction(nameof(Get), new { id }, null);
}
Queries — Read Operations
Define the query
public record GetOrdersByUserQuery(string CustomerId) : IRequest<List<OrderDto>>;
Write the handler
public class GetOrdersByUserQueryHandler
: IRequestHandler<GetOrdersByUserQuery, List<OrderDto>>
{
private readonly AppDbContext _db;
public GetOrdersByUserQueryHandler(AppDbContext db) => _db = db;
public async Task<List<OrderDto>> Handle(
GetOrdersByUserQuery query, CancellationToken ct)
{
return await _db.Orders
.Where(o => o.CustomerId == query.CustomerId)
.Select(o => new OrderDto(o.Id, o.Product, o.Status))
.AsNoTracking()
.ToListAsync(ct);
}
}
Pipeline Behaviours — Cross-Cutting Concerns
Pipeline behaviours wrap every command or query — perfect for logging, validation, and caching.
public class LoggingBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
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)
{
_logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
var response = await next();
_logger.LogInformation("Handled {Request}", typeof(TRequest).Name);
return response;
}
}
// Register
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
Validation with FluentValidation
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Quantity).GreaterThan(0);
}
}
public class ValidationBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
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)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(e => e != null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}
Interview-Ready Summary
- CQRS separates reads (queries) from writes (commands)
- The Mediator pattern routes messages through a central dispatcher — removing direct dependencies
- MediatR is the standard .NET implementation of the Mediator pattern
- Commands = write operations, implement
IRequest<T> - Queries = read operations, implement
IRequest<T> - Handlers implement
IRequestHandler<TRequest, TResponse> - Pipeline behaviours add cross-cutting concerns: logging, validation, caching
- Controllers send commands/queries — they don't know who handles them
A strong interview answer:
"CQRS separates reads and writes into distinct objects — Commands mutate state, Queries return data. MediatR implements the Mediator pattern by routing these objects to their handlers. This decouples controllers from services, makes each operation independently testable, and allows pipeline behaviours to wrap every request with logging, validation, or caching without any handler knowing about it."
Top comments (0)