Praca w IT

Design Patterns – wzorce projektowe, które powinien znać każdy developer

Design Patterns

Kod, który działa, to jedno. Kod, który za pół roku zrozumie ktoś inny (albo ty sam/a po urlopie) – to zupełnie inna historia. Właśnie tu wchodzą design patterns, czyli wzorce projektowe: sprawdzone, powtarzalne rozwiązania typowych problemów w projektowaniu oprogramowania.

Nie są to gotowe fragmenty kodu do wklejenia, tylko schematy myślenia – wypracowane przez lata praktyki sposoby na to, jak zorganizować klasy i obiekty, żeby system był elastyczny, testowalny i łatwy w utrzymaniu. Zdecydowana większość doświadczonych programistów, niezależnie od stosu technologicznego, posługuje się nimi na co dzień – często nawet nieświadomie. W tym artykule przechodzimy przez najważniejsze wzorce projektowe, pokazujemy design patterns – przykłady w Javie oraz tłumaczymy, kiedy dany wzorzec ma sens, a kiedy jest tylko niepotrzebnym komplikowaniem prostego problemu.

Skąd wzięły się wzorce projektowe

Historia design patterns sięga 1994 roku i książki Design Patterns: Elements of Reusable Object-Oriented Software, napisanej przez czwórkę autorów znaną dziś jako Gang of Four (GoF): Ericha Gammę, Richarda Helma, Ralpha Johnsona i Johna Vlissidesa. Opisali w niej 23 klasyczne wzorce, podzielone na trzy kategorie: kreacyjne, strukturalne i behawioralne (czynnościowe).

Od tamtej pory katalog wzorców rozrósł się – doszły m.in. wzorce architektoniczne (MVC, MVVM, Repository), wzorce współbieżności czy wzorce charakterystyczne dla frameworków (np. Dependency Injection w Spring). Sama koncepcja jednak pozostaje niezmienna: zamiast wymyślać koło na nowo, sięgamy po nazwane, przetestowane przez tysiące projektów rozwiązanie.

Dlaczego to ważne? Bo wzorce projektowe to też wspólny język w zespole. Kiedy jeden developer mówi drugiemu „tu przyda się Observer”, obaj od razu wiedzą, o jakiej strukturze mowa – bez rysowania diagramów i tłumaczenia od zera.

Trzy kategorie wzorców GoF

Wzorce kreacyjne (Creational Patterns)

Odpowiadają na pytanie: jak tworzyć obiekty, żeby proces tworzenia był elastyczny i nie wiązał kodu na sztywno z konkretnymi klasami. Do tej grupy należą m.in. Singleton, Factory Method, Abstract Factory, Builder i Prototype.

Wzorce strukturalne (Structural Patterns)

Skupiają się na tym, jak łączyć klasy i obiekty w większe struktury, zachowując przy tym elastyczność i minimalizując zależności. Tu znajdziemy Adapter, Decorator, Facade, Composite czy Proxy.

Wzorce behawioralne (Behavioral Patterns)

Dotyczą komunikacji i podziału odpowiedzialności między obiektami – jak obiekty współpracują, nie znając nawzajem swojej implementacji. Klasyka to Observer, Strategy, Command, State czy Chain of Responsibility.

Poniżej omawiamy te wzorce, które w praktyce pojawiają się najczęściej – zarówno w rozmowach rekrutacyjnych, jak i w realnym kodzie produkcyjnym.

Singleton – jeden obiekt, jedna instancja

Singleton gwarantuje, że dana klasa ma dokładnie jedną instancję w całej aplikacji, i udostępnia do niej globalny punkt dostępu. Klasyczne zastosowanie to np. konfiguracja aplikacji, pula połączeń do bazy danych czy logger.

java
public class ConfigManager {
    private static ConfigManager instance;
    private final Map<String, String> settings = new HashMap<>();

    private ConfigManager() {
        // prywatny konstruktor blokuje tworzenie kolejnych instancji
    }

