Strona główna / Artykuły

SOLID jako pytanie o zmianę: refaktoryzacja usługi Spring Boot

Naucz się stosować każdą z zasad SOLID w backendzie Spring Boot, zadając sobie pytanie, co może ulec zmianie, oraz jak uniknąć nadmiernego projektowania, do którego często prowadzą te zasady.

Wydawca: REACTAPP.TOP12 min czytania2660 słów

Źródła: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me

Większość programistów potrafi wymienić, co oznaczają pięć liter SOLID, ale niewielu potrafi wyjaśnić, w jaki sposób te zasady powinny kształtować rzeczywisty serwis Spring Boot z repozytoriami, dostawcami płatności i kanałami powiadamiania. Same definicje nie są pomocne, gdy menedżer produktu prosi o powiadomienia przez WhatsApp w piątkowe popołudnie. Ten przewodnik omawia każdą z zasad przy użyciu typowego kodu backendowego, pokazuje refaktoryzację, którą każda z nich sugeruje, a co równie ważne, wskazuje, w jakich przypadkach mechaniczne stosowanie zasady pogarsza strukturę kodu.

Wspólnym mianownikiem wszystkich pięciu zasad nie jest „więcej interfejsów” ani „małe klasy”. Chodzi o to, by kod był łatwy do modyfikacji, tak aby zmiana jednego zachowania nie wpływała na wszystko wokół niego. Poniższe przykłady dotyczą Javy, ale teza ta odnosi się bezpośrednio do usług typu TypeScript w Node.js; jeśli wolisz tę środowisko, istnieje dodatkowy artykuł na SOLID poprzez praktyczne przykłady w JavaScript.

Problem, którego próbuje zapobiec SOLID

Zacznijmy od usługi obsługującej zamówienia, która realizuje wszystko, co potrzebne przy procesie płatności, w jednej metodzie: waliduje dane, przechowuje je, wysyła e-maile, generuje faktury, powiadamia użytkowników i rejestruje dane analityczne.

public class OrderService {
public void createOrder(Order order) {
        validateOrder(order);
        saveOrder(order);
        sendEmail(order);
        generateInvoice(order);
        sendNotification(order);
        updateAnalytics(order);
    }
}

Nic tutaj nie jest zepsute. Logika jest czytelna od góry do dołu, przeszłaby szybko audyt kodu i mogłaby działać w produkcji przez miesiące. Problemy pojawiają się wraz z kolejnymi wymaganiami. Jeden ze stakeholderów chce, aby powiadomienia były wysyłane przez WhatsApp zamiast e-mailem. Inny chce, aby generowanie faktur odbywało się w tle. Zespół ds. danych ogłasza migrację bazy danych. Następnie dane analityczne muszą być publikowane w Kafka.

Każda z tych prośb trafia do tej samej klasy. Kod nadal działa; problem polega na tym, że każda mała zmiana zmusza do otwierania, edytowania i ponownego testowania modułu powiązanego z pięcioma innymi kwestiami. To powiązanie, przy którym jedna zmiana pociąga za sobą wiele innych, jest dokładnie tym, co zasady SOLID mają na celu kontrolowanie.

Jedna odpowiedzialność: licz powody do zmiany

Zasada pojedynczej odpowiedzialności mówi, że klasa powinna mieć jedną odpowiedzialność, co w praktyce oznacza jeden powód do zmiany. Często jest błędnie interpretowana jako „jeden metod na klasę”. Taka interpretacja jest błędna: klasa z dwudziestoma metodami może nadal mieć jedną odpowiedzialność, o ile wszystkie one służą temu samemu celowi.

Cennym pytaniem nie jest to, jak duża jest klasa, ale co mogłoby zmusić cię do jej edycji. Jeśli możesz wymienić kilka niespowiązanych odpowiedzi, klasa prawdopodobnie pełni kilka funkcji.

Rozpoznawanie mieszanych odpowiedzialności

Oto usługa rejestracji użytkowników w stylu, który spotykamy w wielu bazach kodu.

public class UserService {
public void registerUser(User user) {
        validate(user);
        saveToDatabase(user);
        sendEmail(user);
        generateReport(user);
    }
}

