Skip to main content
Technology & EngineeringCsharp Dotnet307 lines

Mediatr

MediatR library for implementing CQRS, commands, queries, and pipeline behaviors in .NET

Quick Summary14 lines
You are an expert in the MediatR library for implementing the CQRS (Command Query Responsibility Segregation) and Mediator patterns in .NET applications.

## Key Points

- **Separate command and query models** — commands mutate state and can use a rich domain model; queries should project directly to DTOs for efficiency.
- **One handler per file** — keeps each handler small and focused; name the file after the request (e.g., `CreateOrderHandler.cs`).
- **Use marker interfaces** to scope behaviors — e.g., `ICommand` for transaction behavior, so queries are not wrapped in transactions.
- **Keep handlers thin** — delegate complex domain logic to domain services or aggregate methods. The handler orchestrates, it does not contain business rules.
- **Order behaviors deliberately** — validation should run before transaction, logging should wrap everything.
- **Over-engineering simple CRUD** — if a handler just calls `db.Add()` and `SaveChanges()`, MediatR adds indirection without benefit. Use it when you need pipeline behaviors or decoupling.
- **Circular dependencies in behaviors** — injecting services in behaviors that themselves depend on MediatR can cause resolution loops. Keep behavior dependencies minimal.
- **Too many behaviors** — each behavior adds overhead. Profile the pipeline if handler latency becomes an issue.
skilldb get csharp-dotnet-skills/MediatrFull skill: 307 lines
Paste into your CLAUDE.md or agent config

MediatR CQRS Pattern — C#/.NET

You are an expert in the MediatR library for implementing the CQRS (Command Query Responsibility Segregation) and Mediator patterns in .NET applications.

Core Philosophy

Overview

MediatR is an in-process messaging library that decouples request senders from handlers. It enables CQRS by separating commands (write operations) from queries (read operations), each handled by a dedicated handler class. Pipeline behaviors provide a clean way to layer cross-cutting concerns like validation, logging, and transactions.

Core Concepts

Commands and Queries

Commands represent intent to change state. Queries represent intent to read state.

// Command: changes state, returns a result
public record CreateOrderCommand(
    string CustomerId,
    List<OrderItemDto> Items) : IRequest<OrderDto>;

// Command: changes state, no return value
public record CancelOrderCommand(int OrderId) : IRequest;

// Query: reads state, never mutates
public record GetOrderByIdQuery(int OrderId) : IRequest<OrderDto>;

public record GetOrdersQuery(
    int Page,
    int PageSize,
    string? Status) : IRequest<PagedResult<OrderDto>>;

Handlers

public class CreateOrderHandler(
    AppDbContext db,
    ILogger<CreateOrderHandler> logger)
    : IRequestHandler<CreateOrderCommand, OrderDto>
{
    public async Task<OrderDto> Handle(
        CreateOrderCommand request, CancellationToken ct)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Items = request.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice
            }).ToList(),
            Status = OrderStatus.Pending
        };

        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);

        logger.LogInformation("Created order {OrderId} for {CustomerId}",
            order.Id, request.CustomerId);

        return order.ToDto();
    }
}

public class GetOrderByIdHandler(AppDbContext db)
    : IRequestHandler<GetOrderByIdQuery, OrderDto>
{
    public async Task<OrderDto> Handle(
        GetOrderByIdQuery request, CancellationToken ct)
    {
        var order = await db.Orders
            .AsNoTracking()
            .Include(o => o.Items)
            .Where(o => o.Id == request.OrderId)
            .Select(o => o.ToDto())
            .FirstOrDefaultAsync(ct);

        return order ?? throw new NotFoundException($"Order {request.OrderId} not found");
    }
}

Registration

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<CreateOrderCommand>();
    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
    cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});

Implementation Patterns

Pipeline Behaviors

Behaviors wrap every request and execute in registration order, forming a pipeline.

// Logging behavior
public class LoggingBehavior<TRequest, TResponse>(
    ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        logger.LogInformation("Handling {Request}: {@Payload}", requestName, request);

        var sw = Stopwatch.StartNew();
        var response = await next();
        sw.Stop();

        logger.LogInformation("Handled {Request} in {ElapsedMs}ms",
            requestName, sw.ElapsedMilliseconds);

        return response;
    }
}

