Jak grupować logikę biznesową w klasach

  • 1 Komentarz

Z reguły nasze aplikacje posiadają jakąś logikę biznesową (o ile nie piszemy prostego CRUD). Chciałbym jednak zaznaczyć, że nie mam tu na myśli logiki w rozumieniu walidacji czy reguł biznesowych, np. brak możliwości zmiany wartości jakiegoś pola, gdy pewne warunki nie zostaną spełnione. Chodzi mi bardziej o logikę procesu, np. gdy klient złoży zamówienie, musimy mu wysłać wiadomość e-mail. Ta logika nie dotyczy obiektu zamówienia, ale jest z nim związana.

Skoro wiemy, że będziemy musieli przechowywać gdzieś taką logikę biznesową, zastanówmy się, gdzie to może być. W tym poście pokaże 3 najprostsze podejścia, jak to można zrobić.

Serwis dla modelu

Najprostszym i chyba najpopularniejszym rozwiązaniem jest umieszczenie logiki biznesowej w serwisach. Niektórzy takie klasy nazywają również procesorami (processor) albo menadżerami (manager). Klasa serwisu jest tworzona dla każdej klasy modelu. W związku z tym mamy np. OrderService czy CustomerService.

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

public class ItemsService : IItemsService
{
    // ...

    public async Task<IEnumerable<Item>> RetrieveAllAsync()
    {
        // ...
    }

    public async Task<Item> RetrieveByIdAsync(Guid itemId)
    {
        // ...
    }

    public async Task<Guid> InsertAsync(Item item)
    {
        // ...
    }

    public async Task ChangeNameAsync(Guid itemId, string name)
    {
        // ...
    }
}

Zaletą takiego rozwiązania jest niewątpliwie to, że wszystkie operacje biznesowe dostępne dla danej klasy modelu są w jednym miejscu. Jeśli w jakiejś klasie (np. kontroler w WebAPI) potrzebujemy wykonać jakieś operacje biznesowe dla danego modelu, wystarczy że dodamy tylko jedną zależność i mamy dostęp do wszystkich operacji. Dodatkowo, jeśli chcemy się dowiedzieć jakie działania są dozwolone, to mamy je wszystkie w jednym miejscu.

Wadą natomiast jest to, że nie zawsze chcemy udostępniać wszystkie operacje. Oczywiście możemy stworzyć małe dedykowane interfejsy i zaimplementować je w jednej klasie. Takie rozwiązanie powoduje jednak, że znika nam zaleta dodania jednej zależność, aby mieć dostęp do wszystkich operacji. Ponadto, jeśli nasza logika zaczyna się rozrastać, np. mamy coraz więcej operacji albo zawierają one coraz więcej kroków, nasza klasa serwisu powoli przestaje być czytelna. Kolejną wadą takiego podejścia może być duże sprzężenie (coupling). Im więcej operacji w jednej klasie, tym prawdopodobnie będziemy musieli dodać więcej różnych zależności. Co za tym idzie może nam również maleć spójność (cohesion), ponieważ metody będą używać tylko niektórych zależności.

Serwis dla odczytu i serwis dla zapisu

Podobnym, jednak trochę innym podejściem jest stworzenie osobnych klas (serwisów) z operacjami do zapisu (lub modyfikacji) i z operacjami do odczytu (pobierania).

Takie podejście argumentujemy tym, że bardzo często potrzebujemy innych zależności w metodach, które dodają lub modyfikują obiekt, a innych w metodach, które obiekt pobierają. Dla przykładu w metodach zapisujących możemy potrzebować zależności do walidatora, aby sprawdzić, czy obiekt jest poprawny albo zależności do notyfikatora, aby wysłać jakieś powiadomienie. Metody pobierające obiekt takich zależności nie będą potrzebować. Dzięki takiemu rozdzieleniu rośnie nam spójność.