Wykonywa on cztery odrębne zadania: weryfikację użytkownika, przechowywanie jego danych, wysyłanie e-maili oraz generowanie raportów. To daje cztery niezależne powody do zmian. Nowy dostawca usług e-mail oznacza konieczność edytowania UserService. Przejście na inny bazę danych również wymaga edytowania UserService. Zmiana formatu raportów ponownie skutkuje koniecznością edytowania UserService. Gdy niepowiązane zespoły ciągle modyfikują ten sam plik, mamy do czynienia z problemem w projekcie.

Delегowanie zadań współpracownikom

Refaktoryzacja polega na zachowaniu logiki koordynacji w UserService, a przeniesieniu zadań specjalistycznych do dedykowanych współpracowników, którzy są injectowani za pomocą konstruktora.

public class UserService {
private final UserRepository userRepository;
    private final EmailService emailService;
    public void registerUser(User user) {
        validate(user);
        userRepository.save(user);
        emailService.sendWelcomeEmail(user);
    }
}

Struktura zależności, która powstaje, jest łatwa do wyobrażenia:

UserService
     |
     +---- UserRepository
     |
     +---- EmailService

UserService decyduje teraz o tym, co dzieje się podczas rejestracji, podczas gdy UserRepository odpowiada za przechowywanie danych, a EmailService za ich dostawę. Walidacja pozostaje w usłudze, ponieważ jest częścią samej zasady rejestracji, natomiast raportowanie całkowicie opuszcza proces rejestracji.

Zwyczajem, który należy kultywować, jest przestanie liczenia metod i zamiast tego zastanowienie się, ile różnych czynników może skłonić nas do modyfikacji klasy. To pytanie wskazuje na rzeczywiste powiązania między komponentami, a nie tylko na pozorną wielkość kodu.

Otwarty/Zamknięty: dodawanie nowych funkcjonalności bez ponownego otwierania stabilnego kodu

Oficjalne sformułowanie, że entity programistyczne powinny być otwarte na rozbudowę, ale zamknięte na modyfikację, brzmi abstrakcyjnie. Tłumacząc to językiem potocznym: powinniśmy móc wprowadzać nowe funkcjonalności bez ciągłego edytowania kodu, który już działa i przeszedł testy.

Rozwijające się warunki

Klasycznym przykładem jest obsługa płatności. Pierwsza wersja często polega na przekazywaniu ciągu znaków.

public class PaymentService {
public void pay(String type) {
        if (type.equals("CARD")) {
            // card payment
        }
        if (type.equals("UPI")) {
            // UPI payment
        }
        if (type.equals("NET_BANKING")) {
            // net banking
        }
    }
}

To jest w porządku dla trzech metod. Następnie biznes prosi o obsługę portfeli, PayPal, Apple Pay oraz przelewów bankowych. Każde dodanie oznacza ponowne otwarcie klasy PaymentService, rozszerzenie warunków i ponowne testowanie wszystkich już istniejących ścieżek. Klasa stopniowo przekształca się w długą łańcuch instrukcji if/else.

Zastępowanie ścieżek polimorfizmem

Podejście Open/Closed wprowadza abstrakcję opisującą, co każda metoda płatności musi robić.

public interface PaymentProcessor {
void process(Payment payment);
}

Każda metoda płatności staje się wtedy odrębną implementacją. Procesor kart:

public class CardPaymentProcessor
        implements PaymentProcessor {
@Override
    public void process(Payment payment) {
        // card payment
    }
}

A procesor UPI:

public class UpiPaymentProcessor
        implements PaymentProcessor {
@Override
    public void process(Payment payment) {
        // UPI payment
    }
}

Dodawanie nowego dostawcy oznacza obecnie konieczność napisania nowej klasy zamiast edytowania istniejących. Procesory, które już są w użyciu, pozostają nietknięte, co zmniejsza zasięg wpływu każdej zmiany. W aplikacji Spring zazwyczaj rejestruje się każdy procesor jako bean i wybiera odpowiedni poprzez mapę opartą na typie płatności, dzięki czemu nawet logika wyboru nie musi wymagać tworzenia nowej gałęzi.

Kiedy interfejs nic ci nie daje

Nic z tego nie oznacza, że każda klasa musi mieć interfejs. Bardzo powszechnym wzorcem w projektach Spring jest jedno-do-jednego dopasowanie interfejsu do jego jedynej implementacji:

public interface UserService {
    void createUser();
}

z jedną klasą za nim:

public class UserServiceImpl implements UserService {
}