    public static synchronized ConfigManager getInstance() {
        if (instance == null) {
            instance = new ConfigManager();
        }
        return instance;
    }

    public String get(String key) {
        return settings.get(key);
    }
}

Singleton bywa jednak krytykowany – i słusznie. Wprowadza globalny stan w aplikacji, co utrudnia testowanie jednostkowe (trudno podmienić instancję na mocka) i może prowadzić do ukrytych zależności między odległymi częściami kodu. W nowoczesnych aplikacjach, szczególnie tych opartych na frameworkach z Dependency Injection (jak Spring czy .NET Core), rolę Singletona najczęściej przejmuje kontener DI, który zarządza cyklem życia obiektów za nas – bezpieczniej i bardziej testowalnie.

Factory Method i Abstract Factory – oddelegowanie tworzenia obiektów

Factory Method przenosi logikę tworzenia obiektu do osobnej metody lub klasy, dzięki czemu kod korzystający z obiektu nie musi znać jego konkretnej implementacji – operuje na interfejsie lub klasie abstrakcyjnej.

java
interface PaymentMethod {
    void process(double amount);
}

class CardPayment implements PaymentMethod {
    public void process(double amount) {
        System.out.println("Płatność kartą: " + amount + " zł");
    }
}

class BlikPayment implements PaymentMethod {
    public void process(double amount) {
        System.out.println("Płatność BLIK: " + amount + " zł");
    }
}

class PaymentFactory {
    public static PaymentMethod create(String type) {
        return switch (type) {
            case "card" -> new CardPayment();
            case "blik" -> new BlikPayment();
            default -> throw new IllegalArgumentException("Nieznana metoda płatności: " + type);
        };
    }
}

Dzięki temu dodanie nowej metody płatności – np. Apple Pay – nie wymaga zmian w kodzie, który z płatności korzysta, tylko rozszerzenia fabryki. To bezpośrednie zastosowanie zasady otwarte-zamknięte (Open/Closed Principle) z SOLID.

Abstract Factory idzie o krok dalej – tworzy rodziny powiązanych obiektów (np. zestaw komponentów UI dla motywu jasnego i ciemnego), gwarantując ich spójność.

Builder – porządek przy tworzeniu złożonych obiektów

Kiedy obiekt ma wiele opcjonalnych pól, konstruktor z dziesięcioma parametrami staje się nieczytelny. Builder rozwiązuje ten problem, pozwalając budować obiekt krok po kroku.

java
public class JobOffer {
    private final String title;
    private final String company;
    private final Integer salaryMin;
    private final boolean remote;

    private JobOffer(Builder builder) {
        this.title = builder.title;
        this.company = builder.company;
        this.salaryMin = builder.salaryMin;
        this.remote = builder.remote;
    }

    public static class Builder {
        private String title;
        private String company;
        private Integer salaryMin;
        private boolean remote;

        public Builder title(String title) { this.title = title; return this; }
        public Builder company(String company) { this.company = company; return this; }
        public Builder salaryMin(int salary) { this.salaryMin = salary; return this; }
        public Builder remote(boolean remote) { this.remote = remote; return this; }

        public JobOffer build() {
            return new JobOffer(this);
        }
    }
}

JobOffer offer = new JobOffer.Builder()
        .title("Backend Developer")
        .company("Just Geek IT")
        .salaryMin(15000)
        .remote(true)
        .build();

W Javie ten wzorzec bywa dziś częściowo zastępowany przez rekordy (records) i biblioteki takie jak Lombok (@Builder), ale sama idea pozostaje fundamentem czytelnego API.

Observer – powiadamianie o zmianach stanu

Observer definiuje mechanizm subskrypcji: jeden obiekt (subject) informuje wielu innych (obserwatorów) o zmianach swojego stanu, bez potrzeby znajomości ich konkretnej implementacji. To fundament programowania zdarzeniowego – od nasłuchiwaczy kliknięć w interfejsach graficznych, przez systemy powiadomień, aż po reaktywne biblioteki jak RxJava.

