As ASP.NET applications grow, controllers often become crowded with business logic, validation, and orchestration code. The result is tight coupling, harder testing, and code that becomes difficult to maintain over time.
MediatR helps solve this by introducing the mediator pattern: instead of one class depending on many services, your endpoints send a request to a mediator, and a dedicated handler processes it.
In this article, you will learn how to use MediatR in ASP.NET Core to build cleaner, more modular C# applications.
What is MediatR?
MediatR is a lightweight library for in-process messaging in .NET. It allows you to send request objects (commands and queries) to handlers without your controllers knowing how the work is done.
At a high level:
- A controller sends a request through
IMediator. - MediatR finds the matching handler.
- The handler runs your business logic and returns a response.
This keeps controllers thin and moves behavior into focused, testable units.
Why use MediatR in ASP.NET?
MediatR is popular in ASP.NET projects because it encourages a clean separation of concerns. Controllers become simple entry points, while application behavior lives in handlers. This makes code easier to understand, test, and evolve.
Key benefits:
- Reduced controller and service constructor bloat.
- Better separation between API layer and business logic.
- Easy unit testing of handlers in isolation.
- Natural fit for CQRS-style command/query organization.
- Extensibility through pipeline behaviors for validation, logging, and performance tracking.
Setting up MediatR in ASP.NET Core
Start by adding the NuGet package:
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection Then register MediatR in Program.cs:
using MediatR;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
var app = builder.Build();
app.MapControllers();
app.Run(); This scans your assembly for handlers and registers them automatically.
Commands and Queries (CQRS Style)
A common approach is to model writes as commands and reads as queries.
Example Command: Create Product
Define a command request:
using MediatR;
public record CreateProductCommand(string Name, decimal Price) : IRequest<int>; Create the handler:
using MediatR;
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
private readonly IProductRepository _products;
public CreateProductCommandHandler(IProductRepository products)
{
_products = products;
}
public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Price = request.Price
};
await _products.AddAsync(product, cancellationToken);
return product.Id;
}
} Example Query: Get Product By Id
Define the query request:
using MediatR;
public record GetProductByIdQuery(int Id) : IRequest<ProductDto?>; Create the handler:
using MediatR;
public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto?>
{
private readonly IProductRepository _products;
public GetProductByIdQueryHandler(IProductRepository products)
{
_products = products;
}
public async Task<ProductDto?> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
{
var product = await _products.GetByIdAsync(request.Id, cancellationToken);
return product is null
? null
: new ProductDto(product.Id, product.Name, product.Price);
}
} Using MediatR in Controllers
Your controller depends only on IMediator, not a long list of domain services.
using MediatR;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> Create(CreateProductCommand command)
{
var id = await _mediator.Send(command);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _mediator.Send(new GetProductByIdQuery(id));
return product is null ? NotFound() : Ok(product);
}
} This keeps endpoint code very small and easy to read.
Notifications for Domain Events
MediatR also supports one-to-many publishing via notifications.
Define a notification:
using MediatR;
public record ProductCreatedNotification(int ProductId, string Name) : INotification; Create a notification handler:
using MediatR;
public class ProductCreatedEmailHandler : INotificationHandler<ProductCreatedNotification>
{
private readonly IEmailService _email;
public ProductCreatedEmailHandler(IEmailService email)
{
_email = email;
}
public async Task Handle(ProductCreatedNotification notification, CancellationToken cancellationToken)
{
await _email.SendAsync(
"admin@site.com",
"New product created",
$"Product {notification.Name} (ID: {notification.ProductId}) was created.",
cancellationToken);
}
} Publish it from your command handler:
await _mediator.Publish(new ProductCreatedNotification(product.Id, product.Name), cancellationToken); This allows side effects such as email, cache updates, or audit logging without bloating your command handler.
Pipeline Behaviors: Cross-Cutting Logic
Pipeline behaviors let you run reusable logic before and after each request handler. Common use cases include validation, logging, timing, and transaction boundaries.
Example: Logging Behavior
using MediatR;
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<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:
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); With one registration, every MediatR request passes through this behavior.
Validation Pattern with MediatR
A common pattern is combining FluentValidation with a pipeline behavior:
- Define validators per command/query.
- Execute validation in a behavior before the handler runs.
- Throw or return a structured validation result on failure.
This keeps validation logic centralized and consistent across endpoints.
Common Mistakes to Avoid
When adopting MediatR, watch out for these pitfalls:
- Putting heavy business logic back into controllers instead of handlers.
- Creating anemic handlers that simply call a large service method with no boundaries.
- Overusing MediatR for trivial internal calls where direct method calls are clearer.
- Ignoring pipeline behaviors, then repeating validation/logging in each handler.
- Returning EF entities directly instead of API-focused DTOs.
MediatR helps structure code, but architecture discipline still matters.
Conclusion
MediatR is a practical way to keep ASP.NET applications clean and maintainable. By moving behavior into focused handlers, using commands and queries for intent, and applying pipeline behaviors for cross-cutting concerns, you can reduce coupling and make your codebase easier to test and evolve.
If your controllers are getting crowded or your service layer feels tangled, MediatR is a strong next step that brings clarity without adding unnecessary complexity.