Jeśli nie ma realnych szans na drugą implementację, a zastąpienie jej niczym nie poprawi sytuacji, to ta interfejs jest po prostu dodatkowym plikiem, który trzeba przeglądać. Współczesne biblioteki do tworzenia mocków mogą symulować konkretne klasy, więc sama testowalność rzadko stanowi mocny argument. Zasady SOLID dotyczą projektowania z myślą o zmianach, których się spodziewamy, a nie o automatycznym otaczaniu każdej klasy abstrakcją.

Zasada podstawienia Liskova: szanuj umowę, a nie tylko sygnaturę

Zasada podstawienia Liskova stanowi, że powinno być możliwe używanie dowolnego podtypu tam, gdzie oczekiwany jest jego typ nadrzędny, bez naruszania poprawności programu. Spośród pięciu zasad ta wydaje się najbardziej akademicka, więc pomocne jest ponowne użycie przykładu z płatnościami.

Abstrakcja jest taka sama jak wcześniej:

public interface PaymentProcessor {
void process(Payment payment);
}

Implementacja karty spełnia to, co obiecuje interfejs:

public class CardPaymentProcessor
        implements PaymentProcessor {
public void process(Payment payment) {
        // process payment
    }
}

Na razie wszystko w porządku. Potem ktoś dodaje procesor do płatności gotówkowych, który w rzeczywistości nie może przetwarzać żadnych transakcji online:

public class CashPaymentProcessor
        implements PaymentProcessor {
public void process(Payment payment) {
        throw new UnsupportedOperationException();
    }
}

Kompilator jest zadowolony, ponieważ sygnatura metody się zgadza. Jednak zachowanie narusza dane obietnice. Każdy użytkownik posiadający obiekt typu PaymentProcessor słusznie zakłada, że wywołanie metody process doprowadzi do przetworzenia płatności. W tej implementacji pętla przechodząca przez wszystkie procesory może nagle wywołać błąd w czasie wykonywania, więc użytkownicy zaczynają dodawać sprawdzenia typu instanceof lub bloki try/catch, aby się chronić – co jest wyraźnym znakiem wycieku informacji z abstrakcji.

Kwestią, którą należy postawić podczas pisania implementacji, jest to, czy rzeczywiście może ona zachowywać się w sposób obiecywany przez abstrakcję. Jeśli uczciwa odpowiedź brzmi „nie”, rozwiązaniem jest zazwyczaj zmiana abstrakcji – na przykład poprzez oddzielne modelowanie płatności offline lub podział tej funkcjonalności na węższy interfejs. Zasada Liskova dotyczy poprawności zachowania, a nie tego, czy słowo kluczowe implements kompiluje się.

Segregacja interfejsów: uniemożliwienie klientom polegania na elementach, których ignorują

Zasada segregacji interfejsów mówi, że żaden klient nie powinien być zmuszany do polegania na metodach, których nie używa. Wielkie, ogólne interfejsy to zazwyczaj miejsce, gdzie ta zasada się objawia.

Interfejs służący wszystkim

Rozważmy interfejs, który obejmuje zarządzanie pracownikami, wypłaty, zatwierdzanie urlopów oraz generowanie raportów:

public interface EmployeeOperations {
void createEmployee();
    void updateEmployee();
    void deleteEmployee();
    void generateSalary();
    void approveLeave();
    void generateReport();
}

Komponent raportowania wymaga od niego tylko jednej operacji:

generateReport();

Jednak dzięki zależności od EmployeeOperations jest powiązany z logiką tworzenia, usuwania, wypłaty pensji i urlopów, których nigdy nie zostanie użyta.

Dzielenie według potrzeb użytkowników

Lepsze rozwiązanie polega na stworzeniu oddzielnych, sprecyzowanych interfejsów. Jeden służy do zarządzania rekordami pracowników:

public interface EmployeeManagement {
void createEmployee();
    void updateEmployee();
    void deleteEmployee();
}

A drugi do generowania raportów:

public interface EmployeeReporting {
void generateReport();
}

Każdy użytkownik zależy teraz tylko od funkcji, której potrzebuje. Jedna klasa może nadal implementować obie; kluczowe jest to, co widzą klienci.

Dlaczego to ma znaczenie wraz z rozwojem systemu

W małej aplikacji zbyt rozbudowana interfejs stanowi niewielki problem. W dużej aplikacji przekształca się ona w sieć przypadkowych powiązań, w której zmiana wprowadzona dla jednego użytkownika wpływa na wiele niepowiązanych z nim. Mniejsze interfejsy sprawiają, że granice zależności są wyraźne i łatwiejsze do zrozumienia.

