Recently I came across using the MediatR package in our source code. This interested me. Why should I use MediatR? What advantages can it give me? Here we'll discuss these topics.
How to use MediatR
Basic usage of MediatR is very simple. First, you install the MediatR NuGet package. In your application you'll have different descriptions of work to be done (e.g. create a ToDo item, change user name, etc.). These descriptions are called requests in MediatR. They are simple classes implementing IRequest<T> interface. This is a marker interface without any members.
class CreateToDoItem : IRequest<int>
{
public string ToDoItemText { get; set; }
}
These classes may not contain any logic, they are just data containers for your operations.
But what is type parameter T in the IRequest<T> interface? You see, your operations may return some results. For example, if you are creating a new ToDo item, you may need to get the ID of that item. This is exactly what the T is for. In our case, we want to get integer ID of the new ToDo item.
Now we need some code that will perform this operation. MediatR calls this code request handler. Request handlers must implement IRequestHandler<TRequest, TResponse> interface, where TRequest must be IRequest<TResponse>:
class CreateToDoItemHandler : IRequestHandler<CreateToDoItem, int>
{
public Task<int> Handle(CreateToDoItem request, CancellationToken cancellationToken)
{
...
}
}
As you can see, this interface requires the implementation of a single Handle method that asynchronously performs the requested operation and returns the desired result.
The only thing left to do is to connect the request and the corresponding handler. MediatR does this using a dependency container. If you develop ASP.NET Core application then you can use MediatR's MediatR.Extensions.Microsoft.DependencyInjection package. But MediatR supports many different containers.
services.AddMediatR(typeof(Startup));
Here services is an instance of IServiceCollection interface which is usually accessible in the ConfigureServices method of the Startup class. This command will scan the assembly where the Startup class lives and find all request handlers.
Now you can execute your requests. You just need to get reference to IMediator instance. It is registered in your container using the same AddMediatR method.
var toDoItemId = await mediator.Send(createToDoItemRequest);
That's all. MediatR will find the appropriate request handler, execute it, and return the result to you.
And now we come to the main question.
Why do we need MediatR?
Let's say we have an ASP.NET Core controller which supports operations with ToDo items. We'll compare how we can implement ToDo item creation using MediatR and without it. Here is the code without MediatR:
[ApiController]
public class ToDoController : ControllerBase
{
private readonly IToDoService _service;
public ToDoController(IToDoService service)
{
_service = service;
}
[HttpPost]
public async Task<IActionResult> CreateToDoItem([FromBody] CreateToDoItem createToDoItemRequest)
{
var toDoItemId = await _service.CreateToDoItem(createToDoItemRequest);
return Ok(toDoItemId);
}
}
And now the same implementation with MediatR:
[ApiController]
public class ToDoController : ControllerBase
{
private readonly IMediator _mediator;
public ToDoController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> CreateToDoItem([FromBody] CreateToDoItem createToDoItemRequest)
{
var toDoItemId = await _mediator.Send(createToDoItemRequest);
return Ok(toDoItemId);
}
}
Do you see any serious advantages of MediatR here? I don't. In fact, I think the implementation with MediatR is a little less readable. It uses generic Send method instead of meaningful CreateToDoItem.
So why should I use MediatR?
References
First of all, MediatR separates request handlers from requests. In our controller code, we do not reference CreateToDoItemHandler class. It means that we can move this class to any place inside the same assembly and we'll not need to modify the code of our controller.
But personally, I don't see this as a big advantage. Yes, it will be easier for you to make some changes to your project. But at the same time, we will face some difficulties here. From the code of our controller, we don't actually see who is processing our request. To find a handler for an instance of CreateToDoItem, we need to know what MediatR is and how it works. There is nothing particularly complicated here. After all, IToDoService is also not a handler implementation, we will have to look for classes implementing this interface. But it will still take more time for new developers to figure out what's going on.
Single responsibility
The next difference is more important. You see, the request handler is a class. And this whole class is responsible for performing a single operation. In the case of a service (for example, IToDoService), one method is responsible for performing one operation. This means that the service can contain many different methods, possibly related to different operations. This makes it difficult to understand the service code. On the other hand, the entire request handler class is responsible for a single operation. This makes this class smaller and easier to understand.
It all looks nice, but the reality is slightly messier. Usually you have to support a lot of related operations (e.g. create ToDo item, update ToDo item, change status of ToDo item, ...) All these operations may require the same pieces of code. In case of service we can use private methods to do common job. But request handlers are separate classes. Of course, we can use inheritance and extract everything we need into the base class. But this brings us to the same situation, if not worse. In the case of the service, we had many methods in one class. Now we have many methods distributed across multiple classes. I'm not sure which is better.
In other words, if you want to shoot your leg, you still have plenty of options.
Decorators
But there is one more serious advantage of MediatR. You see, all your request handlers implement the same interface IRequestHandler. It means that you can write decorators applicable to all of them. In ASP.NET Core you can use Scrutor NuGet package for support of decorators. For example, you can write logging decorator:
class LoggingDecorator<TRequest, TResponse> : IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IRequestHandler<TRequest, TResponse> _handler;
private readonly Logger _logger;
public LoggingDecorator(IRequestHandler<TRequest, TResponse> handler,
Logger logger)
{
_handler = handler;
_logger = logger;
}
public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken)
{
_logger.Log("Log something here.");
return _handler.Handle(request, cancellationToken);
}
}
Now register it:
services.AddMediatR(typeof(Startup));
services.Decorate(typeof(IRequestHandler<,>), typeof(LoggingDecorator<,>));
And that's all. Now you applied logging to all your request handlers. You don't need to create a separate decorator for each of your services. All you need is to decorate a single interface.
But why bother with Scrutor? MediatR provides the same functionality with pipeline behaviors. Write a class implementing IPipelineBehavior:
class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly Logger _logger;
public LoggingBehavior(Logger logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
{
try
{
_logger.Log($"Before execution for {typeof(TRequest).Name}");
return await next();
}
finally
{
_logger.Log($"After execution for {typeof(TRequest).Name}");
}
}
}
Register it:
services.AddMediatR(typeof(Startup));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
And everything works the same way. You don't need decorators anymore. All registered pipeline behaviors will be executed with each request handler in the order they are registered.
The approach with behaviors is even better than that of decorators. Consider the following example. You may want to execute some requests inside a transaction. In order to mark such requests you use ITransactional marker interface:
interface ITransactional { }
class CreateToDoItem : IRequest<int>, ITransactional
...
How to apply your behavior only to requests marked with ITransactional interface? You may use generic class constraints:
class TransactionalBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : ITransactional
...
But you can't do the same with Scrutor decorators. If you implement decorator like this:
class TransactionalDecorator<TRequest, TResponse> : IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>, ITransactional
...
you will not be able to use it if you have any request that does not implement ITransactional.
When implementing pipeline behavior remember, that they executed on every call of Send method. It may be important if you are sending requests from inside of handlers:
class CommandHandler : IRequestHandler<Command, string>
{
private readonly IMediator _mediator;
public CommandHandler(IMediator mediator)
{
_mediator = mediator;
}
public async Task<string> Handle(Command request, CancellationToken cancellationToken)
{
...
var result = await _mediator.Send(new AnotherCommand(), cancellationToken);
...
}
}
If you marked both Command and AnotherCommand with ITransactional interface, corresponding TransactionalBehavior will be executed twice. So make sure that you don't create two separate transactions.
Other functionality
MediatR provides you with other functionality as well. It supports notifications mechanism. It may be very useful if you use domain events in your architecture. All classes of your events must implement INotification marker interface. And you can create any number of handlers for this event type with INotificationHandler interface. The difference between requests and notification is as follows. The request will be passed to only one single handler. A notification will be passes to all registered handlers for this type of notifications. Also, for a request, you can get the result of its processing. Notifications does not allow to get any results. Use Publish method to send notifications.
MediatR also providers an exception handling mechanism. It is rather sophisticated and you can read about it here.
Conclusion
In conclusion, I have to say that MediatR is an interesting NuGet package. The ability to express all operations using a single interface and behavior mechanism makes it attractive for use in my projects. I can't say it's a silver bullet, but it has certain advantages. Good luck using it.
No comments:
Post a Comment