In a typical ASP.NET Core application, you might be tempted to place a lot of logic in your controllers or services. As the app grows, this can lead to:
- Controllers bloated with business logic
- Services tightly coupled and difficult to test
- A tangled web of dependencies that becomes a nightmare to maintain
Sound familiar?
Enter the Mediator pattern, and more specifically, MediatR: a lightweight library that brings this pattern to your .NET applications.
What Is the Mediator Pattern?
The Mediator pattern encourages communication between objects through a central “mediator” rather than direct references. It promotes:
- Loose coupling between components
- Single Responsibility in handlers
- Centralised coordination of behaviour
In ASP.NET Core, this means that instead of calling services directly from controllers, we send a request to MediatR, which routes it to a handler.
Why Use MediatR?
Some key benefits:
✅ Your controllers stay clean and focused
✅ Business logic is encapsulated in small, testable units
✅ Great synergy with the CQRS pattern
✅ Built-in support for behaviours (logging, validation, caching)
Getting Started with MediatR
Install the NuGet packages:
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Register MediatR in your application (example using .NET 8 Minimal APIs):
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
var app = builder.Build();
Define a Request
Create a request that represents your action. For example, fetching a list of products:
public record GetAllProductsQuery() : IRequest<IEnumerable<Product>>;
This is a query (read operation) using the IRequest<TResponse>
interface from MediatR.
Create the Handler
Now define a handler that contains the actual logic:
public class GetAllProductsHandler : IRequestHandler<GetAllProductsQuery, IEnumerable<Product>>
{
private readonly ProductDbContext _context;
public GetAllProductsHandler(ProductDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Product>> Handle(GetAllProductsQuery request, CancellationToken cancellationToken)
{
return await _context.Products
.AsNoTracking()
.ToListAsync(cancellationToken);
}
}
This handler can be easily tested in isolation.
Use It in Your Endpoint
Now, your endpoint becomes clean and focused:
app.MapGet("/products", async (ISender sender) =>
{
var products = await sender.Send(new GetAllProductsQuery());
return Results.Ok(products);
});
Notice how we don’t inject the database context or repository directly — just MediatR’s ISender
.
Structure: Commands vs Queries
When applying CQRS with MediatR, we typically separate:
IRequest<T>
: Queries (read-only, return data)IRequest<Unit>
orIRequest<bool>
: Commands (write operations, return nothing or success/failure)
This encourages clear separation of concerns.
Add Pipeline Behaviours (Validation, Logging, etc.)
One of MediatR’s most powerful features is the pipeline.
You can inject cross-cutting concerns like validation, logging, caching, etc., without touching your handlers.
Example: a simple logging behaviour
public class LoggingBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;
public LoggingBehaviour(ILogger<LoggingBehaviour<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
_logger.LogInformation("Handling {RequestName}", typeof(TRequest).Name);
var response = await next();
_logger.LogInformation("Handled {RequestName}", typeof(TRequest).Name);
return response;
}
}
Register it in your Program.cs
:
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
You can do the same for FluentValidation, retry policies, or performance tracing.
Can I Still Use Dependency Injection?
Absolutely! All your handlers and behaviours can use constructor injection as usual. MediatR plays very well with the standard ASP.NET Core DI system.
How Do I Test It?
Testing is simple. Just instantiate the handler and pass the request directly:
[Fact]
public async Task GetAllProducts_ReturnsData()
{
// Arrange
var context = FakeDbContextFactory.Create(); // use EF Core InMemory for testing
var handler = new GetAllProductsHandler(context);
// Act
var result = await handler.Handle(new GetAllProductsQuery(), default);
// Assert
Assert.NotNull(result);
Assert.NotEmpty(result);
}
No web server or integration test needed.
When Not to Use MediatR?
While MediatR is fantastic for many scenarios, it’s not always the best choice:
- If your app is very small, it might add unnecessary complexity
- If handlers become overly fragmented, they can be harder to manage
- For synchronous command chains, consider using a service class instead
Use it where it adds clarity and structure, not by default.
Conclusion
The Mediator pattern — implemented with MediatR — is a great way to improve the structure, testability, and maintainability of your ASP.NET Core applications.
By centralising message handling and enabling cross-cutting behaviours through pipelines, MediatR helps you write cleaner, more robust code — especially in applications adopting Clean Architecture or CQRS.
Give it a try in your next feature. You might never go back.