In-depth Analysis of MediatR: Architectural Impact, Performance, and Practical Challenges
David Shergilashvili
???? Engineering Manager | ??? .NET Solution Architect | ?? Software Developer | ?? Herding Cats and Microservices
Introduction
MediatR is a widely used library in the .NET ecosystem that implements the mediator design pattern. This article delves deep into all aspects of using MediatR, examining its impact on system architecture, performance, and code maintainability. We'll also analyze its advantages and disadvantages in various scenarios.
MediatR Architecture and Core Components
MediatR's architecture is based on several key components:
Let's examine the detailed implementation and usage scenarios for each component:
IRequest and IRequestHandler
public class GetUserQuery : IRequest<User>
{
public int UserId { get; set; }
}
public class GetUserQueryHandler : IRequestHandler<GetUserQuery, User>
{
private readonly IUserRepository _userRepository;
public GetUserQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<User> Handle(GetUserQuery request, CancellationToken cancellationToken)
{
return await _userRepository.GetUserByIdAsync(request.UserId, cancellationToken);
}
}
In this example, GetUserQuery is a request, and GetUserQueryHandler is its corresponding handler. This structure ensures a clear separation between request definition and its processing.
INotification and INotificationHandler
public class UserCreatedNotification : INotification
{
public int UserId { get; set; }
public string Username { get; set; }
}
public class EmailNotificationHandler : INotificationHandler<UserCreatedNotification>
{
private readonly IEmailService _emailService;
public EmailNotificationHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task Handle(UserCreatedNotification notification, CancellationToken cancellationToken)
{
await _emailService.SendWelcomeEmailAsync(notification.UserId, notification.Username, cancellationToken);
}
}
Notifications allow us to send a single message to multiple handlers. This is useful for scenarios where a single event triggers multiple side effects.
Behaviors
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
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 {typeof(TRequest).Name}");
var response = await next();
_logger.LogInformation($"Handled {typeof(TRequest).Name}");
return response;
}
}
Behaviors allow us to add logic that runs before and after the handling of all (or certain types of) requests. This is ideal for cross-cutting concerns such as logging, validation, or transaction management.
MediatR's Impact on System Architecture
Using MediatR significantly impacts system architecture:
1. Decoupling
MediatR promotes code decoupling as the request initiator is not directly connected to its handler. This reduces direct dependencies between classes.
2. Vertical Slicing
MediatR naturally pushes developers towards vertical slicing, where each business operation is represented by a separate request and handler. This facilitates adding new functionality and modifying existing ones.
3. Single Responsibility Principle
Using MediatR promotes adherence to the Single Responsibility Principle, as each handler is responsible for executing only one specific operation.
4. Testability
MediatR's structure makes it easier to write unit tests, as each handler can be tested independently.
MediatR's Impact on Performance
Using MediatR has certain impacts on system performance:
1. Reflection
MediatR uses reflection to connect requests and handlers, which incurs a small performance cost. However, this cost is negligible in most cases.
2. Object Creation
A new object is created for each request, which increases memory usage and Garbage Collection load.
3. Pipeline Behaviors
Using Pipeline Behaviors results in additional method calls, which also affect performance.
Despite these factors, in most cases, these performance costs are insignificant and do not cause problems. However, for very high-performance systems, this may be a consideration.
MediatR Usage Scenarios and Practical Challenges
Scenario 1: Microservices
MediatR is particularly useful in a microservices architecture:
Advantages:
Challenges:
Scenario 2: Monolithic Applications
Using MediatR in monolithic applications can be beneficial but also create certain challenges:
Advantages:
Challenges:
Scenario 3: CQRS Implementation
MediatR is often used for implementing the CQRS (Command Query Responsibility Segregation) pattern:
Advantages:
Challenges:
MediatR Alternatives and Their Comparison
1. Regular Services and Dependency Injection
Advantages:
Disadvantages:
Example:
public interface IUserService
{
Task<User> GetUserAsync(int id);
Task<User> CreateUserAsync(CreateUserRequest request);
}
public class UserService : IUserService
{
private readonly IUserRepository _repository;
private readonly ILogger<UserService> _logger;
public UserService(IUserRepository repository, ILogger<UserService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<User> GetUserAsync(int id)
{
_logger.LogInformation($"Getting user with id {id}");
return await _repository.GetByIdAsync(id);
}
public async Task<User> CreateUserAsync(CreateUserRequest request)
{
_logger.LogInformation($"Creating new user");
var user = new User(request.Username, request.Email);
await _repository.AddAsync(user);
return user;
}
}
领英推荐
2. CQRS Pattern Without Mediator
Advantages:
Disadvantages:
Example:
public interface ICommandHandler<TCommand>
{
Task HandleAsync(TCommand command);
}
public interface IQueryHandler<TQuery, TResult>
{
Task<TResult> HandleAsync(TQuery query);
}
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
private readonly IUserRepository _repository;
public CreateUserCommandHandler(IUserRepository repository)
{
_repository = repository;
}
public async Task HandleAsync(CreateUserCommand command)
{
var user = new User(command.Username, command.Email);
await _repository.AddAsync(user);
}
}
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, User>
{
private readonly IUserRepository _repository;
public GetUserQueryHandler(IUserRepository repository)
{
_repository = repository;
}
public async Task<User> HandleAsync(GetUserQuery query)
{
return await _repository.GetByIdAsync(query.UserId);
}
}
3. Functional Approach
Advantages:
Disadvantages:
Example:
public static class UserOperations
{
public static async Task<User> CreateUser(CreateUserRequest request, IUserRepository repository)
{
var user = new User(request.Username, request.Email);
await repository.AddAsync(user);
return user;
}
public static async Task<User> GetUser(int id, IUserRepository repository)
{
return await repository.GetByIdAsync(id);
}
}
// Usage
public class UserController : ControllerBase
{
private readonly IUserRepository _repository;
public UserController(IUserRepository repository)
{
_repository = repository;
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest request)
{
var user = await UserOperations.CreateUser(request, _repository);
return Ok(user);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var user = await UserOperations.GetUser(id, _repository);
return Ok(user);
}
}
Best Practices for Using MediatR
Using MediatR in Various Scenarios
1. API Endpoints
MediatR can be used for implementing API endpoints:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IMediator _mediator;
public UsersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var query = new GetUserQuery { Id = id };
var user = await _mediator.Send(query);
return user != null ? Ok(user) : NotFound();
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserCommand command)
{
var user = await _mediator.Send(command);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
}
This approach ensures a clear separation between the controller and business logic.
2. Background Jobs
MediatR can be used to organize background jobs:
public class DataCleanupJob : BackgroundService
{
private readonly IMediator _mediator;
public DataCleanupJob(IMediator mediator)
{
_mediator = mediator;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _mediator.Send(new CleanupOldDataCommand(), stoppingToken);
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
}
}
}
This approach allows us to separate job logic into a separate handler, improving code organization and testability.
3. Event-Driven Architecture
MediatR's notifications can be used to implement event-driven architecture:
public class OrderCreatedNotification : INotification
{
public int OrderId { get; set; }
}
public class EmailNotificationHandler : INotificationHandler<OrderCreatedNotification>
{
private readonly IEmailService _emailService;
public EmailNotificationHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task Handle(OrderCreatedNotification notification, CancellationToken cancellationToken)
{
await _emailService.SendOrderConfirmationEmailAsync(notification.OrderId);
}
}
public class InventoryUpdateHandler : INotificationHandler<OrderCreatedNotification>
{
private readonly IInventoryService _inventoryService;
public InventoryUpdateHandler(IInventoryService inventoryService)
{
_inventoryService = inventoryService;
}
public async Task Handle(OrderCreatedNotification notification, CancellationToken cancellationToken)
{
await _inventoryService.UpdateInventoryForOrderAsync(notification.OrderId);
}
}
// Usage
public class OrderService
{
private readonly IMediator _mediator;
public OrderService(IMediator mediator)
{
_mediator = mediator;
}
public async Task CreateOrderAsync(CreateOrderCommand command)
{
// Order creation logic
var orderId = await CreateOrderInDatabase(command);
// Send notification
await _mediator.Publish(new OrderCreatedNotification { OrderId = orderId });
}
}
This approach allows us to easily add new handlers for the order creation event, increasing system flexibility.
Challenges Associated with Using MediatR and Their Solutions
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly IDbContext _dbContext;
public TransactionBehavior(IDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
using var transaction = await _dbContext.BeginTransactionAsync();
try
{
var response = await next();
await transaction.CommitAsync();
return response;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
5. Difficulty with Dependency Injection:
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Startup).Assembly));
services.Scan(scan => scan
.FromAssemblyOf<Startup>()
.AddClasses(classes => classes.AsImplementedInterfaces())
.WithTransientLifetime());
6. Testing Difficulties:
public class TestMediator : IMediator
{
private readonly Dictionary<Type, object> _handlers = new Dictionary<Type, object>();
public void RegisterHandler<TRequest, TResponse>(Func<TRequest, TResponse> handler)
{
_handlers[typeof(TRequest)] = handler;
}
public async Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
{
var requestType = request.GetType();
if (_handlers.TryGetValue(requestType, out var handler))
{
return await Task.FromResult(((Func<IRequest<TResponse>, TResponse>)handler)(request));
}
throw new NotImplementedException($"No handler registered for {requestType}");
}
// Implementation for other methods...
}
Optimizing MediatR Performance
Optimizing performance when using MediatR is crucial. Here are some tips:
public async Task Handle(MyRequest request, CancellationToken cancellationToken)
{
var task1 = _repository.GetDataAsync();
var task2 = _service.ProcessAsync();
await Task.WhenAll(task1, task2);
var result1 = await task1;
var result2 = await task2;
// Use result1 and result2
}
4. Use Value Tasks: For synchronous operations that sometimes work asynchronously, use ValueTask<T>:
public ValueTask<int> Handle(MyRequest request, CancellationToken cancellationToken)
{
if (_cache.TryGetValue(request.Id, out var cachedResult))
{
return new ValueTask<int>(cachedResult);
}
return new ValueTask<int>(GetFromDatabaseAsync(request.Id));
}
5. Use Structs for Small Requests: For small, frequently used requests, consider using structs instead of classes:
public struct GetUserByIdQuery : IRequest<UserDto>
{
public int UserId { get; }
public GetUserByIdQuery(int userId) => UserId = userId;
}
Using MediatR in a Domain-Driven Design (DDD) Context
MediatR can be effectively used with Domain-Driven Design principles:
public class CreateOrderCommand : IRequest<int>
{
public int CustomerId { get; set; }
public List<OrderItemDto> Items { get; set; }
}
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
private readonly IOrderRepository _orderRepository;
public CreateOrderCommandHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<int> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
var order = new Order(request.CustomerId);
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity);
}
await _orderRepository.AddAsync(order);
return order.Id;
}
}
2. Using Domain Events: Use MediatR notifications to implement Domain Events:
public class OrderCreatedEvent : INotification
{
public int OrderId { get; }
public OrderCreatedEvent(int orderId) => OrderId = orderId;
}
public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
{
private readonly IEmailService _emailService;
public OrderCreatedEventHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
{
await _emailService.SendOrderConfirmationEmailAsync(notification.OrderId);
}
}
3. Using the Specification Pattern: Use the Specification pattern with MediatR requests:
public class GetOrdersWithSpecificationQuery : IRequest<IEnumerable<Order>>
{
public ISpecification<Order> Specification { get; }
public GetOrdersWithSpecificationQuery(ISpecification<Order> specification)
{
Specification = specification;
}
}
public class GetOrdersWithSpecificationQueryHandler : IRequestHandler<GetOrdersWithSpecificationQuery, IEnumerable<Order>>
{
private readonly IOrderRepository _orderRepository;
public GetOrdersWithSpecificationQueryHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<IEnumerable<Order>> Handle(GetOrdersWithSpecificationQuery request, CancellationToken cancellationToken)
{
return await _orderRepository.ListAsync(request.Specification);
}
}
Using MediatR in Microservices Architecture
MediatR can be effectively used in a microservices architecture:
Example for API Gateway:
public class OrderApiGateway : ControllerBase
{
private readonly IMediator _mediator;
public OrderApiGateway(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto)
{
var command = new CreateOrderCommand { /* mapping from dto */ };
var orderId = await _mediator.Send(command);
return Ok(new { OrderId = orderId });
}
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var query = new GetOrderQuery { OrderId = id };
var order = await _mediator.Send(query);
return Ok(order);
}
}
Conclusion
MediatR is a powerful tool that offers many advantages, including code decoupling, vertical slicing, and easy implementation of cross-cutting concerns. However, its use requires a careful approach and good architectural decisions.
MediatR is particularly useful for complex, enterprise-level applications where code organization and flexibility are critically important. On the other hand, for simple CRUD applications or small projects, using MediatR might lead to unnecessary complexity.