SOLID w praktyce - jak tworzyć kod łatwiejszy w rozwoju

Młody programista z niebieskimi włosami żongluje symbolami kryptowalut, otoczony ekranami z kodem. Zasady SOLID są kluczem do jego sukcesu.

Napisano przez

Alex Jabłoński

Opublikowano

14 paź 2026

Spis treści

Gdy jedna klasa obsługuje zamówienie, wysyła e-maile, zapisuje dane i jeszcze zna szczegóły płatności, każda zmiana zaczyna budzić niepokój. Zasady SOLID pomagają uporządkować takie zależności, ograniczyć ryzyko regresji i budować kod, który łatwiej rozwijać oraz testować. Pokażę, jak rozumieć wszystkie pięć reguł na przykładach typowych dla aplikacji webowych, gdzie kończy się zdrowa architektura, a zaczyna przesadne komplikowanie projektu.

Pięć prostych reguł może znacząco zmienić jakość kodu

  • S odpowiada za jedną odpowiedzialność, a nie za jedną metodę w klasie.
  • O zachęca do rozszerzania funkcji bez ciągłego modyfikowania działającego kodu.
  • L pilnuje kontraktów, dzięki którym implementacje mogą być bezpiecznie zastępowane.
  • I ogranicza zbyt duże interfejsy, aby klasy nie zależały od niepotrzebnych metod.
  • D odwraca zależności od szczegółów technicznych na rzecz abstrakcji.

Po co w ogóle stosować SOLID

SOLID to akronim pięciu zasad projektowania obiektowego. Ich wspólnym celem jest zmniejszenie liczby miejsc, które trzeba zmieniać podczas rozwoju aplikacji. Nie chodzi jednak o to, by każdą klasę dzielić na najmniejsze możliwe fragmenty. Dobre podejście polega na szukaniu takich granic, przy których zmiana jednej funkcji nie pociąga za sobą lawiny zmian w innych modułach.

W aplikacji webowej problem zwykle zaczyna się niewinnie. Kontroler przyjmuje żądanie, serwis wykonuje logikę biznesową, a repozytorium zapisuje dane. Z czasem do tego samego miejsca trafiają walidacja, logowanie, wysyłka wiadomości, obsługa płatności i reguły promocji. Sam kod może nadal działać, ale jego utrzymanie staje się coraz droższe.

Ja traktuję SOLID przede wszystkim jako zestaw pytań pomocnych przy projektowaniu i refaktoryzacji. Czy ta klasa ma jeden powód do zmiany? Czy mogę dodać nowy wariant bez edytowania kilku instrukcji warunkowych? Czy test zastępczej implementacji nadal będzie miał sens? Takie pytania dają więcej niż mechaniczne odhaczanie pięciu liter.

Te reguły nie gwarantują automatycznie dobrego projektu. Czasem ich zbyt rygorystyczne stosowanie prowadzi do dziesiątek interfejsów, pustych klas i niepotrzebnej abstrakcji. Największą wartość przynoszą wtedy, gdy odpowiadają na realną złożoność domeny, a nie na wyobrażenie o przyszłych wymaganiach.

Schody z liter S.O.L.I.D. ilustrujące zasady solid w programowaniu: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, Dependency Inversion.

Single Responsibility Principle czyli jedna odpowiedzialność

Zasada jednej odpowiedzialności mówi, że moduł powinien mieć jeden powód do zmiany. To sformułowanie jest precyzyjniejsze niż popularne, ale mylące hasło „jedna klasa powinna robić jedną rzecz”. Większa klasa może mieć kilka powiązanych metod, o ile wszystkie dotyczą tego samego obszaru odpowiedzialności.

Przykład klasy, która robi zbyt wiele

class OrderService {
    calculateTotal(order) { ... }
    validateAddress(order) { ... }
    saveToDatabase(order) { ... }
    sendConfirmationEmail(order) { ... }
}

