.NET & C# Development · Lesson 8 of 11
CQRS with MediatR (+ Without)
What is CQRS?
CQRS (Command Query Responsibility Segregation) separates operations that change state (commands) from operations that read state (queries).
Traditional: OrderService.CreateOrder() ← same service does everything
OrderService.GetOrders()
CQRS: CreateOrderCommand → CreateOrderCommandHandler
GetOrdersQuery → GetOrdersQueryHandlerThe benefits:
- Each handler has one job — easy to test, easy to reason about
- Commands and queries can scale independently (separate read/write DBs if needed)
- Pipeline behaviors give you cross-cutting concerns (validation, logging, caching) without touching handlers
- Clean separation maps perfectly onto Clean Architecture's Application layer
Setting Up MediatR
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection # MediatR < 12
# MediatR 12+ includes DI support nativelyRegister in Program.cs:
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)
);For multi-project solutions (e.g., Application layer in a separate project):
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly)
);Commands
A command represents an intent to change state. It returns either nothing or a minimal result (like a created ID).
Defining a Command
// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(
int CustomerId,
List<OrderItemDto> Items,
string DeliveryAddress
) : IRequest<int>; // returns the new order's ID
public record OrderItemDto(int ProductId, int Quantity, decimal UnitPrice);Command Handler
// Application/Orders/Commands/CreateOrder/CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
private readonly IAppDbContext _db;
private readonly ICurrentUserService _currentUser;
private readonly IDateTime _dateTime;
public CreateOrderCommandHandler(
IAppDbContext db,
ICurrentUserService currentUser,
IDateTime dateTime)
{
_db = db;
_currentUser = currentUser;
_dateTime = dateTime;
}
public async Task<int> Handle(CreateOrderCommand request, CancellationToken ct)
{
// Validate business rules
var customer = await _db.Customers.FindAsync(request.CustomerId, ct)
?? throw new NotFoundException(nameof(Customer), request.CustomerId);
// Build the aggregate
var order = new Order
{
CustomerId = request.CustomerId,
Reference = GenerateReference(),
Status = OrderStatus.Pending,
DeliveryAddress = request.DeliveryAddress,
CreatedAt = _dateTime.UtcNow,
CreatedBy = _currentUser.UserId,
Items = request.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
}).ToList()
};
order.TotalAmount = order.Items.Sum(i => i.Quantity * i.UnitPrice);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return order.Id;
}
private static string GenerateReference()
=> $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString()[..6].ToUpper()}";
}Controller — sending the command
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(CreateOrderCommand command, CancellationToken ct)
{
var orderId = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetById), new { id = orderId }, new { id = orderId });
}
}Queries
A query reads state and returns data. It should never modify state.
Defining a Query + DTO
// Application/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs
public record GetOrderByIdQuery(int OrderId) : IRequest<OrderDetailDto>;
public record OrderDetailDto(
int Id,
string Reference,
string Status,
decimal TotalAmount,
string CustomerName,
List<OrderItemDetailDto> Items
);
public record OrderItemDetailDto(
string ProductName,
int Quantity,
decimal UnitPrice,
decimal LineTotal
);Query Handler
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDetailDto>
{
private readonly IAppDbContext _db;
public GetOrderByIdQueryHandler(IAppDbContext db) => _db = db;
public async Task<OrderDetailDto> Handle(GetOrderByIdQuery request, CancellationToken ct)
{
var order = await _db.Orders
.AsNoTracking()
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.FirstOrDefaultAsync(o => o.Id == request.OrderId, ct)
?? throw new NotFoundException(nameof(Order), request.OrderId);
return new OrderDetailDto(
order.Id,
order.Reference,
order.Status.ToString(),
order.TotalAmount,
order.Customer.Name,
order.Items.Select(i => new OrderItemDetailDto(
i.Product.Name,
i.Quantity,
i.UnitPrice,
i.Quantity * i.UnitPrice
)).ToList()
);
}
}Pipeline Behaviors
Pipeline behaviors wrap every IMediator.Send() call — like middleware for your handlers. They run before and after the handler.
IMediator.Send(command)
→ ValidationBehavior
→ LoggingBehavior
→ PerformanceBehavior
→ YourHandler.Handle()Validation Behavior (with FluentValidation)
dotnet add package FluentValidation.DependencyInjectionExtensions// Application/Common/Behaviours/ValidationBehaviour.cs
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(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}Command validator:
// Application/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(c => c.CustomerId).GreaterThan(0);
RuleFor(c => c.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(c => c.Items).ChildRules(item =>
{
item.RuleFor(i => i.Quantity).GreaterThan(0);
item.RuleFor(i => i.UnitPrice).GreaterThan(0);
item.RuleFor(i => i.ProductId).GreaterThan(0);
});
RuleFor(c => c.DeliveryAddress).NotEmpty().MaximumLength(500);
}
}Logging Behavior
public class LoggingBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;
private readonly ICurrentUserService _currentUser;
public LoggingBehaviour(
ILogger<LoggingBehaviour<TRequest, TResponse>> logger,
ICurrentUserService currentUser)
{
_logger = logger;
_currentUser = currentUser;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
var userId = _currentUser.UserId ?? "anonymous";
_logger.LogInformation(
"Handling {RequestName} for user {UserId}: {@Request}",
requestName, userId, request);
var response = await next();
_logger.LogInformation("Handled {RequestName}", requestName);
return response;
}
}Performance Behavior
public class PerformanceBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly Stopwatch _timer = new();
private readonly ILogger<PerformanceBehaviour<TRequest, TResponse>> _logger;
public PerformanceBehaviour(ILogger<PerformanceBehaviour<TRequest, TResponse>> logger)
=> _logger = logger;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
_timer.Restart();
var response = await next();
_timer.Stop();
var elapsed = _timer.ElapsedMilliseconds;
if (elapsed > 500)
{
_logger.LogWarning(
"Slow request: {RequestName} ({Elapsed}ms) {@Request}",
typeof(TRequest).Name, elapsed, request);
}
return response;
}
}Register Behaviors
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>));
});
// FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);Notifications (Events)
MediatR also handles domain events via INotification. One event, multiple handlers.
// Define
public record OrderCreatedNotification(int OrderId, int CustomerId) : INotification;
// Handler 1: Send 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: Update inventory
public class ReserveInventoryHandler : INotificationHandler<OrderCreatedNotification>
{
public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
{
// reduce stock levels...
}
}Publish from the command handler:
await _db.SaveChangesAsync(ct);
// After successful save, publish the event
await _mediator.Publish(new OrderCreatedNotification(order.Id, order.CustomerId), ct);
return order.Id;Folder Structure
src/
└── Application/
├── Common/
│ ├── Behaviours/
│ │ ├── ValidationBehaviour.cs
│ │ ├── LoggingBehaviour.cs
│ │ └── PerformanceBehaviour.cs
│ ├── Exceptions/
│ │ ├── NotFoundException.cs
│ │ └── ValidationException.cs
│ └── Interfaces/
│ ├── IAppDbContext.cs
│ ├── ICurrentUserService.cs
│ └── IEmailService.cs
└── Orders/
├── Commands/
│ ├── CreateOrder/
│ │ ├── CreateOrderCommand.cs
│ │ ├── CreateOrderCommandHandler.cs
│ │ └── CreateOrderCommandValidator.cs
│ └── DeleteOrder/
│ ├── DeleteOrderCommand.cs
│ └── DeleteOrderCommandHandler.cs
└── Queries/
├── GetOrderById/
│ ├── GetOrderByIdQuery.cs
│ └── GetOrderByIdQueryHandler.cs
└── GetOrdersList/
├── GetOrdersListQuery.cs
└── GetOrdersListQueryHandler.csEach feature is fully self-contained. A new developer can navigate to Orders/Commands/CreateOrder and read the entire feature in three files.
Testing a Handler
Handlers are plain classes — no HTTP, no framework wiring:
public class CreateOrderCommandHandlerTests
{
[Fact]
public async Task Handle_ValidCommand_ReturnsNewOrderId()
{
// Arrange
var db = new AppDbContextFactory().CreateInMemory();
db.Customers.Add(new Customer { Id = 1, Name = "Alice" });
await db.SaveChangesAsync();
var handler = new CreateOrderCommandHandler(
db,
new FakeCurrentUserService("user-1"),
new FakeDateTime(DateTime.UtcNow)
);
var command = new CreateOrderCommand(
CustomerId: 1,
Items: [new(ProductId: 10, Quantity: 2, UnitPrice: 25m)],
DeliveryAddress: "123 Main St"
);
// Act
var orderId = await handler.Handle(command, CancellationToken.None);
// Assert
orderId.Should().BeGreaterThan(0);
var order = await db.Orders.FindAsync(orderId);
order.Should().NotBeNull();
order!.TotalAmount.Should().Be(50m);
}
}Key Takeaways
- Commands change state, queries read it — they never mix
- Each
IRequestHandler<TRequest, TResponse>does exactly one thing — no bloated service classes - Pipeline behaviors are the right place for validation, logging, caching, and transaction management — not inside handlers
INotification+INotificationHandlerreplace method calls for domain events, enabling multiple decoupled side effects per event- Handlers are pure classes — test them directly without ASP.NET Core or any HTTP wiring
CQRS Without MediatR
MediatR is a convenience library — it's not CQRS. CQRS is a pattern, and you can implement it with nothing but interfaces and .NET's built-in DI container. This is worth knowing because:
- Some teams ban third-party dependencies beyond a certain count
- You want full control over dispatch without a reflection-based runtime
- You're building a library or a small service where MediatR is overkill
The Core Interfaces
You only need two interfaces to express the pattern:
// A command or query — marker interface
public interface IRequest<TResponse> { }
// A handler that processes it
public interface IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
Task<TResponse> Handle(TRequest request, CancellationToken ct = default);
}
// Void command (no return value)
public interface ICommand : IRequest<Unit> { }
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Unit>
where TCommand : ICommand { }
// Read-only query
public interface IQuery<TResponse> : IRequest<TResponse> { }
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse> { }
// Unit type for void commands
public readonly struct Unit
{
public static readonly Unit Value = new();
}The Dispatcher
A thin dispatcher resolves the right handler from the DI container:
public interface IDispatcher
{
Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request, CancellationToken ct = default);
}
public class Dispatcher : IDispatcher
{
private readonly IServiceProvider _services;
public Dispatcher(IServiceProvider services) => _services = services;
public Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request, CancellationToken ct = default)
{
// Resolve IRequestHandler<TRequest, TResponse> from DI
var handlerType = typeof(IRequestHandler<,>)
.MakeGenericType(request.GetType(), typeof(TResponse));
dynamic handler = _services.GetRequiredService(handlerType);
return handler.Handle((dynamic)request, ct);
}
}Registration
// Program.cs
builder.Services.AddScoped<IDispatcher, Dispatcher>();
// Register every handler in the assembly — one line
var assembly = typeof(Program).Assembly;
var handlerTypes = assembly.GetTypes()
.Where(t => !t.IsAbstract && !t.IsInterface)
.SelectMany(t => t.GetInterfaces()
.Where(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>))
.Select(i => (Service: i, Implementation: t)));
foreach (var (service, impl) in handlerTypes)
builder.Services.AddScoped(service, impl);
// Or use Scrutor (a scanning library):
builder.Services.Scan(scan => scan
.FromAssemblyOf<Program>()
.AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>)))
.AsImplementedInterfaces()
.WithScopedLifetime());Commands and Queries — identical shape to MediatR
// Command
public record CreateOrderCommand(
int CustomerId,
List<OrderItemDto> Items,
string DeliveryAddress
) : ICommand;
// Handler
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
private readonly IAppDbContext _db;
public CreateOrderCommandHandler(IAppDbContext db) => _db = db;
public async Task<Unit> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
// exactly the same logic as the MediatR version
var order = new Order { /* ... */ };
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return Unit.Value;
}
}
// Query
public record GetOrderByIdQuery(int OrderId) : IQuery<OrderDetailDto>;
public class GetOrderByIdQueryHandler : IQueryHandler<GetOrderByIdQuery, OrderDetailDto>
{
private readonly IAppDbContext _db;
public GetOrderByIdQueryHandler(IAppDbContext db) => _db = db;
public async Task<OrderDetailDto> Handle(GetOrderByIdQuery query, CancellationToken ct)
{
var order = await _db.Orders
.AsNoTracking()
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == query.OrderId, ct)
?? throw new NotFoundException(query.OrderId);
return new OrderDetailDto(order.Id, order.Reference, /* ... */);
}
}Controller
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IDispatcher _dispatcher;
public OrdersController(IDispatcher dispatcher) => _dispatcher = dispatcher;
[HttpPost]
public async Task<IActionResult> Create(CreateOrderCommand command, CancellationToken ct)
{
await _dispatcher.SendAsync(command, ct);
return Ok();
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
var result = await _dispatcher.SendAsync(new GetOrderByIdQuery(id), ct);
return Ok(result);
}
}Adding Pipeline Behaviors (Without MediatR)
The dispatcher above doesn't have a pipeline. Here's how to add one using a decorator pattern:
// Generic decorator that wraps any handler with validation
public class ValidationHandlerDecorator<TRequest, TResponse>
: IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IRequestHandler<TRequest, TResponse> _inner;
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationHandlerDecorator(
IRequestHandler<TRequest, TResponse> inner,
IEnumerable<IValidator<TRequest>> validators)
{
_inner = inner;
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, CancellationToken ct)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count > 0)
throw new ValidationException(failures);
return await _inner.Handle(request, ct);
}
}Register with Scrutor's .Decorate():
// Wrap every IRequestHandler with the validation decorator
builder.Services.Decorate(typeof(IRequestHandler<,>), typeof(ValidationHandlerDecorator<,>));The result is identical to MediatR's IPipelineBehavior — every handler gets validation automatically, zero reflection overhead at dispatch time.
When to Use MediatR vs Roll Your Own
This is about cost — not just money, but complexity budget too. Ask these questions:
Use MediatR when…
✅ Medium-to-large application (10+ features, 3+ developers)
✅ You want pipeline behaviors out of the box (less boilerplate)
✅ Your team already knows MediatR — zero learning curve
✅ You use the notifications (INotification) pattern for domain events
✅ You want the Scrutor-free, convention-based handler discoveryMediatR costs: ~25KB NuGet package, uses reflection at dispatch time (negligible for APIs), one extra abstraction layer.
Skip MediatR (roll your own) when…
✅ Small service (< 10 features, solo or small team)
✅ You want to avoid all third-party runtime dependencies (library project, open-source SDK)
✅ You need maximum performance — hot path with thousands of req/s where reflection matters
✅ You want full control over dispatch logic (e.g. custom retry, telemetry wiring)
✅ Your company has a strict approved-dependencies listThe hand-rolled dispatcher above is ~30 lines of code. The only thing you lose compared to MediatR is:
- Auto-discovery via
RegisterServicesFromAssembly(replace with Scrutor or manual registration) - Built-in
INotificationfan-out (add your own event bus or use the decorator pattern) - The
IPipelineBehaviorordering syntax (replace with.Decorate()calls)
The Money Angle
MediatR itself is free (MIT licence). The cost is not the licence. The cost is:
| Factor | MediatR | Hand-rolled |
|--------|---------|-------------|
| Setup time | 5 min | 30 min (write dispatcher + decorators) |
| Learning curve for new devs | Low (widely known) | Low (your interfaces are simpler) |
| Package risk (breaking changes) | Real — MediatR 12 broke MediatR 11 projects | Zero — you own it |
| Reflection overhead at dispatch | Yes (micro, irrelevant below 5k req/s) | No |
| Pipeline ordering complexity | Config-based | Explicit .Decorate() order |
Practical rule of thumb:
- Startup / MVP / solo: skip MediatR, use a 30-line dispatcher
- Team product (3+ devs): use MediatR — the shared vocabulary is worth more than the overhead
- High-frequency internal service (e.g., background job processor doing 50k ops/s): hand-roll the dispatcher and skip reflection
- Open-source library: never take runtime dependencies you don't need — hand-roll always
Side-by-Side: Same Feature, Both Approaches
// ── With MediatR ─────────────────────────────────────────────
// Registration:
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Dispatch:
var result = await _mediator.Send(new GetOrderByIdQuery(id), ct);
// ── Without MediatR ───────────────────────────────────────────
// Registration:
builder.Services.Scan(scan => scan
.FromAssemblyOf<Program>()
.AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>)))
.AsImplementedInterfaces()
.WithScopedLifetime());
builder.Services.AddScoped<IDispatcher, Dispatcher>();
// Dispatch:
var result = await _dispatcher.SendAsync(new GetOrderByIdQuery(id), ct);The calling code is identical. The only difference is what sits between the request and the handler. Your domain logic, your tests, your folder structure — all stay exactly the same either way.
Updated Key Takeaways
- CQRS is a pattern, not a library — you can implement it with 30 lines of DI wiring
- MediatR adds value through pipeline behaviors and conventions — not because CQRS requires it
- Hand-rolled dispatcher uses
.MakeGenericType()+ DI resolution — simple, fast, fully yours - Decorator pattern (
Scrutor.Decorate) gives you pipeline behaviors without MediatR - Use MediatR on team projects where the shared vocabulary justifies the dependency; skip it on small services, libraries, or performance-critical paths
- Either way: commands, queries, handlers, and tests look identical — the abstraction is swappable