java
interface Observer {
    void update(String status);
}

class RecruiterNotifier implements Observer {
    public void update(String status) {
        System.out.println("Rekruter powiadomiony: " + status);
    }
}

class Application {
    private final List<Observer> observers = new ArrayList<>();
    private String status;

    public void subscribe(Observer o) { observers.add(o); }

    public void setStatus(String status) {
        this.status = status;
        observers.forEach(o -> o.update(status));
    }
}

W praktyce w kodzie na co dzień Observer pojawia się m.in. w Spring (ApplicationEventPublisher), w JavaScripcie (nasłuchiwacze zdarzeń DOM) czy w architekturach event-driven, o których pisaliśmy szerzej przy okazji Event-Driven Architecture – architektury zdarzeniowej w systemach rozproszonych.

Strategy – wymienne algorytmy

Strategy pozwala zdefiniować rodzinę algorytmów, zamknąć każdy z nich w osobnej klasie i wymieniać je w trakcie działania programu, bez zmiany kodu, który z nich korzysta.

java
interface DiscountStrategy {
    double apply(double price);
}

class NoDiscount implements DiscountStrategy {
    public double apply(double price) { return price; }
}

class PercentageDiscount implements DiscountStrategy {
    private final double percentage;
    public PercentageDiscount(double percentage) { this.percentage = percentage; }
    public double apply(double price) { return price * (1 - percentage / 100); }
}

class Order {
    private DiscountStrategy discountStrategy = new NoDiscount();

    public void setDiscountStrategy(DiscountStrategy strategy) {
        this.discountStrategy = strategy;
    }

    public double calculateTotal(double price) {
        return discountStrategy.apply(price);
    }
}

To jeden z najczęściej stosowanych wzorców w systemach e-commerce, silnikach reguł biznesowych czy algorytmach sortowania/walidacji, gdzie logika biznesowa lubi się zmieniać.

Decorator – dokładanie funkcjonalności bez dziedziczenia

Decorator pozwala dodawać nowe zachowania do obiektu w czasie działania programu, opakowując go w kolejne „warstwy”, zamiast tworzyć rozrastające się drzewo klas dziedziczących.

java
interface Coffee {
    double cost();
    String description();
}

class SimpleCoffee implements Coffee {
    public double cost() { return 8.0; }
    public String description() { return "Kawa"; }
}

abstract class CoffeeDecorator implements Coffee {
    protected final Coffee wrapped;
    protected CoffeeDecorator(Coffee wrapped) { this.wrapped = wrapped; }
}

class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee wrapped) { super(wrapped); }
    public double cost() { return wrapped.cost() + 2.5; }
    public String description() { return wrapped.description() + " z mlekiem"; }
}

Coffee order = new MilkDecorator(new SimpleCoffee());

Decorator to dokładnie ten mechanizm, na którym oparte są np. strumienie wejścia/wyjścia w Javie (BufferedReader opakowujący FileReader) czy middleware w frameworkach webowych.

Adapter i Facade – porządkowanie zależności

Adapter „tłumaczy” jeden interfejs na drugi – przydaje się, gdy trzeba zintegrować bibliotekę zewnętrzną, której API nie pasuje do reszty systemu, bez modyfikowania jej kodu źródłowego.

Facade z kolei ukrywa złożoność wielu współpracujących klas za jednym, prostym interfejsem. Klasyczny przykład to klasa OrderFacade, która pod spodem koordynuje płatność, magazyn i wysyłkę, a dla reszty aplikacji udostępnia jedną metodę placeOrder(). To wzorzec, który świetnie sprawdza się przy projektowaniu API – więcej o samym projektowaniu interfejsów przeczytasz w artykule o REST API – czym jest i jak z niego korzystać.

Które wzorce warto znać na rozmowę rekrutacyjną

