Kontekst: skąd się wziął SOLID i po co go używać?
Obiektowe podejście w Javie a rola projektowania
Java od początku była projektowana jako język obiektowy dla dużych systemów: serwerowych, biznesowych, korporacyjnych. Klasy, interfejsy, dziedziczenie, polimorfizm – to wszystko ma ułatwiać budowanie oprogramowania, które można rozwijać latami. W praktyce wielu projektów kod obiektowy kończy jako zbiór przerośniętych klas, gęsto powiązanych ze sobą zależności i skomplikowanych warunków sterujących.
Na poziomie języka Java daje wszystkie narzędzia do dobrego projektowania, ale same narzędzia nie narzucają zasad. O tym, czy powstanie przejrzysta architektura, decydują decyzje projektowe: jak dzielone są odpowiedzialności, jak powstają interfejsy, gdzie stosowane jest dziedziczenie, a gdzie kompozycja. Właśnie na tym poziomie działają zasady SOLID.
W projektach utrzymywanych przez lata pytanie brzmi: co wiemy o przyszłości kodu? Wiadomo, że będzie się zmieniał, że pojawią się nowe typy danych, nowe integracje, nowe wymagania biznesowe. Czego nie wiemy? Konkretnej formy tych zmian. Zasady SOLID próbują odpowiedzieć na tę niepewność, proponując struktury, które zmiany ułatwią, zamiast je blokować.
Geneza akronimu SOLID i jego praktyczne cele
Akronim SOLID został spopularyzowany przez Roberta C. Martina (Uncle Bob) jako zestaw pięciu zasad projektowania obiektowego:
- S – Single Responsibility Principle
- O – Open/Closed Principle
- L – Liskov Substitution Principle
- I – Interface Segregation Principle
- D – Dependency Inversion Principle
Zasady SOLID nie są związane z konkretnym frameworkiem. Można je stosować w czystej Javie, w Springu, w aplikacjach desktopowych czy serwerowych. Mają kilka wspólnych celów: ograniczyć sprzężenia między modułami, ułatwić refaktoryzację, uprościć testowanie i wdrażanie zmian w stabilnym kodzie.
Z punktu widzenia programisty Javy te zasady przekładają się na bardzo konkretne decyzje: jak dzielić pakiety, jak formułować interfejsy, kiedy wydzielać nowe klasy, jak organizować warstwę serwisów i repozytoriów oraz jak projektować testy. Dobrze zrozumiane SOLID daje wyraźne wskazówki, co zmieniać, gdy kod zaczyna „boleć”.
Gdzie SOLID naprawdę pomaga, a gdzie przeszkadza
Największą wartość zasady SOLID przynoszą w systemach rozwijanych przez wiele miesięcy lub lat, w zespołach, gdzie nad tym samym kodem pracuje wielu programistów. Tam każdy dodatkowy stopień swobody przy zmianie funkcjonalności przekłada się na mniej regresji, mniejszą liczbę konfliktów w repozytorium i krótsze cykle wdrożeniowe. W systemach, które rosną warstwowo, umiejętne korzystanie z SOLID stabilizuje „rdzeń” aplikacji, a zmiany koncentruje na obrzeżach.
Istnieje jednak druga strona: nadmierne „uzSOLIDowanie” małych, jednorazowych projektów wprowadza przerost formy. Dodatkowe interfejsy, poziomy abstrakcji, hierarchie strategii mogą być wtedy ciężarem. Rzecz w tym, by dopasować intensywność zastosowania SOLID do kontekstu: dla małego skryptu automatyzującego zadanie w zespole nie ma sensu rozbudowana architektura, dla systemu zamówień działającego w firmie od lat – wręcz przeciwnie.
Z definicji do klas i pakietów
Definicje poszczególnych zasad są dość znane. Problem zaczyna się, gdy trzeba zdecydować, czy dana klasa w konkretnym projekcie narusza SRP, czy nowa funkcjonalność łamie OCP, albo czy hierarchia dziedziczenia faktycznie spełnia LSP. Tu potrzebny jest przejrzysty przykład domenowy i seryjna refaktoryzacja: od kodu „spaghetti” do rozwiązania z użyciem kolejnych zasad SOLID.
W kolejnych sekcjach punktem odniesienia stanie się prosty system zamówień, początkowo napisany „na skróty”, a następnie krok po kroku refaktoryzowany w duchu czystego kodu obiektowego. Dzięki temu każda zasada SOLID zostanie przełożona na realne klasy, interfejsy i pakiety w Javie, bez pozostawania na poziomie abstrakcyjnych haseł.
Punkt wyjścia: typowy „monolit klasowy” w Javie
Przykład domeny: prosty system zamówień
Wyobraźmy sobie prosty system obsługi zamówień w sklepie internetowym. W wersji „pierwsze podejście” powstaje jedna główna klasa, którą można spotkać w wielu repozytoriach:
public class OrderService {
public void createOrder(OrderRequest request) {
// walidacja danych
if (request.getItems().isEmpty()) {
throw new IllegalArgumentException("Empty order");
}
if (!request.getEmail().contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
// obliczanie ceny
BigDecimal total = BigDecimal.ZERO;
for (OrderItem item : request.getItems()) {
total = total.add(item.getPrice().multiply(
BigDecimal.valueOf(item.getQuantity())));
}
if (request.getCouponCode() != null) {
if (request.getCouponCode().equals("PROMO10")) {
total = total.multiply(BigDecimal.valueOf(0.9));
}
}
// zapis do bazy
Connection conn = getConnection();
try (PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO orders (email, total) VALUES (?, ?)")) {
stmt.setString(1, request.getEmail());
stmt.setBigDecimal(2, total);
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
// wysłanie maila
sendEmail(request.getEmail(), "Order created, total: " + total);
}
private Connection getConnection() {
// ... inicjalizacja JDBC ...
}
private void sendEmail(String to, String body) {
// ... prosty klient SMTP ...
}
}
Na pierwszy rzut oka wszystko jest w jednym miejscu, łatwe do śledzenia. W praktyce klasa realizuje wiele zadań: walidację, logikę taryf, integrację z bazą danych, wysyłkę e-maili. To zaprzeczenie kilku zasad SOLID jednocześnie, ale taki kod pojawia się często na początku projektu.
Objawy „boga-klasy” i naruszeń SOLID
Jakie objawy pojawiają się po kilku miesiącach rozwoju takiego systemu? Pierwszy sygnał to rozrastanie się metod: kolejne warunki if, dodatkowe kupony, integracje z innymi systemami płatności. Drugi – rosnące sprzężenie: zmiana sposobu wysyłki maili wymaga dotknięcia tej samej klasy, co zmiana walidacji. Pojawia się też problem z testowaniem, bo aby przetestować walidację, trzeba zasymulować połączenie z bazą i e-mail.
Widać jednoczesne naruszenie kilku zasad:
- SRP – jedna klasa ma kilka powodów do zmiany (walidacja, logika biznesowa, persystencja, komunikacja).
- OCP – każdy nowy typ kuponu oznacza modyfikację istniejącej metody, rosnący blok if-else.
- ISP/LSP – gdyby rozszerzać ten kod przez dziedziczenie lub interfejsy, łatwo wpaść w pułapkę zbyt ogólnych kontraktów.
- DIP – klasa zależy bezpośrednio od szczegółów technicznych (JDBC, konkretny klient SMTP).
W praktyce pojawia się efekt domina: mała zmiana w kuponach wymaga przejścia przez te same metody, których dotykają zmiany w integracjach. Zespół zaczyna obawiać się refaktoryzacji, cykl wydania nowej funkcji wydłuża się, a liczba testów integracyjnych rośnie, bo testy jednostkowe są zbyt trudne do napisania bez mockowania wszystkiego naraz.
Konsekwencje praktyczne: strach przed zmianą
W repozytorium z „monolitem klasowym” często widać konflikty przy merge’ach – kilku programistów edytuje tę samą klasę, najczęściej te same metody. Wprowadzenie nowego kuponu rabatowego, obsługi walut czy innego sposobu wysyłki maili kończy się serią poprawek po wdrożeniu. Rozmowy w zespole krążą wokół obaw: „jeśli dotkniemy tej metody, znów coś się wysypie”.
Ten przykład jest dobrym polem do ćwiczenia refaktoryzacji do kodu zgodnego z SOLID. Da się go krok po kroku „rozciąć” na mniejsze klasy, interfejsy i pakiety, bez zmiany zachowania biznesowego. Celem jest struktura, w której nowy kupon lub nowa metoda powiadomienia wymaga dodania nowej klasy, a nie modyfikacji istniejącej logiki w kilku miejscach.
Z punktu widzenia kariery programisty Javy znajomość zasad SOLID jest dziś standardem. Coraz częściej rekrutacje, code review i wewnętrzne standardy jakości bazują na tych pojęciach. W praktyce nauka projektowania obiektowego układa się obok narzędzi takich jak Maven czy Gradle czy tematów w stylu więcej o programowanie, tworząc spójny obraz rzemiosła programistycznego.

Single Responsibility Principle (SRP) w Javie – mniej znaczy czytelniej
Istota SRP: jeden powód do zmiany
Single Responsibility Principle w ujęciu praktycznym mówi: klasa powinna mieć jeden powód do zmiany. W Javie ten powód zwykle odpowiada jednemu spójnemu obszarowi odpowiedzialności: obsługa zamówień, walidacja zamówień, wysyłka powiadomień, komunikacja z bazą danych. Mylone jest to często z zasadą „tylko jedna metoda publiczna” – to nie to samo. Klasa może mieć kilka metod, byle wszystkie dotyczyły tej samej odpowiedzialności.
Na przykład w systemie zamówień sensowna jest klasa, która odpowiada za logikę biznesową tworzenia zamówienia (bez szczegółów technicznych), oraz osobne klasy dla persystencji, walidacji i powiadomień. Odpowiedzialność staje się wtedy bardziej biznesowa niż techniczna. Jednocześnie łatwiej odpowiedzieć na pytanie: „co się stanie, jeśli zmieni się reguła X?” – wiemy, które klasy trzeba przejrzeć.
Jak rozpoznać klasę łamiącą SRP
W codziennej pracy nad kodem kilka sygnałów wskazuje na naruszanie SRP:
- W jednej klasie pojawiają się zarówno operacje na modelu domenowym, jak i kod SQL, obsługa HTTP, wysyłka maili lub logowanie.
- Nazwy metod są z różnych „światów”:
validateOrder,saveOrderToDb,sendConfirmationEmailw jednej klasie. - Każda większa zmiana wymaga modyfikacji tej samej klasy, niezależnie czy dotyczy logiki biznesowej, warstwy bazy czy zewnętrznych integracji.
- Testy jednostkowe tej samej klasy muszą mockować zbyt wiele zależności (baza, e-mail, serwisy zewnętrzne).
Typowy efekt: klasa rośnie, zaczyna się liczyć nie dziesiątkami, a setkami linii. W pull requestach pojawiają się komentarze „ta klasa robi za dużo”, ale bez jasnej strategii, jak ją rozbić.
Refaktoryzacja „boga-klasy” na serwisy i repozytoria
Wracając do przykładu OrderService, można go przekształcić w zestaw wyspecjalizowanych komponentów. Przykładowy podział:
OrderValidator– walidacja danych wejściowych zamówienia.PriceCalculator– logika liczenia sumy, kuponów, rabatów.OrderRepository– persystencja zamówień (JDBC, JPA, inny mechanizm).NotificationService– wysyłanie powiadomień (np. e-mail).OrderService– „orchestrator” wywołujący powyższe komponenty w odpowiedniej kolejności.
public class OrderService {
private final OrderValidator validator;
private final PriceCalculator priceCalculator;
private final OrderRepository orderRepository;
private final NotificationService notificationService;
public OrderService(OrderValidator validator,
PriceCalculator priceCalculator,
OrderRepository orderRepository,
NotificationService notificationService) {
this.validator = validator;
this.priceCalculator = priceCalculator;
this.orderRepository = orderRepository;
this.notificationService = notificationService;
}
public void createOrder(OrderRequest request) {
validator.validate(request);
BigDecimal total = priceCalculator.calculateTotal(request);
Order order = new Order(request.getEmail(), total);
orderRepository.save(order);
notificationService.sendOrderCreated(order);
}
}
Każda z klas zależnych ma teraz jeden wyraźny powód do zmiany. Zmiana zasad rabatowych dotknie PriceCalculator, a przejście z JDBC na JPA – OrderRepository. SRP staje się w ten sposób narzędziem do lokalizowania zmian.
Rola pakietów i nazewnictwa w utrzymaniu SRP
Sam podział na klasy nie wystarczy, jeśli nie uporządkuje się przestrzeni nazw. W Javie naturalnym sprzymierzeńcem SRP są pakiety. Dla prostego systemu zamówień można wprowadzić strukturę:
com.example.order.domain– model domenowy (Order, OrderItem).com.example.order.application– logika przypadków użycia (OrderService, PriceCalculator, OrderValidator).com.example.order.infrastructure– implementacje techniczne (OrderRepositoryJpa, SmtpNotificationService).com.example.order.api– kontrolery REST lub inne adaptery wejściowe.
SRP zostaje wzmocnione przez organizację pakietów: klasy z jednej odpowiedzialności naturalnie lądują w jednym fragmencie struktury projektu. Przy code review szybciej widać, czy dana zmiana „rozlewa się” poza główny obszar odpowiedzialności.
SRP a testy jednostkowe
SRP w testach: prostsze scenariusze, mniej mocków
Rozdzielenie odpowiedzialności przekłada się bezpośrednio na strukturę testów. Zamiast jednego rozbudowanego testu integracyjnego dla OrderService, pojawia się kilka mniejszych klas testowych skupionych na konkretnej logice. Co się zmienia w praktyce?
- Test
PriceCalculatorTestoperuje wyłącznie na modelu domenowym, bez bazy i SMTP. - Test
OrderValidatorTestsprawdza tylko scenariusze poprawnych i błędnych danych wejściowych. - Test
OrderRepositoryTestmoże zostać oznaczony jako integracyjny i używać np. wbudowanej bazy H2. - Test
NotificationServiceTestdziała na „fałszywym” kliencie e-mail zamiast prawdziwego serwera.
Centralny OrderService staje się prostą orkiestracją, którą często wystarczy przetestować kilkoma scenariuszami z użyciem mocków lub stubów dla zależności. Ilość konfiguracji w testach maleje, a zespół z czasem zaczyna unikać „testów wszystkiego na raz” jako kosztownych w utrzymaniu.
Co wiemy? SRP zmniejsza złożoność pojedynczych klas, a tym samym zmniejsza zakres odpowiedzialności pojedynczego testu. Czego nadal nie wiemy? Jak radzić sobie z rozbudową logiki bez ingerowania w istniejący, przetestowany kod. Tu dochodzimy do kolejnej zasady.
Open/Closed Principle (OCP) – rozszerzanie zamiast modyfikowania kodu
OCP w praktyce Javy: klasy otwarte na rozszerzanie, zamknięte na modyfikację
Open/Closed Principle można streścić tak: komponent ma być otwarty na rozszerzanie, a zamknięty na modyfikację. Innymi słowy – nowe funkcjonalności powinny powstawać przez dodawanie nowych klas lub implementacji, nie przez ciągłe edytowanie tych samych plików źródłowych.
W Javie mechanizmem sprzyjającym OCP są interfejsy i kompozycja obiektów. Zamiast wielkiego bloku if-else w metodzie liczącej kupony, lepszym rozwiązaniem jest wyprowadzenie logiki w osobne strategie i dobranie odpowiedniej implementacji w czasie działania.
Typowy antywzorzec: rosnący blok if-else
Rozszerzając wcześniejszy przykład, można przyjrzeć się metodzie liczenia ceny:
public BigDecimal calculateTotal(OrderRequest request) {
BigDecimal base = sumItems(request.getItems());
if (request.getCouponCode() != null) {
if (request.getCouponCode().startsWith("PERCENT_")) {
base = applyPercentDiscount(base, request.getCouponCode());
} else if (request.getCouponCode().startsWith("FIXED_")) {
base = applyFixedDiscount(base, request.getCouponCode());
} else if (request.getCouponCode().equals("FREE_SHIPPING")) {
base = applyFreeShipping(base);
} else if (request.getCouponCode().startsWith("LOYAL_")) {
base = applyLoyaltyDiscount(base, request.getCouponCode());
}
// kolejne else if wraz z rozwojem biznesu
}
return base;
}
Każdy nowy typ kuponu wymaga wejścia do tej metody i dodania kolejnego warunku. To bezpośrednie naruszenie OCP. Wraz z rozwojem systemu rośnie ryzyko konfliktów w merge’ach i niechcianych regresji, bo dotykamy stale centrum algorytmu.
Rozbicie logiki na strategie z użyciem interfejsów
Aby zastosować OCP, można zdefiniować interfejs opisujący zachowanie kuponu:
public interface DiscountPolicy {
boolean supports(String couponCode);
BigDecimal apply(BigDecimal baseAmount, String couponCode);
}
Następnie każdą logikę rabatu zamknąć w osobnej klasie:
public class PercentDiscountPolicy implements DiscountPolicy {
@Override
public boolean supports(String couponCode) {
return couponCode != null && couponCode.startsWith("PERCENT_");
}
@Override
public BigDecimal apply(BigDecimal baseAmount, String couponCode) {
int percent = Integer.parseInt(couponCode.substring("PERCENT_".length()));
return baseAmount.multiply(BigDecimal.valueOf(100 - percent))
.divide(BigDecimal.valueOf(100));
}
}
public class FixedDiscountPolicy implements DiscountPolicy {
@Override
public boolean supports(String couponCode) {
return couponCode != null && couponCode.startsWith("FIXED_");
}
@Override
public BigDecimal apply(BigDecimal baseAmount, String couponCode) {
BigDecimal discount = new BigDecimal(couponCode.substring("FIXED_".length()));
BigDecimal result = baseAmount.subtract(discount);
return result.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : result;
}
}
Sam PriceCalculator nie musi znać szczegółów poszczególnych kuponów:
public class PriceCalculator {
private final List<DiscountPolicy> discountPolicies;
public PriceCalculator(List<DiscountPolicy> discountPolicies) {
this.discountPolicies = discountPolicies;
}
public BigDecimal calculateTotal(OrderRequest request) {
BigDecimal base = sumItems(request.getItems());
String coupon = request.getCouponCode();
if (coupon == null) {
return base;
}
return discountPolicies.stream()
.filter(p -> p.supports(coupon))
.findFirst()
.map(p -> p.apply(base, coupon))
.orElse(base);
}
private BigDecimal sumItems(List<OrderItemRequest> items) {
// ... prosta suma pozycji koszyka ...
}
}
Dodanie nowego typu kuponu sprowadza się do stworzenia nowej klasy implementującej DiscountPolicy i zarejestrowania jej w konfiguracji (np. w Springu jako kolejny bean). Nie ma potrzeby modyfikacji samego PriceCalculator.
Konfiguracja rozszerzeń: od ręcznego wiring do DI
Jak powiązać nowe strategie z kalkulatorem? Są co najmniej dwa scenariusze:
Dobrym uzupełnieniem będzie też materiał: Maven i Gradle z linii komend – praktyczne przykłady — warto go przejrzeć w kontekście powyższych wskazówek.
- Konfiguracja ręczna w prostych projektach:
List<DiscountPolicy> policies = Arrays.asList(
new PercentDiscountPolicy(),
new FixedDiscountPolicy(),
new FreeShippingPolicy()
);
PriceCalculator calculator = new PriceCalculator(policies);
- Automatyczne wstrzykiwanie przez framework DI (np. Spring):
@Service
public class PriceCalculator {
private final List<DiscountPolicy> discountPolicies;
public PriceCalculator(List<DiscountPolicy> discountPolicies) {
this.discountPolicies = discountPolicies;
}
// ...
}
Spring automatycznie wstrzyknie wszystkie beany implementujące DiscountPolicy. Rozszerzenie systemu oznacza dodanie nowego beana, bez zmian w kodzie kalkulatora. To praktyczna realizacja OCP przy wsparciu kontenera DI.
OCP a stabilne kontrakty domenowe
OCP nie oznacza, że nigdy nie modyfikujemy istniejącego kodu. Kluczowe jest ustalenie, które fragmenty systemu traktowane są jako stabilne kontrakty, a które mogą ewoluować. Przykładowo:
- Interfejs
DiscountPolicypowinien być raczej stabilny – to kontrakt rozszerzeń. - Konkretny
PercentDiscountPolicymoże się zmieniać wraz z regułami biznesowymi. - Parametry wejściowe, takie jak
OrderRequest, trzeba projektować ostrożnie, bo naruszenie ich struktury uderzy w wiele miejsc.
W dobrze zorganizowanym projekcie takie „punkty rozszerzeń” są świadomie opisane i chronione testami. Nowe funkcje powstają w nowych klasach, a stare pozostają nienaruszone, jeśli działają poprawnie.

Liskov Substitution Principle (LSP) – kiedy dziedziczenie faktycznie ma sens
Definicja w kontekście Javy: podtyp nie może psuć kontraktu
Liskov Substitution Principle mówi, że obiekty podtypów powinny dać się użyć wszędzie tam, gdzie oczekiwany jest typ bazowy, bez zmiany poprawności działania programu. W Javie przekłada się to na relację między klasami dziedziczącymi oraz implementacjami interfejsów.
Na poziomie faktów oznacza to, że metody w klasie pochodnej nie powinny:
- zaostrzać prewarunków (np. wymagając więcej niż typ bazowy),
- osłabiać gwarantowanych postwarunków (zwracać wyniku o innych własnościach niż obiecano),
- łamać niejawnych założeń dotyczących stanu obiektu.
Gdzie typowo łamane jest LSP? Często w pośpiesznym dziedziczeniu po klasach frameworków lub w „dopasowywaniu” hierarchii do aktualnych potrzeb, bez przemyślenia kontraktu.
Przykład naruszenia LSP: „specjalny” repozytorium zamówień
Załóżmy, że istnieje ogólny interfejs repozytorium:
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(Long id);
}
Dla prostego przypadku pojawia się implementacja JPA:
public class JpaOrderRepository implements OrderRepository {
@Override
public Order save(Order order) {
// ... zapis przez EntityManager ...
return order;
}
@Override
public Optional<Order> findById(Long id) {
// ... odczyt przez EntityManager ...
}
}
Po czasie pojawia się potrzeba osobnego repozytorium wyłącznie do odczytu, np. na potrzeby raportów. Ktoś dodaje klasę:
public class ReadOnlyOrderRepository extends JpaOrderRepository {
@Override
public Order save(Order order) {
throw new UnsupportedOperationException("Read-only repository");
}
}
Na papierze ReadOnlyOrderRepository jest podtypem JpaOrderRepository, a jednocześnie implementuje OrderRepository. W praktyce łamie LSP, bo nie można już bezpiecznie użyć go wszędzie tam, gdzie oczekiwane jest zwykłe OrderRepository. Wyjątek w save zaskakuje kod wywołujący.
Rozwiązanie: separacja kontraktów zamiast „udawania” podtypu
Zamiast tworzyć „specjalne” podklasy łamiące kontrakt, lepiej jest rozdzielić interfejsy na bardziej precyzyjne:
public interface OrderReader {
Optional<Order> findById(Long id);
}
public interface OrderWriter {
Order save(Order order);
}
public class JpaOrderRepository implements OrderReader, OrderWriter {
@Override
public Order save(Order order) {
// ...
}
@Override
public Optional<Order> findById(Long id) {
// ...
}
}
public class ReadOnlyOrderRepository implements OrderReader {
@Override
public Optional<Order> findById(Long id) {
// ... na przykład dostęp tylko do repliki bazy ...
}
}
Kod, który potrzebuje wyłącznie odczytu, zależy od OrderReader. Miejsca, w których wymagany jest zapis, używają OrderWriter. LSP zostaje zachowana, bo żadna z implementacji nie „udaje” pełnego repozytorium z metodą save.
Na koniec warto zerknąć również na: Jak testy E2E pomagają dbać o UX w dużych projektach — to dobre domknięcie tematu.
LSP w hierarchiach domenowych: dziedziczenie vs kompozycja
W modelu domenowym, takim jak zamówienia, pokusa użycia dziedziczenia jest duża: OnlineOrder, InStoreOrder, CorporateOrder itp. Problem pojawia się, gdy różnice między typami wymuszają odmienne zachowanie w metodach wspólnych. Przykład:
public abstract class Order {
public abstract BigDecimal calculateTotal();
}
public class OnlineOrder extends Order {
@Override
public BigDecimal calculateTotal() {
// cena + koszt wysyłki
}
}
public class InStoreOrder extends Order {
@Override
public BigDecimal calculateTotal() {
// bez kosztu wysyłki
}
}
Na razie kontrakt nie jest naruszony. Jednak gdy jeden z typów zacznie wymagać dodatkowych efektów ubocznych (np. logowania, specyficznych walidacji stanu), a drugi nie, wspólna abstrakcja staje się mniej spójna. Pojawia się kod, który musi znać konkretne typy i robić dodatkowe instanceof – to sygnał, że hierarchia może być sztuczna.
Często bezpieczniejszym rozwiązaniem jest kompozycja: wspólna klasa bazowa reprezentuje dane, a specyficzne zachowania delegowane są do strategii lub osobnych serwisów. W efekcie LSP nie jest przeciążone przez „magiczne” różnice między podtypami, bo typy są prostsze, a polimorfizm stosowany oszczędniej.
Kontrakty a dokumentacja i testy
LSP to nie tylko struktura klas, ale też jasna dokumentacja kontraktu. W interfejsie warto precyzować, czego można oczekiwać:
/**
* Repository for reading orders.
* Implementations must return {@link Optional#empty()} when order is not found,
* and must not throw exceptions for non-existing ids.
*/
public interface OrderReader {
Optional<Order> findById(Long id);
}
Testy kontraktowe (wspólne zestawy testów uruchamiane na różnych implementacjach interfejsu) pomagają zweryfikować, czy nowe implementacje rzeczywiście respektują założenia. Dla OrderReader można napisać abstrakcyjną klasę testową, z której dziedziczą testy konkretnych repozytoriów. To praktyczny sposób egzekwowania LSP.
Interface Segregation Principle (ISP) – mniejsze interfejsy, mniej bólu
Idea ISP: klienci nie powinni zależeć od metod, których nie używają
Interface Segregation Principle zakłada, że interfejsy powinny być małe i wyspecjalizowane, a klienci nie powinni być zmuszani do implementowania lub wywoływania metod, których nie potrzebują. W Javie problem jest szczególnie widoczny przy „bogatych” interfejsach serwisów, które rosną z projektu na projekt.
Typowy przykład to interfejs OrderService, który na przestrzeni miesięcy zyskuje kolejne metody: tworzenie, anulowanie, aktualizację adresu, zwroty, generowanie faktur, synchronizację z systemem zewnętrznym. Klasy, które potrzebują tylko niewielkiego wycinka, są i tak związane z całym kontraktem.
Przerośnięty interfejs serwisu zamówień
Wyobraźmy sobie interfejs:
Najczęściej zadawane pytania (FAQ)
Co to jest SOLID w Javie i po co go stosować?
SOLID to zestaw pięciu zasad projektowania obiektowego (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion), spopularyzowanych przez Roberta C. Martina. Ich celem jest ograniczenie nadmiernych powiązań między klasami, uproszczenie zmian w kodzie i ułatwienie testowania.
W Javie zasady SOLID pomagają projektować klasy, interfejsy i pakiety tak, by system dało się rozwijać przez lata, bez ciągłego „wywracania” istniejącej architektury. Zamiast jednej ogromnej klasy obsługującej wszystko, powstaje zestaw mniejszych komponentów o jasno zdefiniowanych odpowiedzialnościach.
Dlaczego zasady SOLID są ważne w dużych projektach Java?
W dużych projektach, utrzymywanych przez wiele miesięcy lub lat, kod jest zmieniany przez wielu programistów. Każda modyfikacja może wywołać efekt domina: zmiana w jednym miejscu psuje coś w innym module. SOLID zmniejsza to ryzyko, bo rozdziela odpowiedzialności i stabilizuje „rdzeń” systemu.
Praktyczna konsekwencja jest prosta: mniej konfliktów przy merge’ach, krótsze cykle wdrożeniowe, mniejszy strach przed refaktoryzacją. Zespół nie musi co tydzień „dotykać” tej samej, rozrośniętej klasy serwisowej, by dodać nową funkcję biznesową.
Kiedy stosowanie SOLID w Javie ma sens, a kiedy jest przerostem formy?
SOLID szczególnie pomaga w systemach biznesowych, które z definicji będą rozwijane: serwisy backendowe, aplikacje korporacyjne, rozbudowane moduły w Springu. Tam inwestycja w czystsze interfejsy, rozdzielenie warstw (walidacja, logika, persystencja, integracje) i odwrócenie zależności realnie obniża koszty utrzymania.
W bardzo małych narzędziach, jednorazowych skryptach czy prostych proof-of-concept rozbudowana architektura SOLID może być obciążeniem. Dodatkowe interfejsy, abstrakcje i klasy bazowe tylko wydłużają czas implementacji, a nie przynoszą wymiernych korzyści, bo kod nigdy nie doczeka się poważnych zmian.
Jak rozpoznać, że mój kod w Javie łamie zasady SOLID?
Typowe sygnały to: rosnące, kilkusetlinijkowe klasy (często nazywane „boga-klasami”), metody obsługujące kilka różnych zadań naraz, rozbudowane łańcuchy if-else oraz trudności z testami jednostkowymi. Jeśli do przetestowania walidacji zamówienia musisz uruchomić połączenie z bazą i wysyłkę e-maili, to sygnał naruszenia kilku zasad na raz.
W praktyce widać to także w historii repozytorium: kilku programistów jednocześnie edytuje ciągle tę samą klasę serwisową, a drobne zmiany (np. nowy kupon rabatowy) kończą się serią poprawek po wdrożeniu. Co wiemy wtedy na pewno? Że odpowiedzialności są zbyt sklejone, a kod nie jest przygotowany na zmiany.
Jak zastosować SOLID do refaktoryzacji „boga-klasy” w Javie?
Dobrym punktem startu jest rozbicie odpowiedzialności: osobna klasa do walidacji danych wejściowych, osobna do logiki taryf i kuponów, osobne repozytorium do zapisu zamówienia oraz serwis komunikacyjny do wysyłki e-maili. Wtedy jedna zmiana (np. nowy typ kuponu) dotyka konkretnego modułu, a nie centralnej, gigantycznej metody.
Następny krok to wprowadzenie interfejsów i zależności „w górę” (Dependency Inversion): serwis zamówień zależy od abstrakcji (np. EmailSender, OrderRepository), a nie od konkretnych implementacji JDBC czy SMTP. Dzięki temu łatwiej podmienić szczegóły techniczne, przetestować logikę biznesową w izolacji i stopniowo porządkować projekt bez „big bang refactoring”.
Czy SOLID jest powiązany z konkretnym frameworkiem Java, np. Spring?
Zasady SOLID są niezależne od frameworka. Można je stosować w czystej Javie, w aplikacjach springowych, desktopowych czy serwerowych. Frameworki dostarczają narzędzi (kontener DI, transakcje, konfigurację), ale to decyzje projektowe programisty decydują, czy kod faktycznie jest zgodny z SOLID.
Przykład: w Springu łatwo zdefiniować wiele beanów i interfejsów, co sprzyja DIP i SRP. Nie jest to jednak automatyczne – równie dobrze można stworzyć jednego „OrderService”, który robi wszystko, ignorując możliwość rozdzielenia odpowiedzialności na mniejsze serwisy, walidatory i repozytoria.
Jak zasady SOLID wpływają na testowanie aplikacji w Javie?
Kod zgodny z SOLID ma mniejsze, wyspecjalizowane klasy i dobrze zdefiniowane interfejsy. Dzięki temu test jednostkowy może obejmować jedną odpowiedzialność, bez konieczności uruchamiania bazy danych, serwera SMTP czy zewnętrznych integracji. Mockowanie zależności staje się prostsze, bo klasa ma ich mniej i są jasno opisane.
Efekt w projektach długoterminowych jest wyraźny: rośnie udział szybkich testów jednostkowych, a liczba ciężkich testów integracyjnych może być ograniczona do kluczowych ścieżek. Co dalej z tego wynika? Krótszy feedback dla programistów i mniejsze ryzyko, że drobna zmiana w jednym miejscu uszkodzi zupełnie inny fragment systemu.