Ostrzeżenie jest takie samo jak w przypadku zasady Otwarte/Zamknięte. Nie dziel interfejsów na fragmenty tylko po to, by spełnić wymóg. Dziel je wtedy, gdy różni klienci rzeczywiście potrzebują różnych podzestawów zachowań.

Inwersja zależności: niech logika biznesowa polega na abstrakcjach

Zasada inwersji zależności zakłada, że zarówno kod na poziomie biznesowym, jak i kod infrastrukturalny powinny opierać się na wspólnych abstrakcjach, zamiast aby warstwa polityk odwoływała się do konkretnych klas niskiego poziomu. W aplikacjach Spring Boot ta zasada jest prawdopodobnie najważniejsza spośród pięciu.

Infrastruktura ściśle powiązana z kodem

Tutaj usługa zamówień tworzy własny repositoryj:

public class OrderService {
private final MySqlOrderRepository repository;
    public OrderService() {
        this.repository = new MySqlOrderRepository();
    }
}

Logika biznesowa teraz dokładnie wie, która technologia bazy danych przechowuje jej dane. Zastąpienie MySQL lub użycie fałszywego repositoryju w teście jednostkowym wymaga edycji samego OrderService. Polityka na wyższym poziomie jest powiązana z detalami na niższym poziomie.

Inwersja zależności

Najpierw określ, czego faktycznie potrzebuje logika biznesowa, niezależnie od technologii przechowywania:

public interface OrderRepository {
void save(Order order);
}

Następnie spraw, aby klasa specyficzna dla MySQL była jedną z implementacji tego kontraktu:

@Repository
public class MySqlOrderRepository
        implements OrderRepository {
    @Override
    public void save(Order order) {
        // save order
    }
}

Na końcu niech usługa otrzymuje abstrakcję poprzez swój konstruktor:

@Service
public class OrderService {
private final OrderRepository repository;
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

OrderService już nie przejmuje się tym, gdzie trafiają zamówienia. Implementacją może być MySQL, PostgreSQL, MongoDB, wywołanie innego usług lub mock w pamięci używany podczas testów. Kierunek przepływu wiedzy się odwrócił: infrastruktura dostosowuje się do interfejsu należącego do warstwy biznesowej, zamiast warstwa biznesowa dostosowywać się do infrastruktury.

Jak iniekcja zależności w Spring wspiera te zasady

Spring sprawia, że zasada inwersji zależności wydaje się naturalna, ponieważ to jego kontener zajmuje się łączeniem komponentów. Gdy usługa deklaruje swoje współpracowniki w konstruktorze, jak poniżej, Spring znajduje bean pasujący do typu OrderRepository i przekazuje go dalej.

@Service
public class OrderService {
private final OrderRepository repository;
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

Klasa nigdy nie tworzy swoich zależności. Porównaj to z bezpośrednią instancjacją w kodzie biznesowym:

OrderRepository repository =
        new MySqlOrderRepository();

Ta jedna linia łączy wywołującego z konkretną klasą i uniemożliwia jej zastąpienie bez edycji. Iniekcja konstruktora utrzymuje powiązania na poziomie abstrakcji i sprawia, że zależności są widoczne w sygnaturze klasy, co jest również powodem, dla którego zazwyczaj preferowana jest ona nad iniekcją pól. Pamiętaj, że iniekcja zależności to mechanizm, a odwrócenie zależności to zasada projektowa: kontener pomaga w łączeniu abstrakcji, ale nadal musisz sam wybrać, jakie abstrakcje zdefiniować.

SOLID nie oznacza większej liczby klas

Zespoły, które dopiero odkryły zasady SOLID, czasami przesadzają. Jedna funkcja użytkownika zamienia się w tuzin różnych typów:

UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor

Gdy zastosujesz to w całym kodzie, otrzymasz czterdzieści klas, podczas gdy osiem z nich wystarczyłoby do jaśniejszego przedstawienia tego samego projektu. To nadmierna inżynieria, a nie dobra architektura. Zasady te istnieją po to, by zmniejszyć złożoność; jeśli ich stosowanie utrudnia zrozumienie kodu, prawdopodobnie stosujesz je mechanicznie, a nie w odpowiedzi na rzeczywisty powód zmian.

Zacznij od tego, co najprawdopodobniej się zmieni

Zamiast zastanawiać się, gdzie można zastosować zasady SOLID, doświadczeni inżynierowie backendu zwykle zaczynają od innego pytania: co w tym komponencie najprawdopodobniej się zmieni? Odpowiedź bezpośrednio odnosi się do tych zasad:

