Klasa per metoda z wykorzystaniem MediatR

Z reguły naszą logikę biznesową umieszczamy w klasach serwisowych. Czasem są one większe, a czasem mniejsze. Zastanówmy się, jak mógłby wyglądać nasz kod, gdybyśmy dla każdej metody z serwisu tworzyli osobną klasę. Wykorzystamy do tego bibliotekę MediatR.

Jeśli ktoś się zastanawia po co, odpowiedź jest prosta: aby zwiększyć spójność i zmniejszyć sprzężenie. Na razie nie będziemy się zagłębiać w takie pojęcia jak CQRS czy CQS. Naszym celem jest rozbicie klas na mniejsze i zmiana tego, jak je wołamy. Po tych zmianach nasze testy e2e nadal powinny działać dokładnie tak samo.

O różnych sposobach grupowania logiki w klasach pisałem tutaj a o tym, jak ważna jest duża spójność (cohesion) i małe sprzężenie (coupling) tutaj.

Kod bez użycia MediatR

Na początku załóżmy, że mamy dwa różne interfejsy, które są wykorzystywane przez nasz serwis. Dla uproszczenia przykładu nazwałem je IReadItemsRepository i IWriteItemsRepository.

public interface IReadItemsRepository
{
    Task<Item> GetByIdAsync(Guid itemId);
}

public interface IWriteItemsRepository
{
    Task<Guid> InsertAsync(Item item);
}

Nasz serwis może wyglądać następująco:

public interface IItemsService
{
    Task<Item> GetByIdAsync(Guid itemId);
    Task<Guid> InsertAsync(Item item);
}

public class ItemsService : IItemsService
{
    private readonly IReadItemsRepository _readItemsRepository;
    private readonly IWriteItemsRepository _writeItemsRepository;

    public ItemsService(
        IReadItemsRepository readItemsRepository,
        IWriteItemsRepository writeItemsRepository
    )
    {
        _readItemsRepository = readItemsRepository;
        _writeItemsRepository = writeItemsRepository;
    }

    public async Task<Item> GetByIdAsync(Guid itemId)
    {
        return await _readItemsRepository.GetByIdAsync(itemId);
    }

    public async Task<Guid> InsertAsync(Item item)
    {
        return await _writeItemsRepository.InsertAsync(item);
    }
}

Łatwo zauważyć, że na załączonym przykładzie nasz serwis ma 2 zależności, a każda z jego metod wykorzystuje tylko 1 z nich (czyli wykorzystuje 50% zależności).

Ważne jest jeszcze to, jak wołany jest nasz serwis:

public class ItemsController : ControllerBase
{
    private readonly IItemsService _itemsService;

    public ItemsController(IItemsService itemsService)
    {
        _itemsService = itemsService;
    }
    
    [HttpGet("{itemId}")]
    public async Task<IActionResult> GetById(Guid itemId)
    {
        return Ok(await _itemsService.GetByIdAsync(itemId));
    }

    [HttpPost]
    public async Task<IActionResult> Insert([FromBody]Item item)
    {
        return Ok(await _itemsService.InsertAsync(item));
    }
}

Kod z użyciem MediatR

Gdy zaczniemy używać bibliotekę MediatR, nasze nowe klasy będą wołane w następujący sposób:

public class ItemsController : ControllerBase
{
    private readonly IMediator _mediator;

    public ItemsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet("{itemId}")]
    public async Task<IActionResult> GetById(Guid itemId)
    {
        return Ok(await _mediator.Send(new GetItemById(itemId)));
    }

    [HttpPost]
    public async Task<IActionResult> Insert([FromBody]Item item)
    {
        return Ok(await _mediator.Send(new InsertItem(item)));
    }
}

To jest duża zmiana. Musimy pamiętać, że takie podejście ma jedną ogromną wadę: dodając zależność do IMediator w naszym kontrolerze, powodujemy, że może on wywołać dowolny handler z dowolną logiką, a nie tylko z logiką odpowiednią dla danego kontrolera (kontekstu). Dlatego podczas przeglądu kodu musimy być szczególnie uważni i monitorować, czy przypadkiem nie umieściliśmy wywołania danego handlera w nieodpowiednim kontrolerze. Niemniej jednak gdy już to opanujemy, łatwiej będzie nam potem zastosować wzorzec CQRS, jeśli oczywiście będziemy chcieli.

Handlery dla naszych metod mogą wyglądać tak:

public class GetItemById : IRequest<Item>
{
    public GetItemById(Guid itemId)
    {
        ItemId = itemId;
    }

    public Guid ItemId { get; }
}

public class GetItemByIdHandler : IRequestHandler<GetItemById, Item>
{
    private readonly IReadItemsRepository _readItemsRepository;

    public GetItemByIdHandler(IReadItemsRepository readItemsRepository)
    {
        _readItemsRepository = readItemsRepository;
    }

    public async Task<Item> Handle(GetItemById request, CancellationToken cancellationToken)
    {
        return await _readItemsRepository.GetByIdAsync(request.ItemId);
    }
}
public class InsertItem : IRequest<Guid>
{
    public InsertItem(Item item)
    {
        Item = item;
    }

    public Item Item { get; }
}

public class InsertItemHandler : IRequestHandler<InsertItem, Guid>
{
    private readonly IWriteItemsRepository _writeItemsRepository;

    public InsertItemHandler(IWriteItemsRepository writeItemsRepository)
    {
        _writeItemsRepository = writeItemsRepository;
    }

    public async Task<Guid> Handle(InsertItem request, CancellationToken cancellationToken)
    {
        return await _writeItemsRepository.InsertAsync(request.Item);
    }
}

Po naszych zmianach każda z nowych klas ma po 1 zależności, więc metoda w danej klasie wykorzystuje je wszystkie (100%). Dzięki takiemu zabiegowi nie powinniśmy już mieć ogromnych klas z dużą ilością metod i zależności, gdzie tylko niektóre zależności są potrzebne dla danej metody.

Na koniec pozostaje nam jeszcze rejestracja nowych klas w kontenerze wstrzykiwania zależności. Mamy do tego specjalną metodę, która wyszukuje i rejestruje wszystkie klasy implementujące interfejs IRequestHandler:

public void ConfigureServices(IServiceCollection services)
{
    // ...

    services.AddMediatR(typeof(GetItemByIdHandler));

    // ...
}

Metoda AddMediatR jako parametr przyjmuje kilka możliwości. Na nasze proste potrzeby najbardziej przydatnymi będą dwie: ta która akceptuje listę Assembly i ta która akceptuje listę Type (z których i tak jest potem tworzona lista Assembly). Dlatego bez problemu możemy nasze handlery mieć w wielu różnych projektach. Wystarczy wtedy do tej metody przekazać listę odpowiednich Assembly lub Type.

Podsumowanie

Po naszych zmianach kod powinien działać tak samo, ale ze względu na zwiększenie spójności i zmniejszenie sprężenia będzie łatwiejszy w utrzymaniu. Nasze klasy nie będą rozrastać się w nieskończoność, nie trzeba będzie ich dzielić na mniejsze, a dodatkowo znalezienie szukanej logiki też powinno być prostsze. Oczywiście prawdziwe przypadki są bardziej rozbudowane. Jednakże na tym prostym przykładzie zobaczyliśmy, jak w łatwy sposób możemy zwiększyć spójność i zmniejszyć sprzężenie.

1 myśl na “Klasa per metoda z wykorzystaniem MediatR”

  1. Pingback: dotnetomaniak.pl

Leave a Reply