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.

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ść:
- Opisz powód zmiany i sprawdź, które moduły są nim dotknięte.
- Oddziel logikę biznesową od zapisu danych, komunikacji i frameworka.
- Wydziel kontrakty tylko tam, gdzie istnieje więcej niż jedna implementacja albo potrzebny jest niezależny test.
- Dodaj testy zachowania, aby refaktoryzacja nie zmieniła reguł biznesowych.
- 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.