Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
Apr 11, 2023

MediatR vs Services or why slices architecture better

Источник:
Просмотров:
2803

Nowadays, everybody so concerned about DDD and how to implement business logic properly that people just forget about existence of other layers. Bada-bing bada-boom, other layers do exist.

Shocked, don’t ya? Take your sit, you will be even more shocked when you realize this story dedicated to Application layer, also known as the least popular layer.

I’ve seen multiple projects: some have it, some just don't understand what should be there and mixed it with others layers, some skip this layer at all. Don't be like the last one 😞. Don't write your application logic inside the controller. Move it somewhere else, for God’s sake 😫

Eric Evans defines this layer as a one that only coordinates tasks and delegates work to domain objects. Uncle Bob describes it as the one where use cases live. However, they are not very descriptive on how it should be implemented.

Which is clearly unfair, since Application layer is usually a place which contains most of the mess. Just think about it. Domain layer has only pure business logic rules. Infrastructure just reuse existing API or ORM. Meanwhile, Application do all the heavy lifting. It should orchestrate domain with infrastructure, handle execution flow, multithreading with locks, telemetry and metrics and so on and so forth.

And after all of that, you are telling me this layer does not deserve a little of explanation? Even a little? 😒 Let’s fix it.

Application layer is directly responsible for each use case in your system. It goes right after UI. So if you have any logic in your controller, move it to this layer. Your controllers should be thin as possible and only contain routing/mapping and other ASP logic. This is how an ideal controller should look:

[ApiController]
[Route("orders")]
public class OrderController : ControllerBase
{
    private readonly OrderAppService _appService;

    public OrderController(OrderAppService appService)
    {
        _appService = appService;
    }

    [HttpGet]
    public IEnumerable<OrderListDto> GetList()
    {
        return _appService.GetList();
    }
}

When coming to application logic, there are two ways how it can be structured: with services and handlers.

A service (aka horizontal architecture) looks like a class with subset of related (usually for a single aggregate) logic:

class OrderService
{
    public void CreateOrder(CreateOrderViewModel viewModel)
    {
        . . .
    }
    
    public IReadOnlyCollection<OrderListViewModel> GetOrders()
    {
        . . .
    }
    
    public void DeleteOrder(int orderId)
    {
        . . .
    }
}

With handlers (aka vertical architecture), you have classes instead of methods (preferably in separate files):

class CreateOrderHandler
{
    public void Handle(CreateOrderViewModel request)
    {
        . . .
    }
}

class GetOrdersHandler
{
    public IReadOnlyCollection<OrderListViewModel> Handle(GetOrdersViewModel request)
    {
        . . .
    }
}

class DeleteOrderHandler
{
    public void Handle(DeleteOrderViewModel request)
    {
        . . .
    }
}

In your Visual Studio solution, Application layer will look like this:

Make sure, your application project does not have any ASP related NuGet packages. Ideally you want it to be UI-agnostic in the way it can be reused with ASP, WinForms and any other new framework that came out.

The approach, with handlers, may be familiar to you, especially if you have used MediatR before. Moreover, this library become so popular among developer that sliced architecture and MediatR can be used interchangeably.

Now, when we know, how they look, let’s compare those in different categories and see which one is better.

1. Aspect oriented programming and cross-cutting concerns

This one happened to be the first, not by accident. While other items in the list debatable, this one isn’t. Moreover, it also has the most impact on your architecture.

Implementing cross-cutting concerns is always a struggle. With service-oriented architecture, the only good way to go is by creating a decorator. Adding something simple like logging performance of the app will have next look:

class OrderServicePerformanceDecorator : IOrderService
{
    public void CreateOrder(CreateOrderViewModel viewModel)
    {
        _logger.LogInfo($"{nameof(CreateOrder)} called at {DateTime.Now}");
        _orderService.CreateOrder(viewModel);
        _logger.LogInfo($"{nameof(CreateOrder)} ended at {DateTime.Now}");
    }
    
    // same implementation for other methods
    public IReadOnlyCollection<OrderListViewModel> GetOrders() { . . . }
    
    public void DeleteOrder(int orderId) { . . . }
}

