Rethinking Mediator: Why I Opt for Simplicity in .NET Projects
Embracing Clarity: Streamlining .NET Projects for Better Developer Experience

The Mediator pattern has become almost a default choice in many .NET projects I’ve seen over the years. It promises better separation of responsibilities, cleaner controllers, lower coupling, and the idea that it is the right enterprise approach for applying CQRS.
I gave it a real chance. However, over time, I realized its practical value was much lower than expected. Even worse, the developer experience became slower. Following the flow of the code, something as simple as pressing F12 turned into a chain of jumps, references, and layers that were hard to trace. In practice, Mediator often hides the logic instead of organizing it.
Let’s look at some of the most common reasons why this pattern is recommended, and why they don’t always represent a real advantage in practice.
1. The myth of low coupling
One of the most common arguments is that Mediator reduces coupling between layers, since the controller does not depend directly on a specific use case. In reality, this “low coupling” is relative: the dependency doesn’t disappear, it just moves to a generic interface such as IMediator. The problem is that this makes it harder to trace what actually happens in the code.
This is more natural:
public class ReportGenerator
{
private readonly IReportRepository _repo;
public ReportGenerator(IReportRepository repo) => _repo = repo;
public Task<Report> Generate(DateTime from, DateTime to)
=> _repo.GenerateAsync(from, to);
}
// Usage
_reportGenerator.Generate(from, to);
Than this:
public record GenerateReportQuery(DateTime From, DateTime To) : IRequest<Report>;
public class GenerateReportHandler : IRequestHandler<GenerateReportQuery, Report>
{
private readonly IReportRepository _repo;
public GenerateReportHandler(IReportRepository repo) => _repo = repo;
public Task<Report> Handle(GenerateReportQuery query, CancellationToken ct)
=> _repo.GenerateAsync(query.From, query.To);
}
// Usage
_mediator.Send(new GenerateReportQuery(from, to));
The code looks decoupled on paper, but in practice, every jump through the mediator adds friction to understanding and debugging. A controller that depends on a façade or an application service is also decoupled from concrete implementations, but keeps a clear and predictable link to the actual use cases of the system.
2. Easy to Test?
It is often said that Mediator makes testing easier because each handler can be tested on its own. This can be true, but it is not unique to Mediator. Any well-structured design with clear separation of responsibilities provides the same benefit.
In fact, the extra boilerplate (requests, handlers, configurations) usually increases the effort needed to set up tests instead of reducing it. Testing becomes easy when responsibilities are small and explicit, not when wrapped inside an extra abstraction.
3. Separation of Responsibilities
Mediator is also associated with a better separation of responsibilities: controllers stay thin, and each handler represents one use case. But again, this is not something exclusive to the pattern. The same clarity can be achieved with explicit classes that describe exactly what they do: ReportGenerator, PaymentProcessor, etc.
This looks clean and thin:
// Each operation is forced into a Request + Handler
public record ApproveOrderCommand(Guid OrderId) : IRequest;
public class ApproveOrderHandler : IRequestHandler<ApproveOrderCommand>
{
private readonly IOrderRepository _orders;
public ApproveOrderHandler(IOrderRepository orders) => _orders = orders;
public async Task Handle(ApproveOrderCommand cmd, CancellationToken ct)
{
var order = await _orders.GetAsync(cmd.OrderId);
order.Approve();
await _orders.SaveAsync(order);
}
}
But this too, and without creating trivial classes (commands, handlers) that add zero semantics to the code:
public class OrderApproval
{
private readonly IOrderRepository _orders;
public OrderApproval(IOrderRepository orders) => _orders = orders;
public async Task Approve(Guid orderId)
{
var order = await _orders.GetAsync(orderId);
order.Approve();
await _orders.SaveAsync(order);
}
}
With Mediator, structure is often dictated by the library instead of intentional design. The result is many trivial classes, less context, and slower navigation.
4. Pipeline Behaviors
If there is one real advantage of Mediator, it is the ability to easily define pipelines for handling common concerns such as logging, validation, or transactions. This helps avoid code duplication and keeps these concerns separated from the business logic.
However, it is very likely that middlewares, action filters, and decorators might cover most scenarios. Here is where I ask, do we really need it? In most cases, probably no.
Conclusion
Mediator aims to solve real problems, such as coupling, testing, and consistency, but it does so by adding an extra layer of infrastructure that often complicates things more than it simplifies them.
Developers spend most of their time reading code, so giving them the best experience when navigating through it is essential. Mediator makes it difficult to follow the flow, increasing cognitive load and slowing navigation. And it even gets worse when using notifications.
A clean design with explicit dependencies and well-defined layers can achieve the same results without unnecessary abstractions. In most real-world projects, we don’t need more indirection; we need more intention.