Fraza „singleton factory observer” nie bez powodu trafia na listę popularnych zapytań w wyszukiwarce – to trzy wzorce, które najczęściej pojawiają się na rozmowach kwalifikacyjnych dla developerów Java, C# czy JavaScript. Rekruterzy techniczni chętnie pytają też o różnicę między Factory Method a Abstract Factory, o wady Singletona oraz o to, kiedy zastosować Strategy zamiast rozbudowanego bloku if-else.

Warto pamiętać, że sama znajomość nazw wzorców to za mało – liczy się umiejętność rozpoznania problemu, który dany wzorzec rozwiązuje, i świadomość jego kosztów.

wzorce projektowe

Kiedy NIE stosować wzorców projektowych

To równie ważna część wiedzy o design patterns, co znajomość samych wzorców. Nadużywanie ich prowadzi do zjawiska nazywanego czasem „pattern overuse” albo żartobliwie golden hammer – gdy każdy problem próbujemy rozwiązać znanym wzorcem, nawet jeśli prostszy kod wystarczyłby w zupełności.

Kilka sygnałów ostrzegawczych:

  • Wprowadzasz interfejs i fabrykę dla klasy, która nigdy nie będzie miała drugiej implementacji.
  • Kod stał się trudniejszy do zrozumienia niż problem, który miał rozwiązać.
  • Wzorzec wprowadzasz „na zapas”, bo „może się przyda w przyszłości” – w praktyce często się nie przyda, a dług techniczny zostaje.

Dobrą praktyką jest trzymanie się zasady YAGNI (You Aren’t Gonna Need It) i sięganie po wzorzec dopiero wtedy, gdy realny problem – powtarzalność kodu, sztywne zależności, trudność w testowaniu – faktycznie się pojawi. To podejście spójne z zasadami opisanymi w naszym artykule o Clean Code – jak pisać czytelny i łatwy w utrzymaniu kod oraz z SOLID – 5 zasadami dobrego kodu obiektowego.

Wzorce projektowe poza Javą

Choć w tym artykule większość przykładów design patterns pokazujemy w Javie (bo to język, w którym GoF i klasyczna terminologia zakorzeniły się najmocniej), te same koncepcje działają identycznie w Kotlinie, C#, Pythonie, TypeScrypcie czy Go – zmienia się tylko składnia, nie idea. 

W językach z funkcjami jako obywatelami pierwszej kategorii (Python, JavaScript, Kotlin) niektóre wzorce, jak Strategy czy Observer, można zapisać znacznie krócej – bez tworzenia osobnych klas, wystarczy przekazać funkcję lub lambdę.

Wzorce projektowe a architektura systemów

Warto odróżnić wzorce projektowe (design patterns) od wzorców architektonicznych – te pierwsze dotyczą relacji między klasami i obiektami wewnątrz modułu, te drugie opisują organizację całego systemu. MVC, mikroserwisy czy Domain-Driven Design to inny poziom abstrakcji, choć często wykorzystują te same klocki – np. Repository (wariant Facade) czy Command w architekturach opartych na kolejkach zdarzeń.

Podsumowanie

Wzorce projektowe to nie akademicka ciekawostka, tylko praktyczne narzędzie, które ułatwia komunikację w zespole i pozwala unikać powtarzalnych błędów architektonicznych. Singleton, Factory, Observer, Strategy czy Decorator pojawiają się – często niewidocznie – w bibliotekach i frameworkach, z których korzystasz codziennie.

Kluczem nie jest znajomość wszystkich 23 wzorców GoF na pamięć, tylko wyczucie, kiedy dany problem faktycznie wymaga wzorca, a kiedy najlepszym rozwiązaniem jest po prostu prosty, czytelny kod. 

Które wzorce projektowe najczęściej wykorzystujesz w swojej codziennej pracy – i czy zdarzyło ci się kiedyś „przekombinować” projekt niepotrzebnym wzorcem?

Redaktorka, dziennikarka i copywriterka, autorka wywiadów, tekstów eksperckich, newsów poświęconych branży IT (i nie tylko).

Podobne artykuły