The drawback here that every time you change your original service (add a method, change method signature) it will affect all decorators. There are other approaches, but they heavily rely on reflection, which is not the safest or fastest thing in a word.

With handlers, since every handler implement the same interface, you only need to implement single aspect and do not worry about any changes in handlers.

class PerformanceAspect : IHandler<TRequest, TResponse>
{
    public TResponse Handle(TRequest request, HandlerDelegate next)
    {
        _logger.LogInfo($"{typeof(TRequest).Name} called at {DateTime.Now}");
        
        var result = next();
        
        _logger.LogInfo($"typeof(TRequest).Name} ended at {DateTime.Now}");
        
        return result;
    }
}

2. Navigation

Services allow navigation by method, which will lead you to the one you need. Even when you have multiple implementation, most IDE are smart enough to show them all and let you chose.

Unfortunately, it is not so simple with handlers 😟. Slices architecture heavily rely on Mediator pattern. It makes low coupling between request and its handler. When navigation by request, you would get to a request and not to a handler.

There is a cheat code to make navigation work. You can put your request in the same file with handler:

class CreateOrderHandler
{
    class CreateOrderViewModel { . . . }
    
    public void Handle(CreateOrderViewModel viewModel)
    {
        . . .
    }
}

Although, I like it, but some developers don’t. Moreover, in some cases it is a matter of architecture and not personal preferences. If you want to have requests in a separate library, this trick just would not work.

So that you don’t think I’m biased, let’s credit the victory to the services.

3. One use case = one handler

I'm not going to mention Single Responsibility Principle (just slightly 😁), but with services, code is more fragile.

Just by looking to a service, you already can tell, how hard it is to find related pieces of code among other public, private and protected methods. You can decide to check an interface to see what use cases are supported, but even it trends to be overwhelmed.

On the other hand, one handler = one use case. Just by looking at handler’s name, you already know what it does. Everything in one place. If there is a private method, it is used only by this and no other handlers. All shared logic already moved across different services, helpers, domain models etc.

4. Size does matter

Sorry for telling you guys, it's true 😞

Services have a tendency to grow quickly and become a huge piece of 💩. It is common for service to be 5 thousands lines long, while the biggest handler I’ve seen only had 5 hundred lines. And believe me on that, nobody like to work with tons of codes.

5. Only dependencies you need

With handlers, you inject only dependencies you need. Rarely would be more than 5. You can not say the same about services. Usually there are 10 or more dependencies. Just compare for yourself:

6. Easier to write unit tests 🧪

This one partly depends on the previous.

Firstly, since there are fewer dependencies, there are less stuff to Mock 😀

Secondly, a single service is used in multiple test cases, and injecting a new dependency mean a need to update all the tests. While changes to handler will cause only changes in handler’s tests.

7. Violation of Liskov Substitution Principle

I usually see such base classes:

abstract class CrudServiceBase
{
    public virtual ViewModel Get(int id) { . . . }
    
    public virtual IReadOnlyCollection<ListViewModel> GetList() { . . . }

    public virtual void Create(CreateViewModel viewModel) { . . . }
    
    public virtual void Update(UpdateViewModel viewModel) { . . . }
    
    public virtual void Delete(int id) { . . . }
}

Seems like we just came up with a solution to simplify our CRUD 😀

Now imagine you inherited your service from it. Everything goes great until you realize some operation make no sense for your entity. For example, Delete() is not supported. So what you gonna do? You probably will throw NotSupportedException. Say hello to Liskov Substitution Principle 👋.

With handlers, you still can define abstract classes for each CRUD operations. And do you know what to do when you don't need to implement some operation, like Delete()? You just don't create such handler. Such simple it is.

8. Less generic parameters

Another issue with base class that you would likely use generic arguments to make it work. So we need to have generic for each method. Here how it could look:

abstract class CrudServiceBase<TCreateTEdit, TCreate, TDelete, TUpdate, TGet, TList>
{
      . . .
}

With handlers, you define only those generic arguments you need.

9. Reuse existing use cases

For use cases, it is common to include other use cases.