  • Baza danych może ulec zmianie: wprowadź abstrakcję repositoria.
  • Dostawcy płatności mogą się zmienić: umieść dostawców za abstrakcją płatności.
  • Kanały powiadamień mogą ulec zmianie: oddziel zachowanie powiadamień od przepływu biznesowego.
  • Zasady biznesowe stale się rozrastają: oddziel odpowiedzialności, zanim jedna klasa pochłonie je wszystkie.
  • Różni klienci wymagają różnych operacji: podziel interfejsy, od których zależą.
  • Implementacje nie zachowują się dokładnie tak samo: przeanalizuj ponownie abstrakcje zamiast siłą je pod nie wpisywać.
  • Takie ujęcie sprawia, że zasady pozostają praktyczne. Dodaj abstrakcje tam, gdzie występuje rzeczywista zmienność, a stabilny kod pozostaw prosty.

    Zastosowanie tego samego podejścia do granic usług

    To rozumowanie ma zastosowanie nie tylko do pojedynczych klas. Wyobraź sobie jedną usługę nadającą się do wdrożenia, odpowiedzialną za wszystkie te obszary:

    User
    Payment
    Notification
    Reporting
    Inventory
    Shipping
    

    Może działać doskonale, ale klienci, płatności, powiadomienia, raportowanie, zapasy i dostawa zmieniają się z różnych powodów i często w różnym czasie, który jest kontrolowany przez różne zespoły. Logika zasady pojedynczej odpowiedzialności może pomóc w określeniu, gdzie leżą naturalne granice.

    To nie oznacza, że każda koncepcja domeny zasługuje na własny mikrosłużbę. SOLID nie jest wymogiem tworzenia większej liczby usług; to przewodnik pomagający prawidłowo rozdzielać odpowiedzialności i zależności, niezależnie od tego, czy te granice będą stanowić moduły, pakiety czy usługi.

    Lista kontrolna dla komponentów backendowych

    Zanim uznasz, że ważny komponent backendowy jest ukończony, przeanalizuj jedno pytanie związane z każdą zasadą:

    • Pojedyncza odpowiedzialność: czy ta klasa ma kilka niespowiązanych powodów do zmiany?
  • Otwarte/Zamknięte: czy można dodać nową wariację bez edytowania stabilnej, istniejącej logiki?
  • Zastąpienie Liskova: czy każda implementacja rzeczywiście przestrzega umowy swojej abstrakcji?
  • Segregacja interfejsów: czy istnieją klienci, którzy polegają na metodach, których nigdy nie wywołują?
  • Inwersja zależności: czy logika biznesowa jest powiązana z konkretną implementacją, o której nie powinna wiedzieć?
  • Szczerze udzielone odpowiedzi na te pytania są cenniejsze niż umiejętność recytowania definicji.

    Główne wnioski

    • Cel wszystkich pięciu zasad jest taki sam: sprawić, by kolejne zmiany były tanie i ograniczone, tak aby zamiana bazy danych, dodanie dostawcy płatności lub zmiana kanału powiadamień wydawała się rutyną, a nie ryzykiem.
    • Czytaj każdą zasadę jako pytanie. Rozległa usługa zadaje pytanie „Co się tu zmienia?”, interfejs oparty na dziesięciu metodach pyta „Kto potrzebuje tego wszystkiego?”, a new SomeImplementation() w logice biznesowej pyta „Czy ta klasa musi o tym wiedzieć?”.
    • Wprowadzaj abstrakcje tam, gdzie zmiana jest prawdopodobna, a unikaj ich tam, gdzie nie jest to konieczne; interfejs z jedną stałą implementacją lub tuzin klas dla jednej funkcjonalności zwiększa koszty bez żadnej ochrony.
    • Naruszenia zasady Liskova zazwyczaj wskazują na błędną abstrakcję, więc należy poprawić model zamiast naprawiać kod używający go.
    • Pisz kod, który działa dziś, i strukturyzuj go tak, aby modyfikacje jutro nie były trudniejsze od tych dzisiaj.

    Literatura pokrewna