W tym przykładzie mamy co najmniej cztery powody do zmiany. Zmiana sposobu liczenia ceny dotyczy reguł biznesowych, zmiana bazy danych dotyczy persystencji, a nowy dostawca poczty wpływa na komunikację. W praktyce rozdzieliłbym te zadania na serwis zamówień, repozytorium i komponent powiadomień.

class OrderService {
    constructor(orderRepository, notifier, pricingPolicy) {
        this.orderRepository = orderRepository;
        this.notifier = notifier;
        this.pricingPolicy = pricingPolicy;
    }

    place(order) {
        const total = this.pricingPolicy.calculate(order);
        this.orderRepository.save({ ...order, total });
        this.notifier.sendOrderConfirmation(order);
    }
}

Po takim podziale serwis nadal koordynuje proces, ale nie zna szczegółów zapisu danych ani wysyłania wiadomości. Dzięki temu łatwiej go przetestować, a każdą część można rozwijać niezależnie. SRP nie oznacza izolowania każdej linijki, tylko rozdzielenie różnych powodów zmiany.

Typowy błąd przy stosowaniu SRP

Początkujący często dzielą kod zbyt wcześnie. Powstają klasy takie jak OrderTotalCalculatorFactoryProvider, które formalnie mają jedną odpowiedzialność, ale praktycznie utrudniają zrozumienie przepływu. Ja najpierw obserwuję, co rzeczywiście zmienia się razem, a dopiero potem wyznaczam granice.

Dobrym sygnałem ostrzegawczym są klasy, które importują bibliotekę do bazy danych, klienta HTTP, system szablonów i moduł logowania. Taki zestaw zależności zwykle oznacza, że moduł łączy kilka warstw. Warto wtedy rozdzielić nie tylko metody, lecz także odpowiedzialności biznesowe i techniczne.

Open-Closed Principle czyli rozszerzanie bez ciągłych zmian

Zasada otwarte-zamknięte mówi, że kod powinien być otwarty na rozszerzanie, ale zamknięty na modyfikację. Nie oznacza to zakazu zmieniania istniejących klas. Chodzi o to, aby dodanie nowego wariantu funkcji nie wymagało każdorazowo edytowania centralnego modułu pełnego instrukcji if i switch.

Instrukcje warunkowe jako sygnał problemu

function calculateShipping(order, type) {
    if (type === "courier") {
        return order.weight * 2.5;
    }

    if (type === "locker") {
        return 12.99;
    }

    if (type === "store") {
        return 0;
    }

    throw new Error("Unknown shipping type");
}

Przy trzech opcjach taki kod jest czytelny. Problem pojawia się, gdy dochodzą przesyłki zagraniczne, dostawa tego samego dnia i indywidualne umowy z przewoźnikami. Każdy nowy wariant wymaga zmiany funkcji, testów oraz często konfiguracji w kilku miejscach.

Lepszym rozwiązaniem może być wspólny kontrakt i osobne strategie:

class CourierShipping {
    calculate(order) {
        return order.weight * 2.5;
    }
}

class LockerShipping {
    calculate(order) {
        return 12.99;
    }
}

class ShippingCalculator {
    constructor(strategy) {
        this.strategy = strategy;
    }

    calculate(order) {
        return this.strategy.calculate(order);
    }
}

Nowy sposób dostawy można dodać jako kolejną klasę bez przebudowy kalkulatora. To prosty przykład wzorca strategii, który naturalnie wspiera OCP. W większych systemach podobną rolę mogą pełnić rejestry handlerów, fabryki albo mechanizmy wstrzykiwania zależności.

Nie każda instrukcja warunkowa jest błędem. Jeśli mamy dwa stabilne warianty, tworzenie całej hierarchii klas może zwiększyć koszt utrzymania. OCP ma największy sens tam, gdzie warianty faktycznie rosną albo zmieniają się niezależnie od reszty systemu.