So the operation to DeleteAllOrders() could reuse some logic from DeleteOrder():

public void DeleteAllOrders()
{
    using (var dbContextTransaction = context.Database.BeginTransaction())
    {
        var orderIds = GetOrderIds();
        
        foreach (var orderId in orderIds) DeleteOrder(orderId);
        
        dbContextTransaction.Commit();
    }
}

public void DeleteOrder(int id) { . . . }

Even though, it looks nicely in our example, it is likely that your service is not that simple. Probably it contains multiple use cases with tons of helper methods. Now it becomes an issue to distinguish helper method from use cases, and you will likely get lost.

I may exaggerate here, but you still would spend time to investigate your code, just to tell which GetOrderIds() or DeleteOrder(orderId) is a use case and which is a private method.

Now, compare to handler:

class DeleteAllOrdersHandler
{
        . . .
    
    public void Handle(DeleteAllOrdersRequest request)
    {
        using (var dbContextTransaction = context.Database.BeginTransaction())
        {
            var orderIds = GetOrderIds();

            foreach (var orderId in orderIds) _dispatcher.Dispatch(new DeleteOrderRequest(orderId));

            dbContextTransaction.Commit();
        }
    }

        . . .
}

The use case called explicitly. It is clear that DeleteOrderRequiest is a use case, while GetOrderIds() just a helper.

10. More classes

Developers are usually scared when they're facing a lot of code. Even I myself mentioned earlier that size does matter.

While services are likely to grow in size, handlers will need more code to support it.

You have one service, but multiple handlers with request/response dtos:

Even though, it is infrastructure code, like dtos, which is not so vulnerable to bugs, it is still a code that takes time to write.

Is having one class with multiple methods better than multiple classes? 🤔 Debatable. Developers prefer classes to methods. They enable usage of OOP and patterns. Being said that, we all are lazy and would rather go with methods 😁

So technically, services win this round.

11. Complexity 🧩

Services are much simpler and easier to explain and work with. Let be honest, at this point, everybody knows what service is 😒.

On the other hand, there is a rumor that handlers are complicated. Personally, I haven't seen anybody struggle with them. Even junior developers get use to them quickly.

But let it be, this let’s credit it to services as well.

12. Code reusability

How do you reuse code with services? You probably create another service 😃

How do you reuse code with handlers? You probably create a service 😆

With services, it is not clear which one implement use cases and which contains shared logic.

On the other hand, handlers allow having additional level of abstraction in the form of services.

I am not saying that you can not use handlers as additional level of abstractions, any combination is allowed, any architecture is allowed until it works. However, that is the most common way I’ve seen.

So, in this case, I would prefer handlers.

Conclusion

After 12 round of action here, with having a lead in 9 categories, absolute winner is 🥁 🥁 🥁 Verticaaaaal architecture.

What conclusion can we make after everything being said? Should we always go with slices? I would say yes, we should.

The only valid point for services would be CRUD application. But let be fair, we are not writing CRUD application anymore. Even if you do, it’s likely to involve into something bigger. Sooner or later you will end up with migrating to slices, so why not starting with them right away? 😁

Похожее
Apr 29
Author: Salman Karim
Background tasks are crucial in many software applications, and scheduling them to run periodically or at specific times is a common requirement. In the .NET ecosystem, Quartz.NET provides a robust and flexible framework for scheduling such tasks. Let’s delve into...
Aug 22, 2021
The following are a set of best practices for using the HttpClient object in .NET Core when communicating with web APIs. Note that probably not all practices would be recommended in all situations. There can always be good reasons to...
Apr 4
Author: João Simões
Performance comparison between ToList and ToArray Ever since Microsoft introduced Language Integrated Query to the .NET framework (also known as LINQ) developers have been using it extensively to work with collections. From a simple filter, to an aggregation, to a...
Apr 7, 2023
Author: Matt Eland
Exploring nullability improvements in C# and the !, ?, ??, and ??= operators. Null is famously quoted as being the "billion-dollar mistake" due to the quantity of NullReferenceExceptions that we see in code (particularly when just starting out). The prevalence...
Написать сообщение
Тип
Почта
Имя
*Сообщение