Validation Behavior with FluentValidation

public class ValidationBehavior<TRequest, TResponse>(
    IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    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 = (await Task.WhenAll(
                validators.Select(v => v.ValidateAsync(context, ct))))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

// Validator
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item");
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.Quantity).GreaterThan(0);
            item.RuleFor(i => i.UnitPrice).GreaterThan(0);
        });
    }
}

Transaction Behavior

public class TransactionBehavior<TRequest, TResponse>(AppDbContext db)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICommand  // marker interface for commands only
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        await using var transaction = await db.Database.BeginTransactionAsync(ct);

        try
        {
            var response = await next();
            await transaction.CommitAsync(ct);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }
}

Notifications (Pub/Sub)

// Notification: broadcast to all handlers
public record OrderCreatedNotification(int OrderId, string CustomerId) : INotification;

// Multiple handlers can process the same notification
public class SendOrderConfirmationEmail(IEmailSender email)
    : INotificationHandler<OrderCreatedNotification>
{
    public async Task Handle(OrderCreatedNotification n, CancellationToken ct)
    {
        await email.SendOrderConfirmationAsync(n.OrderId, n.CustomerId);
    }
}

public class UpdateInventory(IInventoryService inventory)
    : INotificationHandler<OrderCreatedNotification>
{
    public async Task Handle(OrderCreatedNotification n, CancellationToken ct)
    {
        await inventory.DeductStockAsync(n.OrderId, ct);
    }
}

// Publishing from a command handler
public class CreateOrderHandler(AppDbContext db, IPublisher publisher)
    : IRequestHandler<CreateOrderCommand, OrderDto>
{
    public async Task<OrderDto> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        // ... create order ...
        await db.SaveChangesAsync(ct);
        await publisher.Publish(new OrderCreatedNotification(order.Id, request.CustomerId), ct);
        return order.ToDto();
    }
}

Wiring to Minimal API Endpoints

public static class OrderEndpoints
{
    public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
    {
        group.MapGet("/{id:int}", async (int id, ISender sender) =>
            await sender.Send(new GetOrderByIdQuery(id)));

        group.MapGet("/", async ([AsParameters] GetOrdersQuery query, ISender sender) =>
            await sender.Send(query));

        group.MapPost("/", async (CreateOrderCommand command, ISender sender) =>
        {
            var order = await sender.Send(command);
            return TypedResults.Created($"/orders/{order.Id}", order);
        });

        group.MapDelete("/{id:int}", async (int id, ISender sender) =>
        {
            await sender.Send(new CancelOrderCommand(id));
            return TypedResults.NoContent();
        });

        return group;
    }
}

Best Practices

  • Separate command and query models — commands mutate state and can use a rich domain model; queries should project directly to DTOs for efficiency.
  • One handler per file — keeps each handler small and focused; name the file after the request (e.g., CreateOrderHandler.cs).
  • Use marker interfaces to scope behaviors — e.g., ICommand for transaction behavior, so queries are not wrapped in transactions.
  • Keep handlers thin — delegate complex domain logic to domain services or aggregate methods. The handler orchestrates, it does not contain business rules.
  • Order behaviors deliberately — validation should run before transaction, logging should wrap everything.

Common Pitfalls

  • Over-engineering simple CRUD — if a handler just calls db.Add() and SaveChanges(), MediatR adds indirection without benefit. Use it when you need pipeline behaviors or decoupling.
  • Notification handlers silently failing — by default, an exception in one notification handler does not stop others, but the exception may be swallowed. Configure MediatR to use a custom publisher strategy if you need error handling.
  • Circular dependencies in behaviors — injecting services in behaviors that themselves depend on MediatR can cause resolution loops. Keep behavior dependencies minimal.
  • Sending notifications inside a transaction — if a notification handler calls an external API and the transaction rolls back, the external side effect has already occurred. Publish notifications after commit or use an outbox pattern.
  • Too many behaviors — each behavior adds overhead. Profile the pipeline if handler latency becomes an issue.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add csharp-dotnet-skills

Get CLI access →