Gdy klasa zaczyna obsługiwać zbyt wiele przypadków, a każda zmiana pociąga za sobą poprawki w kilku innych miejscach, problemem zwykle nie jest sam Java, lecz sposób organizacji kodu. W tym artykule pokazuję najważniejsze wzorce projektowe w Javie, ich praktyczne zastosowania, zalety, ograniczenia oraz sytuacje, w których lepiej z nich zrezygnować.
Najważniejsze wzorce pomagają pisać kod, który łatwiej rozwijać i testować
- Trzy grupy obejmują wzorce kreacyjne, strukturalne i behawioralne.
- Builder upraszcza tworzenie obiektów z wieloma opcjonalnymi parametrami.
-
Strategy pozwala wymieniać algorytmy bez rozbudowanych instrukcji
ifiswitch. - Adapter i Facade porządkują współpracę z obcymi lub skomplikowanymi interfejsami.
- Wzorzec nie jest celem. Stosuję go dopiero wtedy, gdy rozwiązuje konkretny problem projektowy.
Czym są wzorce projektowe w Javie i po co ich używać
Wzorzec projektowy to sprawdzony sposób organizacji współpracujących klas i obiektów. Nie jest gotową biblioteką ani fragmentem kodu do bezmyślnego skopiowania. To raczej nazwa rozwiązania, która pozwala szybko porozumieć się z innym programistą.
Jeżeli mówię, że dana część aplikacji korzysta ze wzorca Strategy, od razu wiadomo, że algorytm można wymieniać niezależnie od obiektu, który go używa. Taki wspólny język przydaje się podczas code review, projektowania architektury i rozmów rekrutacyjnych.
Klasyczny katalog wzorców Gang of Four dzieli rozwiązania na 23 wzorce należące do trzech grup. W codziennej pracy nie trzeba znać wszystkich na pamięć. Znacznie ważniejsze jest rozpoznanie problemu, który dany wzorzec faktycznie rozwiązuje.
| Grupa | Główne pytanie | Przykłady |
|---|---|---|
| Kreacyjne | Jak tworzyć obiekty bez uzależniania kodu od konkretnych klas? | Factory Method, Abstract Factory, Builder, Singleton |
| Strukturalne | Jak łączyć klasy i upraszczać ich współpracę? | Adapter, Decorator, Facade, Proxy |
| Behawioralne | Jak organizować komunikację i odpowiedzialności między obiektami? | Strategy, Observer, Command, Template Method |
W nowoczesnej Javie część dawnych konstrukcji można zapisać krócej dzięki lambdom, interfejsom funkcyjnym, rekordom i wstrzykiwaniu zależności. To nie oznacza, że wzorce straciły znaczenie. Po prostu ich implementacja bywa dziś lżejsza niż w starszych wersjach języka.
Wzorce kreacyjne pomagają rozsądnie tworzyć obiekty
Factory Method i prosta fabryka
Fabryka przenosi decyzję o tym, jaki obiekt utworzyć, poza kod biznesowy. Jest przydatna, gdy aplikacja pracuje z kilkoma implementacjami tego samego interfejsu, na przykład różnymi metodami płatności.
interface Payment {
void pay(BigDecimal amount);
}
final class PaymentFactory {
static Payment create(String type) {
return switch (type) {
case "card" -> new CardPayment();
case "transfer" -> new BankTransferPayment();
default -> throw new IllegalArgumentException("Nieznana metoda");
};
}
}
W praktyce najprostsza fabryka często wystarcza. Gdy jednak instrukcja switch zaczyna puchnąć, lepsza może być mapa dostawców albo właściwy Factory Method. Fabryka ma ukrywać szczegóły tworzenia, a nie stać się kolejną klasą z setkami warunków.
Builder przy wielu parametrach
Builder sprawdza się przy obiektach, które mają kilka wymaganych pól i wiele opcjonalnych ustawień. Zamiast konstruktora z ośmioma parametrami otrzymujemy kod, który można czytać niemal jak konfigurację.
Order order = new Order.Builder("ORD-1001")
.customerId(42L)
.discount(new BigDecimal("10.00"))
.priority(true)
.build();
Największą zaletą jest czytelność oraz mniejsze ryzyko pomylenia parametrów tego samego typu. Nie stosuję Buildera do każdej klasy. Przy obiekcie z dwoma prostymi polami zwykły konstruktor lub metoda fabrykująca będzie bardziej przejrzysta.
Singleton z dużym znakiem zapytania
Singleton gwarantuje istnienie jednej instancji klasy. W Javie można go bezpiecznie zrealizować na przykład za pomocą enuma, ale sam wzorzec często tworzy globalny stan, utrudnia testowanie i zwiększa zależności między modułami.
enum AppConfig {
INSTANCE;
public String environment() {
return "production";
}
}
W aplikacjach opartych na Springu rolę pojedynczej instancji zwykle przejmuje kontener zależności. Moja praktyczna zasada jest prosta: nie wybieram Singletona tylko dlatego, że obiekt ma być dostępny wszędzie. Najpierw sprawdzam, czy lepsze nie będzie jawne wstrzyknięcie zależności.
Wzorce strukturalne porządkują zależności między klasami
Adapter, gdy interfejsy do siebie nie pasują
Adapter pozwala użyć istniejącej klasy mimo niezgodnego interfejsu. Typowy przykład to integracja z zewnętrznym dostawcą, który zwraca dane w innym formacie niż ten używany w naszej domenie.
interface WeatherService {
Temperature currentTemperature(String city);
}
final class ExternalWeatherAdapter implements WeatherService {
private final ExternalWeatherClient client;
ExternalWeatherAdapter(ExternalWeatherClient client) {
this.client = client;
}
public Temperature currentTemperature(String city) {
ExternalReading reading = client.read(city);
return new Temperature(reading.value(), reading.unit());
}
}
Dzięki temu reszta aplikacji zna tylko własny interfejs. Kiedy dostawca zmieni bibliotekę albo format odpowiedzi, modyfikuję adapter, a nie kilkanaście klas biznesowych. To jeden z najbardziej praktycznych wzorców przy integracjach API.
Decorator zamiast rozbudowanej hierarchii
Decorator dodaje obiektowi nowe zachowanie bez zmieniania jego klasy. Dobrym przykładem jest opakowanie klienta HTTP w logowanie, cache lub mechanizm ponawiania żądań.
interface MessageSender {
void send(String message);
}
final class LoggingSender implements MessageSender {
private final MessageSender delegate;
LoggingSender(MessageSender delegate) {
this.delegate = delegate;
}
public void send(String message) {
System.out.println("Wysyłam wiadomość");
delegate.send(message);
}
}
Można połączyć kilka dekoratorów, na przykład logowanie, pomiar czasu i obsługę błędów. Ceną jest większa liczba obiektów oraz trudniejsze śledzenie przepływu. Gdy opakowań robi się za dużo, warto sprawdzić, czy lepszy nie będzie mechanizm middleware albo filtrów.
Facade i Proxy w aplikacjach webowych
Facade udostępnia prosty interfejs do złożonego podsystemu. Serwis aplikacji może na przykład wywołać jedną metodę placeOrder(), zamiast samodzielnie koordynować magazyn, płatność, fakturę i wysyłkę.
Proxy działa jako pośrednik przed właściwym obiektem. Może kontrolować dostęp, opóźniać utworzenie instancji, dodawać autoryzację albo rejestrować wywołania. Spring wykorzystuje proxy między innymi w AOP i transakcjach, dlatego warto rozumieć ten wzorzec, nawet jeśli nie tworzymy go ręcznie.
Najczęstsza pułapka polega na tym, że wywołanie metody wewnątrz tej samej klasy może ominąć proxy. W efekcie adnotacja odpowiedzialna za transakcję lub logowanie nie zadziała tak, jak oczekujemy. To dobry przykład, że znajomość nazwy wzorca nie wystarcza bez zrozumienia mechanizmu.
Wzorce behawioralne oddzielają decyzje od wykonania
Strategy dla wymiennych algorytmów
Strategy wydziela algorytm do osobnego obiektu. Wyobraźmy sobie naliczanie kosztu dostawy, które zależy od kraju, rodzaju przesyłki i aktualnej promocji.
interface ShippingCost {
BigDecimal calculate(Order order);
}
final class StandardShipping implements ShippingCost {
public BigDecimal calculate(Order order) {
return new BigDecimal("12.99");
}
}
final class FreeShipping implements ShippingCost {
public BigDecimal calculate(Order order) {
return BigDecimal.ZERO;
}
}
final class Checkout {
private final ShippingCost shippingCost;
Checkout(ShippingCost shippingCost) {
this.shippingCost = shippingCost;
}
BigDecimal total(Order order) {
return order.subtotal().add(shippingCost.calculate(order));
}
}
Nową regułę można dodać bez przepisywania klasy Checkout. W prostych przypadkach strategią może być nawet lambda, jeśli interfejs ma jedną metodę. Strategy szczególnie dobrze wspiera testy, ponieważ każdą regułę można sprawdzać osobno.
Observer przy reagowaniu na zdarzenia
Observer pozwala powiadamiać wiele obiektów o zmianie stanu. W aplikacji webowej zdarzenie „zamówienie opłacone” może uruchomić wysłanie e-maila, aktualizację magazynu i zapis informacji analitycznej.
W większych systemach klasyczny Observer bywa zastępowany zdarzeniami aplikacyjnymi albo brokerem wiadomości. Trzeba wtedy świadomie zdecydować, czy odbiorca ma zareagować natychmiast, czy asynchronicznie. Im więcej ukrytych subskrypcji, tym trudniej odtworzyć pełny przepływ, dlatego zdarzenia powinny mieć jasne nazwy i dokumentowany kontrakt.
Command, gdy operację trzeba przechować lub cofnąć
Command zamienia operację w obiekt. Dzięki temu można umieścić polecenie w kolejce, wykonać je później, logować albo zaimplementować cofanie zmian.
interface Command {
void execute();
}
final class SendEmailCommand implements Command {
private final EmailService emailService;
private final String recipient;
SendEmailCommand(EmailService emailService, String recipient) {
this.emailService = emailService;
this.recipient = recipient;
}
public void execute() {
emailService.send(recipient);
}
}
Nie każdy serwis potrzebuje pełnego Commanda. Jeśli operacja jest jednorazowym wywołaniem prostej metody, dodatkowa klasa może tylko zwiększyć narzut. Sięgam po ten wzorzec wtedy, gdy ważne są kolejkowanie, historia, ponawianie albo cofanie.
Jak dobrać wzorzec do problemu, a nie odwrotnie
Najpierw opisuję problem zwykłymi słowami. Czy chodzi o tworzenie obiektów, wymianę algorytmu, integrację z obcą biblioteką, czy może o komunikację między modułami? Dopiero później szukam wzorca, który pasuje do tej sytuacji.
| Problem | Wzorzec, od którego warto zacząć | Na co uważać |
|---|---|---|
| Wiele opcjonalnych parametrów | Builder | Nie stosować do bardzo prostych obiektów |
| Różne implementacje tej samej usługi | Strategy lub Factory | Nie przenosić wszystkich warunków do jednej fabryki |
| Niezgodny interfejs zewnętrznej biblioteki | Adapter | Nie przeciekać typami dostawcy do domeny |
| Dodatkowe zachowania wokół istniejącego obiektu | Decorator lub Proxy | Nie tworzyć zbyt wielu warstw opakowań |
| Złożona operacja wykonująca kilka kroków | Facade | Facade nie powinien przejąć całej logiki biznesowej |
W projektach Java często spotykam połączenie kilku wzorców. Factory tworzy właściwą strategię, Facade wywołuje kilka usług, a Decorator dodaje logowanie. Samo użycie wielu nazw nie świadczy jednak o dobrej architekturze. Dobre rozwiązanie zmniejsza złożoność, a nie tylko zmienia jej miejsce.
Przed wdrożeniem sprawdzam cztery rzeczy. Czy wzorzec ograniczy zależności? Czy ułatwi testowanie? Czy nowa funkcjonalność będzie wymagała dodania klasy zamiast modyfikowania wielu istniejących miejsc? I czy zespół bez problemu zrozumie tę konstrukcję za kilka miesięcy?
Najczęstsze błędy podczas stosowania wzorców
Traktowanie wzorca jak obowiązkowej recepty
Największym błędem jest dodawanie abstrakcji przed pojawieniem się realnego problemu. Klasa z jedną implementacją nie zawsze potrzebuje fabryki, a prosty konstruktor nie wymaga Buildera tylko dlatego, że taki wzorzec istnieje.
Zbyt wczesne projektowanie pod przyszłe wymagania prowadzi do kodu trudniejszego w utrzymaniu. Najpierw prostota, później abstrakcja to zasada, która w praktyce oszczędza sporo czasu.
Mylenie wzorca z frameworkiem
Wzorzec opisuje strukturę rozwiązania, natomiast framework dostarcza gotowy mechanizm działania. Spring może używać proxy, fabryk i obserwatorów, ale korzystanie ze Springa nie oznacza automatycznie poprawnego zastosowania tych wzorców w kodzie aplikacji.
Podobnie biblioteka nie naprawi złego podziału odpowiedzialności. Jeśli jedna klasa obsługuje walidację, zapis do bazy, wysyłkę e-maila i integrację z płatnościami, samo dodanie Facade niewiele zmieni. Najpierw trzeba rozdzielić odpowiedzialności.
Przeczytaj również: Mikrofrontendy - Czy na pewno ich potrzebujesz?
Ignorowanie testowalności
Wzorzec powinien ułatwiać testy, a nie wymuszać tworzenie skomplikowanych atrap. Gdy Singleton utrudnia podstawienie konfiguracji, a ukryty Observer powoduje trudne do przewidzenia skutki uboczne, jest to sygnał ostrzegawczy.
Najłatwiej testuje się małe klasy zależne od interfejsów. Dlatego przy wyborze rozwiązania patrzę nie tylko na diagram klas, ale też na to, jak napiszę test jednostkowy dla głównego przypadku i błędu.
Jak uczyć się wzorców na praktycznych przykładach
Najlepszą metodą jest wzięcie niewielkiego projektu, na przykład sklepu internetowego, systemu rezerwacji albo aplikacji do zarządzania zadaniami. Następnie warto świadomie zaimplementować kilka problemów: tworzenie obiektów przez Factory, naliczanie ceny przez Strategy, integrację z zewnętrznym API przez Adapter oraz obsługę zdarzeń przez Observer.
Przy każdym wzorcu zapisuję trzy informacje. Jaki problem rozwiązuje? Jakie zależności usuwa? Co stanie się z kodem, jeśli wymaganie się zmieni? Takie pytania uczą więcej niż zapamiętywanie diagramów UML.
Do pracy zawodowej szczególnie przydaje się rozumienie Buildera, Factory, Strategy, Adaptera, Decoratora, Facade i Proxy. Nie dlatego, że są zawsze najlepsze, lecz dlatego, że często pojawiają się w bibliotekach, frameworkach i istniejących aplikacjach.
Podczas rozmowy rekrutacyjnej dobrze umieć pokazać nie tylko przykład użycia, ale również koszt rozwiązania. Dojrzała odpowiedź brzmi czasem „tu nie użyłbym wzorca, bo prosty kod jest czytelniejszy”. Taka ocena zwykle mówi o umiejętnościach projektowych więcej niż wyrecytowana lista wszystkich wzorców GoF.
Największą wartość daje świadome upraszczanie kodu
Wzorce projektowe w Javie są językiem opisywania powtarzalnych problemów, a nie kolekcją obowiązkowych konstrukcji. Najpierw rozpoznaję zmianę, która będzie zachodzić w systemie, potem izoluję ją za pomocą odpowiedniej abstrakcji i dopiero na końcu nadaję rozwiązaniu nazwę.
Jeżeli wzorzec pomaga oddzielić decyzję od wykonania, ukryć zewnętrzną zależność albo uprościć testy, prawdopodobnie ma sens. Jeśli dodaje warstwy bez realnej korzyści, prostszy kod będzie lepszym wyborem.
Najlepszy sposób nauki to regularne porównywanie dwóch wersji tego samego rozwiązania i sprawdzanie, która łatwiej przyjmuje kolejną zmianę. Właśnie wtedy wzorce przestają być teorią, a zaczynają działać jako praktyczne narzędzia projektowania.