.NET · CQRS · MediatR · C#
Implementing CQRS with MediatR in .NET Core
Command Query Responsibility Segregation with MediatR in .NET: thin controllers, queries and commands, domain events, and the reliability gap that leads to the Outbox Pattern.
Janardhana Bandaru
· 11 min read
Let me paint you a picture. You join a new project. The codebase looks clean on the surface. The folder structure is tidy, the naming conventions are consistent, and there is a nice README. Then you open a controller.
It is 600 lines long. There is a constructor injecting eight services. There is business logic sitting right next to database calls. One method sends an email, updates a record, logs the action, and posts an event to a queue. All in the same breath. You scroll up. You scroll down. You close the file and take a slow sip of coffee.
Welcome to what I call the Controller God Syndrome. And the painful part? Most of us have written code exactly like this at some point. I know I have.
The Question Nobody Asks Early Enough
Here is the thing that took me years to fully appreciate. The problem is not laziness. It is not even ignorance. The problem is that MVC, as a pattern, is extraordinarily good at one thing: handling HTTP. Routing, serialization, status codes, middleware. That is what controllers are built for.
But somewhere along the way, controllers became the default home for everything else too. Business rules. Validation logic. Transaction handling. Orchestration of multiple services. And the longer a project runs, the more weight gets loaded onto that one poor endpoint.
Now this raises an important question. What if there was a way to strip the controller down to what it should actually be, and move all that business logic somewhere cleaner, more testable, and more maintainable? What if reads and writes had their own dedicated paths?
That is the core promise of CQRS. And MediatR is the library that makes it practical in .NET.
CQRS: The Idea Is Simpler Than the Acronym
This article is about CQRS at the application layer, in a single process. Separate read and write databases, eventual consistency across services, and full event sourcing are different problems. We stay focused on how a .NET API organizes reads, writes, and side effects cleanly.
CQRS stands for Command Query Responsibility Segregation. The name is heavy. The idea is not.
Think about how a bank works. When you check your balance, nothing is updated. Data is read and returned. When you transfer money, the bank validates the account, moves funds, logs the transfer, and may trigger compliance checks. Those operations have different risk profiles, performance characteristics, and failure modes.
The foundation is simple:
- A Query reads data and returns it. It never modifies state.
- A Command changes state and returns nothing, or only an acknowledgment (often a new ID).
In most codebases, the same service still does both. The same OrderService that fetches an order by ID also creates, updates, and cancels orders. As the application grows, that service becomes a fat tangle because it is trying to be two different things at once.
Enter MediatR: The Air Traffic Controller
MediatR is a .NET library created by Jimmy Bogard, the same person behind AutoMapper. At its heart, MediatR implements the Mediator design pattern. Instead of components talking to each other directly, every request goes through a central mediator that routes it to the right handler.
Think of it like an air traffic controller. Planes do not talk to each other directly. If they did, the sky would be chaos. Instead, every plane communicates through a central tower that knows who is landing, who is taking off, and what runway is free. The controller takes the message, figures out who should handle it, and routes it accordingly.
MediatR works the same way. Your controller sends a request object (a command or a query) to the mediator. The mediator looks up the registered handler for that specific request type, invokes it, and returns the result. The controller never needs to know which service handled it, how, or what dependencies were involved.
The Mediator pattern promotes loose coupling by keeping objects from referring to each other explicitly and it lets you vary their interaction independently.
— Gang of Four, Design Patterns (1994)
That is the theory. Here is what it looks like in practice.
Setting It Up
Installing MediatR in a .NET Core project is a single NuGet command.
dotnet add package MediatR
Then register it in your Program.cs (or Startup.cs if you are on an older version).
var builder = WebApplication.CreateBuilder(args);
// Register MediatR and scan your assembly for handlers
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.Run();
That one line scans your entire assembly, finds every class that implements IRequestHandler, and registers them automatically. No manual wiring. No factory patterns. MediatR does the discovery for you.
Your First Query: Fetching an Order
Start with the read side. You want to fetch an order by ID. In traditional MVC you inject IOrderService and call GetByIdAsync. That works. Watch what changes with MediatR.
First, define the query. A query is just a plain C# class that implements IRequest<T>, where T is the return type.
using MediatR;
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto>;
public record OrderDto(
Guid Id,
string CustomerName,
decimal TotalAmount,
string Status,
DateTime CreatedAt
);
Notice that we are using a C# record here. Records are immutable by default, which is perfect for queries. A query should never carry mutable state. It is a snapshot of what you are asking for, nothing more.
Now write the handler.
using MediatR;
using Microsoft.EntityFrameworkCore;
public class GetOrderByIdQueryHandler
: IRequestHandler<GetOrderByIdQuery, OrderDto>
{
private readonly AppDbContext _db;
public GetOrderByIdQueryHandler(AppDbContext db)
{
_db = db;
}
public async Task<OrderDto> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
var order = await _db.Orders
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken)
?? throw new NotFoundException($"Order {request.OrderId} not found");
return new OrderDto(
order.Id,
order.CustomerName,
order.TotalAmount,
order.Status.ToString(),
order.CreatedAt
);
}
}
And finally, the controller. Look at how clean it becomes.
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await _mediator.Send(new GetOrderByIdQuery(id));
return Ok(result);
}
}
That is the entire controller. One dependency. One responsibility. The controller receives the HTTP request, constructs the query, sends it through the mediator, and returns the result. It does not know or care what database is used, which ORM is doing the querying, or how the data is shaped.
Your First Command: Creating an Order
Now the write side. Commands are slightly different. They represent intent to change state. They carry the data needed to perform that change. In strict CQRS, a command returns nothing at all. It fires and forgets, and the caller never expects data back. That is the purist position, and Greg Young (who coined CQRS) holds it firmly.
In practice though, most teams make one pragmatic compromise: returning the ID of the newly created record. Jimmy Bogard, who built MediatR, openly endorses this. The reason is simple. If your command creates an order and the caller needs to redirect to /orders/{id}, forcing them to issue a second query just to get that ID is unnecessary friction. So we return a Guid. It is a deliberate, conscious trade-off, not a mistake. Know when you are bending the rule and why.
using MediatR;
public record CreateOrderCommand(
string CustomerName,
string CustomerEmail,
List<OrderLineItem> LineItems
) : IRequest<Guid>;
public record OrderLineItem(
Guid ProductId,
int Quantity,
decimal UnitPrice
);
using MediatR;
public class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, Guid>
{
private readonly AppDbContext _db;
// IPublisher is used here instead of IMediator.
// It only handles Publish — use it when your class does not need to Send queries or commands.
private readonly IPublisher _publisher;
public CreateOrderCommandHandler(AppDbContext db, IPublisher publisher)
{
_db = db;
_publisher = publisher;
}
public async Task<Guid> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerName = request.CustomerName,
CustomerEmail = request.CustomerEmail,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow,
LineItems = request.LineItems.Select(li => new LineItem
{
ProductId = li.ProductId,
Quantity = li.Quantity,
UnitPrice = li.UnitPrice,
}).ToList()
};
order.TotalAmount = order.LineItems.Sum(li => li.Quantity * li.UnitPrice);
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
// Publish a domain event. Note: this runs AFTER SaveChangesAsync, outside the DB transaction.
// If this call fails, the order is already committed but the event was never dispatched.
// For guaranteed delivery, replace this with the Outbox Pattern.
await _publisher.Publish(new OrderCreatedEvent(order.Id, order.CustomerEmail), cancellationToken);
return order.Id;
}
}
Look at the Publish call at the bottom. After saving the order, we announce OrderCreatedEvent. We do not call email or inventory from the command handler. We say something happened and let other handlers react. That decoupling is powerful. It is also where reliability problems hide, which we will face honestly after the fan-out example.
Domain Events: Publish and Subscribe
MediatR supports a Publish/Subscribe model through the INotification interface. Unlike requests (which have exactly one handler), notifications can have many handlers. Every interested component gets notified.
using MediatR;
// The event itself
public record OrderCreatedEvent(Guid OrderId, string CustomerEmail) : INotification;
// Handler 1: Send confirmation email
public class SendOrderConfirmationEmailHandler
: INotificationHandler<OrderCreatedEvent>
{
private readonly IEmailService _emailService;
public SendOrderConfirmationEmailHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task Handle(
OrderCreatedEvent notification,
CancellationToken cancellationToken)
{
await _emailService.SendConfirmationAsync(
notification.CustomerEmail,
notification.OrderId);
}
}
// Handler 2: Update inventory
public class ReserveInventoryHandler
: INotificationHandler<OrderCreatedEvent>
{
private readonly IInventoryService _inventory;
public ReserveInventoryHandler(IInventoryService inventory)
{
_inventory = inventory;
}
public async Task Handle(
OrderCreatedEvent notification,
CancellationToken cancellationToken)
{
await _inventory.ReserveForOrderAsync(notification.OrderId);
}
}
Domain events · publish / subscribe
Command handler
Order saved · SaveChangesAsync
Publish event
OrderCreatedEvent
fans out to
-
SendOrderConfirmationEmailHandler
Confirmation email
-
ReserveInventoryHandler
Reserve stock
Sequential · same process · no DB rollback if one fails
Both handlers run when the event is published. Neither the command handler nor the controller knows about email or inventory. They are decoupled. A third reaction (audit log, warehouse feed) is a new INotificationHandler only. Zero changes to existing handlers.
Pipeline Behaviors: Cross-Cutting Concerns Without Copy-Paste
Handlers should not each reimplement logging, validation, and transactions. MediatR pipeline behaviors are middleware for the application layer: they wrap every request before and after the handler, the same way ASP.NET middleware wraps HTTP.
public interface IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken);
}
Call await next() to pass control inward. Work before next() runs on the way in. Work after next() runs on the way out. Throw before next() and the pipeline short-circuits: later behaviors and the handler never run.
MediatR request pipeline
-
HTTP request
POST /api/orders
-
Controller
mediator.Send(command)
- before
LoggingBehavior
enter · start timer
- guard
ValidationBehavior
may short-circuit
- core
Handler
CreateOrder · SaveChanges
- after
LoggingBehavior
exit · log duration
-
HTTP response
201 Created · orderId
Registration order is execution order. First registered is outermost: it enters first and exits last. The sample below is the minimal useful pair (logging, then validation). Production systems often add performance timing and a selective transaction behavior around the handler; that full stack is covered in the companion post.
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
This article stops at the mental model. Full implementations (ValidateAsync, transaction opt-in, order pitfalls) are in MediatR Pipeline Behaviors.
The Real World: Alternatives and Trade-offs
Before you refactor everything, be honest about the cost. MediatR is not a silver bullet.
The most common criticism is indirection. When someone new joins your team and looks at a controller, they see mediator.Send(new CreateOrderCommand(…)). They have no idea what handles that command without searching. In a traditional service-based architecture, the dependency is explicit: you inject IOrderService and call CreateAsync. You can Ctrl+Click straight to the implementation.
That indirection is a real cost. It adds a layer of navigation that not everyone appreciates. IDE support helps (and gets better every year), but the conceptual overhead is real.
Another common alternative is the Vertical Slice Architecture, which MediatR actually pairs exceptionally well with. Instead of organizing code by layers (Controllers, Services, Repositories), you organize by features. Every feature (create order, get order, cancel order) lives in its own folder with everything it needs: command, handler, validator, and DTO all in one place.
Features/
Orders/
Create/
CreateOrderCommand.cs
CreateOrderCommandHandler.cs
CreateOrderCommandValidator.cs
CreateOrderResponse.cs
GetById/
GetOrderByIdQuery.cs
GetOrderByIdQueryHandler.cs
OrderDto.cs
Cancel/
CancelOrderCommand.cs
CancelOrderCommandHandler.cs
This structure is remarkably developer-friendly. When a product manager asks you to change how orders are cancelled, you go straight to the Cancel folder. Everything you need is right there.
When Not to Use This
If you are building a small CRUD API with ten endpoints, MediatR is often overkill. The pattern adds boilerplate. For simple applications, a clean service layer is easier to navigate and just as correct.
But once your application crosses a certain threshold, the boilerplate pays dividends. When you have 50 features, 20 developers, and requirements that change weekly, the strict isolation that MediatR enforces becomes a genuine asset. Handlers are independently testable. Pipeline behaviors apply universally. New features slot in without touching existing ones.
- Use MediatR when your application has complex business logic that deserves its own home.
- Use it when you have cross-cutting concerns (logging, validation, caching) that you want to apply consistently.
- Use it when you want each feature to be independently testable without loading the entire service layer.
- Skip it for simple CRUD endpoints where a direct service call is more readable.
- Skip it if your team is unfamiliar with the pattern and there is no time to onboard them properly.
What About Performance?
MediatR uses reflection-based dispatch, which can sound expensive. In practice the overhead is microseconds. On a typical web API, network I/O, the database round-trip, and serialization dominate by orders of magnitude.
If you build a sub-millisecond, ultra-hot path, measure it and consider bypassing the mediator only where evidence demands it. For most enterprise APIs, that is not the battle worth fighting first.
Conclusion
CQRS and MediatR are not architecture magic. They are a discipline. They force you to name intent: this is a read, this is a write, this is something that happened. That explicitness costs boilerplate and onboarding time.
In return you get handlers with one job, a place for cross-cutting behavior, and side effects that are not buried inside the command. The controller becomes a thin HTTP adapter. Business logic gets a real home.
After more than a decade building enterprise .NET systems across healthcare, insurance, loyalty platforms, and AI products, I still reach for this pattern when the domain is complex enough to justify it. Not because it is fashionable. Because it scales with the problem.
The next time a controller opens at 600 lines, you know there is a better shape. Start with queries and commands. Publish domain events carefully. And treat the dual-write warning above as unfinished business until delivery is reliable.
Where to go next
- The Outbox Pattern: A Design Deep-Dive — the reliability gap behind in-process
PublishafterSaveChanges. - MediatR Pipeline Behaviors — production logging,
ValidateAsync, transactions, and registration order. - Test handlers in isolation with mocks, and send critical commands through the real mediator in integration tests so behaviors run too.