Jeśli chodzi o wady, to gdy jakaś klasa potrzebuje w swoich metodach pobrać i zmodyfikować obiekt danego modelu, musimy dodać do niej dwie zależności. Dodatkowo wciąż możemy mieć sytuację, że innych zależności będziemy używać, gdy modyfikujemy jedno pole obiektu a innych, gdy modyfikujemy drugie pole. Powoduje to, że spójność wciąż może być niska. Ponadto wciąż może nam się przytrafić, że dany serwis do zapisu lub odczytu będzie się rozrastał i przestanie być czytelny.

Takie serwisy mogą wyglądać następująco:

public class ItemsReadService : IItemsReadService
{
    // ...

    public async Task<IEnumerable<Item>> RetrieveAllAsync()
    {
        // ...
    }

    public async Task<Item> RetrieveByIdAsync(Guid itemId)
    {
        // ...
    }
}

public class ItemsWriteService : IItemsWriteService
{
    // ...

    public async Task<Guid> InsertAsync(Item item)
    {
        // ...
    }

    public async Task ChangeNameAsync(Guid itemId, string name)
    {
        // ...
    }
}

Klasa serwisu dla metody

Jeszcze większym rozdrobnieniem jest stworzenie osobnej klasy dla każdej z metod. Osiągniemy wtedy największą spójność, ponieważ każda z klas będzie miała tylko takie zależności, jakich potrzebuje dana metoda.

Niestety takie podejście również ma wady. Jeśli będziemy mieli klasę, która potrzebuje wywołać każdą z tych metod (np. kontroler w WebAPI), znacząco urośnie nam liczba zależności. Możemy temu zapobiec stosując bibliotekę typu MediatR, jednakże te zależności wciąż będą, tylko nie będzie ich widać w konstruktorze.

Kolejną wadą jest konieczność tworzenia bardzo dużej liczby klas. Bez odpowiedniego grupowania ich, może się zrobić bałagan. Z drugiej jednak strony posiadanie wielu małych klas spowoduje, że łatwiej będzie nam zidentyfikować oraz usunąć klasy (i metody), które nie są już potrzebne. A mniej kodu do utrzymania, to mniej problemów.

Przykład takich małych klas serwisów:

public class RetrieveAllItemsService : IRetrieveAllItemsService
{
    // ...

    public async Task<IEnumerable<Item>> RetrieveAllAsync()
    {
        // ...
    }
}

public class RetrieveItemByIdService : IRetrieveItemByIdService
{
    // ...

    public async Task<Item> RetrieveByIdAsync(Guid itemId)
    {
        // ...
    }
}

public class InsertItemService : IInsertItemService
{
    // ...

    public async Task<Guid> InsertAsync(Item item)
    {
        // ...
    }
}

public class ChangeItemNameService : IChangeItemNameService
{
    // ...

    public async Task ChangeNameAsync(Guid itemId, string name)
    {
        // ...
    }
}

Podsumowanie

Moim zdaniem każde z tych podejść jest i dobre, i złe. Wszystko zależy od tego, jak wyglądałyby nasze klasy i jak mogą się one rozwinąć. Jeśli każda z metod zajmuje niewiele kodu i wykorzystuje dokładnie te same zależności, to nie jest niczym złym umieszczenie nich wszystkich w jednej klasie. Mało tego, tworzenie osobnej klasy dla każdej z tych operacji wydaje się przerostem formy nad treścią. Jednakże jeśli z dużym prawdopodobieństwem te metody się mocno rozrosną i będą używać różnych zależności, to czasem warto zawczasu podzielić je na dwie klasy (jedną do odczytu i jedną do zapisu) albo nawet stworzyć osobną klasę dla każdej z tych metod. Należy jednak pamiętać, że nic nie stoi na przeszkodzie, aby tego podziału dokonać w przyszłości, gdy taka potrzeba nadejdzie.

1 myśl na “Jak grupować logikę biznesową w klasach”

  1. Pingback: dotnetomaniak.pl

Leave a Reply