Liskov Substitution Principle czyli bezpieczna zamiana implementacji

Zasada podstawienia Liskov mówi, że obiekt typu pochodnego powinien móc zastąpić obiekt bazowy bez psucia działania programu. W praktyce oznacza to, że implementacja musi dotrzymywać obietnic zapisanych w kontrakcie. Sam fakt dziedziczenia nie wystarcza.

Dziedziczenie, które łamie kontrakt

class ReadOnlyDocument extends Document {
    save() {
        throw new Error("Saving is not supported");
    }
}

Jeśli kod korzystający z Document zakłada, że metoda save() zadziała, obiekt ReadOnlyDocument nie jest poprawnym zamiennikiem. Błąd nie wynika z technicznego problemu z dziedziczeniem, lecz z tego, że klasa bazowa obiecuje więcej, niż powinien obiecywać wspólny kontrakt.

Rozwiązaniem może być rozdzielenie możliwości odczytu i zapisu:

class ReadableDocument {
    read() { ... }
}

class WritableDocument extends ReadableDocument {
    save() { ... }
}

Podobny problem często widać przy integracjach płatności. Jeśli interfejs zakłada zwrot identyfikatora autoryzacji i obsługę zwrotu środków, nie każda metoda płatności będzie mogła uczciwie go implementować. Lepiej podzielić kontrakt zgodnie z możliwościami dostawców niż wymuszać puste metody albo wyjątki.

Podczas testów sprawdzam nie tylko, czy klasa spełnia typ, lecz także czy zachowuje się zgodnie z oczekiwaniami. Wyjątki, dodatkowe ograniczenia i zmienione znaczenie wartości są częstym sygnałem naruszenia LSP, nawet gdy kod kompiluje się bez problemu.

Interface Segregation Principle czyli małe kontrakty zamiast wielkich interfejsów

Zasada segregacji interfejsów zachęca do budowania małych, wyspecjalizowanych kontraktów. Klasa nie powinna być zmuszana do implementowania metod, których nie potrzebuje. Dzięki temu zależności są prostsze, a zmiana jednej części interfejsu rzadziej wpływa na odległe moduły.

Dlaczego duży interfejs szybko przeszkadza

interface UserService {
    createUser();
    updateUser();
    deleteUser();
    sendPasswordReset();
    exportUsers();
    generateReport();
}

Panel administracyjny może potrzebować wszystkich operacji, ale aplikacja wysyłająca wiadomość o resecie hasła potrzebuje tylko jednej. Podłączenie jej do szerokiego interfejsu zwiększa sprzężenie i utrudnia testowanie.

W tym przypadku lepiej wydzielić mniejsze kontrakty:

interface UserCreator {
    createUser();
}

interface PasswordResetSender {
    sendPasswordReset();
}

interface UserExporter {
    exportUsers();
}

Małe interfejsy nie oznaczają, że każdy moduł musi mieć własny typ. Granica powinna wynikać z potrzeb klienta, czyli kodu, który korzysta z danego kontraktu. Interfejs projektuję pod użytkownika zależności, a nie pod klasę, która przypadkiem implementuje wszystkie dostępne operacje.

W aplikacjach webowych ISP dobrze sprawdza się przy adapterach do zewnętrznych usług. Kod domenowy może korzystać tylko z metod potrzebnych do autoryzacji, bez znajomości całego SDK dostawcy. To ogranicza wpływ zmian biblioteki na resztę systemu i ułatwia zastąpienie jej atrapą w testach.

Dependency Inversion Principle czyli zależność od abstrakcji

Odwrócenie zależności oznacza, że moduły wysokiego poziomu nie powinny bezpośrednio zależeć od szczegółów technicznych. Obie warstwy powinny opierać się na abstrakcji. W dodatku abstrakcja nie powinna być tworzona pod dyktando konkretnej biblioteki, lecz pod potrzeby logiki biznesowej.

