.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      → GetOrdersQueryHandler

The 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

Bash
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection  # MediatR < 12
# MediatR 12+ includes DI support natively

Register in Program.cs:

C#
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)
);

For multi-project solutions (e.g., Application layer in a separate project):

C#
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

C#
// 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

C#
// 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

C#
[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

C#
// 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

C#
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)

Bash
dotnet add package FluentValidation.DependencyInjectionExtensions
C#
// 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:

C#
// 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

C#
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

C#
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

C#
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.

C#
// 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:

C#
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.cs

Each 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:

C#
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 + INotificationHandler replace 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:

C#
// 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:

C#
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

C#
// 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

C#
// 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

C#
[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:

C#
// 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():

C#
// 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 discovery

MediatR 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 list

The 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 INotification fan-out (add your own event bus or use the decorator pattern)
  • The IPipelineBehavior ordering 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

C#
// ── 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