Przeczytaj również: Singleton w Javie - jak go poprawnie używać (i kiedy nie)

Problem bezpośredniego tworzenia zależności

class AccountService {
    register(data) {
        const database = new MySqlDatabase();
        database.insert("accounts", data);
    }
}

Test tej klasy wymaga prawdziwej bazy albo skomplikowanego przechwytywania konstruktora. Dodatkowo zmiana MySQL na inne rozwiązanie dotyka kodu biznesowego, chociaż rejestracja konta nie powinna wiedzieć, z jakiej bazy korzysta aplikacja.

Po zastosowaniu wstrzykiwania zależności kod staje się bardziej elastyczny:

class AccountService {
    constructor(accountRepository) {
        this.accountRepository = accountRepository;
    }

    register(data) {
        this.accountRepository.save(data);
    }
}

Implementację repozytorium można podać podczas uruchamiania aplikacji, a w teście zastąpić prostą atrapą. To właśnie jest praktyczny efekt DIP. Wstrzykiwanie zależności nie jest celem samym w sobie, lecz sposobem na oddzielenie decyzji technicznych od reguł, które mają znaczenie dla biznesu.

Trzeba jednak uważać na abstrakcje tworzone na zapas. Interfejs IGenericRepositoryForEverything zwykle ukrywa problem zamiast go rozwiązywać. Lepiej zdefiniować kontrakt odpowiadający konkretnej potrzebie, na przykład AccountRepository z metodami save() i findByEmail().

Jak wdrażać SOLID bez produkowania zbędnej architektury

Najrozsądniej zacząć od kodu, który naprawdę sprawia problemy. Jeśli zmiana stawki podatku wymaga modyfikacji kontrolera, repozytorium i modułu powiadomień, mamy dobry kandydat do refaktoryzacji. Nie ma sensu przebudowywać stabilnej klasy tylko dlatego, że można ją podzielić na trzy mniejsze.

Przy pracy nad istniejącą funkcją stosuję prostą kolejność:

  1. Opisz powód zmiany i sprawdź, które moduły są nim dotknięte.
  2. Oddziel logikę biznesową od zapisu danych, komunikacji i frameworka.
  3. Wydziel kontrakty tylko tam, gdzie istnieje więcej niż jedna implementacja albo potrzebny jest niezależny test.
  4. Dodaj testy zachowania, aby refaktoryzacja nie zmieniła reguł biznesowych.
  5. Usuń abstrakcje bez zastosowania i sprawdź, czy kod po zmianie jest łatwiejszy do czytania.

Warto też rozróżnić błąd projektowy od zwykłej niedogodności. Klasa mająca 150 linii nie musi łamać SRP, jeśli wszystkie metody dotyczą jednego, spójnego procesu. Z drugiej strony klasa licząca 30 linii może być źle zaprojektowana, gdy łączy obsługę HTTP, reguły domenowe i szczegóły infrastruktury.

Najczęstsza przesada polega na budowaniu warstwy abstrakcji przed pojawieniem się realnego problemu. Każdy interfejs, adapter i fabryka zwiększają koszt poznawczy. Dobra architektura redukuje złożoność całego systemu, a nie tylko rozmiar pojedynczych klas.

Nie traktuję też SOLID jako recepty na każdy projekt. W małym skrypcie, prototypie lub jednorazowym narzędziu prostota może być ważniejsza niż pełna separacja warstw. Reguły zaczynają przynosić wyraźny zwrot wtedy, gdy kod ma żyć długo, rozwijać się przez wiele miesięcy i być utrzymywany przez więcej niż jedną osobę.

Najlepszy test jakości to koszt następnej zmiany

Pięć zasad nie służy do zdobywania punktów podczas przeglądu kodu. Ich sens widać dopiero wtedy, gdy pojawia się nowy dostawca płatności, kolejny typ użytkownika albo zmiana reguły biznesowej. Jeśli potrafię dodać taką funkcję bez naruszania wielu niezależnych modułów, architektura prawdopodobnie dobrze chroni swoje granice.

Na początku skupiłbym się na trzech rzeczach: jednej odpowiedzialności modułu, małych kontraktach i wstrzykiwaniu zależności. Te obszary najczęściej poprawiają testowalność i czytelność bez tworzenia ciężkiej infrastruktury. Pozostałe zasady warto dokładać wtedy, gdy konkretny problem rzeczywiście je uzasadnia.

Najbardziej dojrzałe podejście nie polega na tym, że każda klasa wygląda podręcznikowo. Chodzi o to, by kod jasno pokazywał swoje reguły, ograniczał skutki zmian i nie zmuszał zespołu do rozumienia całej aplikacji przed poprawieniem jednej funkcji.

FAQ - Najczęstsze pytania

Sygnałem są różne, niezależne powody do zmiany, na przykład jednoczesna obsługa logiki biznesowej, bazy danych i wysyłki e-maili. Warto też sprawdzić zależności klasy. Importowanie klienta HTTP, biblioteki bazodanowej, systemu szablonów i modułu logowania często wskazuje na połączenie kilku odpowiedzialności.

Strategia sprawdza się, gdy liczba niezależnych wariantów rośnie, na przykład przy różnych metodach dostawy. Osobne klasy mogą obsługiwać kuriera, paczkomat i odbiór w sklepie, a kalkulator korzysta ze wspólnego kontraktu. Nowy wariant można wtedy dodać bez rozbudowywania centralnego łańcucha instrukcji if.

Jeśli kontrakt Document obiecuje działającą metodę save(), implementacja ReadOnlyDocument, która zawsze zgłasza wyjątek, nie może bezpiecznie zastąpić obiektu bazowego. Lepszym rozwiązaniem jest rozdzielenie kontraktu odczytu i zapisu, na przykład przez klasy ReadableDocument i WritableDocument.

Klient powinien zależeć tylko od metod, których rzeczywiście potrzebuje. Zamiast szerokiego UserService można wydzielić kontrakty UserCreator, PasswordResetSender i UserExporter. Takie podejście ułatwia testowanie oraz ogranicza wpływ zmian zewnętrznego SDK na kod domenowy.

Nie warto tworzyć wielu interfejsów, adapterów i fabryk przed pojawieniem się realnego problemu. W małym skrypcie, prototypie lub stabilnej klasie prostota może być ważniejsza niż pełna separacja warstw. Abstrakcję najlepiej wprowadzać wtedy, gdy istnieje więcej niż jedna implementacja, potrzebny jest niezależny test albo zmiany rzeczywiście rozchodzą się po wielu modułach.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi:

solid wstrzykiwanie zależności interfejsy refaktoryzacja wzorzec strategii

Udostępnij artykuł

Alex Jabłoński

Alex Jabłoński

Nazywam się Alex Jabłoński i od 14 lat zajmuję się programowaniem webowym. Moja przygoda z tą dziedziną zaczęła się od prostych projektów, które stopniowo przerodziły się w pasję do tworzenia złożonych aplikacji internetowych. Interesuje mnie nie tylko kodowanie, ale także dzielenie się wiedzą, co skłoniło mnie do pisania artykułów, które pomagają innym zrozumieć zasady programowania. W swoich tekstach koncentruję się na podstawach, ale także na bardziej zaawansowanych zagadnieniach, starając się w przystępny sposób wyjaśnić trudne tematy. Przy pisaniu zwracam szczególną uwagę na aktualność informacji oraz ich zrozumiałość. Regularnie śledzę nowinki w branży, porównuję różne podejścia i staram się uprościć skomplikowane koncepcje, aby były dostępne dla każdego, niezależnie od poziomu zaawansowania. Moim celem jest dostarczenie wartościowych treści, które pomogą czytelnikom w ich drodze do kariery w programowaniu webowym.

Napisz komentarz