Gdy aplikacja zaczyna rosnąć, podobne problemy wracają w różnych miejscach: tworzenie obiektów, obsługa wielu wariantów algorytmu, integracja z zewnętrznym API czy dokładanie logowania bez przepisywania istniejącego kodu. Wzorce projektowe pomagają nadać tym problemom powtarzalną strukturę, ale tylko wtedy, gdy wynikają z realnej potrzeby. Pokażę, które rozwiązania są szczególnie użyteczne w C# i .NET, jak je dobierać oraz kiedy lepiej z nich zrezygnować.
Najważniejsze decyzje przy stosowaniu wzorców w .NET
- Wzorzec rozwiązuje problem projektowy, a nie zastępuje znajomości języka ani dobrego podziału odpowiedzialności.
- Factory, Strategy, Decorator i Adapter należą do najpraktyczniejszych rozwiązań w aplikacjach C#.
- Dependency Injection upraszcza wymianę implementacji, testowanie i zarządzanie cyklem życia usług.
- Singleton nie jest domyślną receptą na współdzielenie obiektu i często utrudnia testy.
- Najlepszy moment na użycie wzorca pojawia się wtedy, gdy konkretny problem zaczyna się powtarzać.
Co naprawdę daje wzorzec, a czego nie załatwi
Wzorzec projektowy to sprawdzony sposób organizowania klas, obiektów i ich współpracy w sytuacji, która regularnie pojawia się podczas tworzenia oprogramowania. Nie jest gotowym fragmentem kodu do bezrefleksyjnego skopiowania. To raczej nazwany pomysł na rozwiązanie problemu, dzięki któremu zespół szybciej rozumie intencję autora.
Jeżeli mówię, że w danym miejscu zastosowano strategię, od razu wiadomo, że istnieje kilka wymiennych sposobów wykonania tej samej operacji. Gdy pojawia się dekorator, można oczekiwać, że dodatkowa funkcjonalność została dołączona bez modyfikowania podstawowej implementacji. Taki wspólny język ma dużą wartość podczas przeglądów kodu, szczególnie w większych zespołach.
Nie należy jednak mylić wzorca z biblioteką, frameworkiem ani stylem architektury. Clean Architecture, architektura heksagonalna i CQRS opisują organizację większych części systemu, natomiast Factory czy Strategy najczęściej rozwiązują problem na poziomie kilku klas. Jedno rozwiązanie może wspierać drugie, ale nie są tym samym.
| Kategoria | Główne pytanie | Przykłady |
|---|---|---|
| Kreacyjne | Jak bezpiecznie tworzyć obiekty? | Factory, Builder, Singleton |
| Strukturalne | Jak łączyć klasy i rozszerzać ich możliwości? | Adapter, Decorator, Facade |
| Behawioralne | Jak organizować zachowanie i komunikację? | Strategy, Observer, Command, State |
W praktyce nie uczę się całego katalogu na pamięć. Znacznie bardziej opłaca się rozpoznawać symptomy problemu. Jeśli klasa ma kilka rozgałęzień zależnych od typu płatności, prawdopodobnie potrzebuje strategii albo fabryki. Jeśli każda usługa tworzy własnego klienta HTTP, problem dotyczy zależności i cyklu życia obiektów, a nie braku kolejnego interfejsu.

Najbardziej użyteczne rozwiązania w codziennym C#
Factory, gdy sposób tworzenia obiektu zaczyna żyć własnym życiem
Fabryka oddziela kod korzystający z obiektu od szczegółów jego tworzenia. Dobrze sprawdza się wtedy, gdy konkretny typ zależy od konfiguracji, danych wejściowych albo wybranego kanału. Przykładem może być wybór klienta do wysyłki powiadomień przez e-mail, SMS lub usługę zewnętrzną.
public interface INotificationSender
{
Task SendAsync(string recipient, string message);
}
public interface INotificationSenderFactory
{
INotificationSender Create(NotificationChannel channel);
}Największą korzyścią jest zamknięcie decyzji w jednym miejscu. Kod biznesowy nie musi znać klas `EmailNotificationSender` ani `SmsNotificationSender`. Nie warto jednak tworzyć rozbudowanej fabryki dla dwóch prostych konstruktorów, jeśli zwykły konstruktor lub rejestracja w kontenerze DI rozwiązuje problem czytelniej.
Strategy, gdy algorytm ma kilka równorzędnych wersji
Strategia izoluje warianty zachowania za wspólnym interfejsem. Wyobraźmy sobie wyliczanie rabatu, ocenę ryzyka transakcji albo wybór sposobu kompresji pliku. Zamiast jednej metody z długim łańcuchem `if` każda reguła trafia do osobnej klasy.
public interface IDiscountStrategy
{
decimal Calculate(Order order);
}
public sealed class VipDiscount : IDiscountStrategy
{
public decimal Calculate(Order order) => order.Total * 0.15m;
}To rozwiązanie ułatwia testy, bo każdą regułę można sprawdzić niezależnie. W aplikacjach ASP.NET Core strategie często są rejestrowane przez Dependency Injection, czyli mechanizm dostarczania zależności z zewnątrz zamiast tworzenia ich wewnątrz klasy. Według dokumentacji Microsoftu kontener DI jest w .NET dostępny jako część podstawowego stosu, razem między innymi z konfiguracją i logowaniem.
Decorator, gdy trzeba dodać funkcję bez naruszania rdzenia
Dekorator opakowuje istniejącą usługę i dodaje do niej zachowanie, na przykład logowanie, cache, pomiar czasu albo ponawianie wywołania. Dzięki temu podstawowa klasa nie musi wiedzieć, czy jej wynik będzie zapisywany w pamięci podręcznej.
public sealed class CachedProductService : IProductService
{
private readonly IProductService inner;
public CachedProductService(IProductService inner)
{
this.inner = inner;
}
public async Task GetAsync(int id)
{
// odczyt z cache lub wywołanie inner.GetAsync(id)
return await inner.GetAsync(id);
}
} Największą pułapką jest niekontrolowane zagnieżdżanie dekoratorów. Gdy jedno wywołanie przechodzi przez cache, logowanie, retry, autoryzację i metryki, trzeba jasno ustalić kolejność. Każda warstwa powinna mieć jedną czytelną odpowiedzialność, inaczej dekorator staje się tylko trudnym do śledzenia łańcuchem pośredników.
Adapter i Facade, gdy systemy nie mówią tym samym językiem
Adapter tłumaczy jeden interfejs na inny. Jest bardzo przydatny przy integracji z dostawcą płatności, systemem ERP, usługą Azure albo biblioteką, której nie chcemy ujawniać w całej aplikacji. Własny interfejs chroni resztę kodu przed zmianami w zewnętrznym SDK.
Fasada działa szerzej. Udostępnia prosty punkt wejścia do kilku złożonych komponentów, na przykład do procesu rejestracji użytkownika obejmującego zapis w bazie, wysłanie wiadomości i utworzenie profilu. Adapter tłumaczy interfejs, a fasada upraszcza współpracę z całym podsystemem.
Builder, gdy konstruktor przestaje być czytelny
Builder pomaga tworzyć obiekty z wieloma opcjonalnymi ustawieniami. W C# część jego zastosowań przejęły inicjalizatory obiektów, rekordy i nazwane argumenty, dlatego nie warto używać go automatycznie. Nadal ma sens przy złożonych regułach walidacji, wieloetapowym tworzeniu konfiguracji albo budowaniu zapytań.
Jeśli obiekt ma dwa wymagane parametry i jedno ustawienie opcjonalne, builder będzie prawdopodobnie przerostem formy nad treścią. Gdy jednak konstruktor przyjmuje 8-10 parametrów, a ich kolejność łatwo pomylić, osobny sposób budowania może wyraźnie poprawić bezpieczeństwo kodu.
Jak dobrać rozwiązanie do konkretnego problemu
Najpierw opisuję problem bez używania nazw wzorców. To proste ćwiczenie często pokazuje, że potrzebna jest zwykła refaktoryzacja, a nie rozbudowana architektura. Dopiero później sprawdzam, który schemat najlepiej pasuje do obserwowanego napięcia w kodzie.
- Znajdź powtarzalność i określ, co dokładnie się zmienia.
- Oddziel część stałą od wariantów, reguł lub zależności zewnętrznych.
- Wybierz najmniejszą abstrakcję, która rozwiązuje problem.
- Napisz test pokazujący oczekiwane zachowanie.
- Sprawdź, czy nowa struktura ułatwia kolejną zmianę, a nie tylko zwiększa liczbę klas.
| Objaw w kodzie | Rozwiązanie do rozważenia | Na co uważać |
|---|---|---|
| Długi wybór typu przez `if` lub `switch` | Strategy albo Factory | Nie rozdzielaj logiki, która zmienia się tylko raz |
| Wiele dodatkowych operacji wokół jednej usługi | Decorator | Ustal kolejność i odpowiedzialność warstw |
| Obce API przecieka do domeny | Adapter | Nie kopiuj całego zewnętrznego modelu do własnego interfejsu |
| Trudny do czytania konstruktor | Builder lub obiekt opcji | Nie twórz buildera dla prostego obiektu |
| Klasy tworzą zależności przez `new` | Dependency Injection | Nie rejestruj wszystkiego jako singleton |
Ważna jest też kolejność refaktoryzacji. Najpierw zapewniam testy dla obecnego zachowania, później wydzielam interfejs lub klasę, a dopiero na końcu zmieniam sposób rejestracji zależności. Wzorzec powinien zmniejszać koszt kolejnej zmiany. Jeśli po jego wprowadzeniu trzeba edytować więcej plików niż wcześniej, prawdopodobnie abstrakcja jest przedwczesna.
Jak wzorce wspierają architekturę aplikacji .NET
W większym systemie pojedyncze rozwiązania zaczynają tworzyć spójną architekturę. Adaptery oddzielają domenę od zewnętrznych usług, fabryki kontrolują tworzenie obiektów, strategie izolują reguły biznesowe, a dekoratory dodają przekrojowe funkcje. Taki podział ogranicza liczbę miejsc, które trzeba zmienić po wymianie dostawcy lub sposobu działania.
Dobrym przykładem jest aplikacja wykorzystująca kilku dostawców modeli AI. Interfejs może opisywać operację generowania odpowiedzi, adapter tłumaczyć API konkretnego dostawcy, strategia wybierać model zależnie od kosztu lub jakości, a dekorator rejestrować czas i liczbę tokenów. Każdy element rozwiązuje inny problem, więc nie trzeba budować jednej klasy odpowiedzialnej za wszystko.
Podobnie działa warstwa dostępu do płatności. Kod domenowy nie powinien znać szczegółów żądań HTTP, formatów odpowiedzi ani wyjątków konkretnego operatora. Adapter przejmuje tę wiedzę, a aplikacja pracuje na własnych pojęciach, takich jak `PaymentResult` czy `PaymentStatus`.
Dependency Injection jest tutaj szczególnie praktyczne, bo obiekt otrzymuje zależności w konstruktorze i nie tworzy ich samodzielnie. Dzięki temu w teście można podać atrapę, a w produkcji prawdziwą implementację. W nowoczesnym .NET kontener zarządza także cyklem życia usług, między innymi rozróżniając rejestracje typu transient, scoped i singleton.
Nie oznacza to, że każda aplikacja potrzebuje pełnej architektury heksagonalnej. Dla małego narzędzia konsolowego dodatkowe porty i adaptery mogą tylko zasłonić prosty przepływ. Skala domeny, liczba integracji i tempo zmian powinny decydować o poziomie abstrakcji.
Najczęstsze błędy i koszty złych abstrakcji
Używanie wzorca dlatego, że jest popularny
Najczęstszy błąd polega na dopasowywaniu problemu do poznanego wzorca. Programista uczy się Singletona, więc zaczyna traktować go jako uniwersalny sposób na współdzielenie obiektów. Tymczasem często wystarczy odpowiednia rejestracja usługi w kontenerze DI albo zwykłe przekazanie zależności.
Singleton jako ukryty stan globalny
Singleton może mieć sens dla bezstanowej, bezpiecznej współbieżnie usługi, której jedna instancja jest faktycznie wystarczająca. Nie powinien jednak przechowywać przypadkowego stanu użytkownika, danych żądania ani zmiennych kolekcji modyfikowanych bez synchronizacji.
W praktyce singletony utrudniają testy, bo jeden test może zmienić stan widoczny dla następnego. Z tego powodu szczególnie ostrożnie podchodzę do singletonów zawierających cache, konfigurację zmienianą w czasie działania albo obiekty zależne od kontekstu żądania.
Interfejs dla każdej klasy
Sam fakt dodania interfejsu nie czyni projektu elastycznym. Jeśli istnieje tylko jedna implementacja, nie ma realnego punktu wymiany, a interfejs może być wyłącznie dodatkowym plikiem do utrzymania. Abstrakcja ma wartość wtedy, gdy chroni granicę, ułatwia testowanie lub przewiduje uzasadnioną zmianę.
Repository jako warstwa bez celu
Repository bywa użyteczne, gdy ukrywa złożone zapytania, łączy kilka źródeł danych albo przedstawia domenowy sposób wyszukiwania. Nie zawsze ma sens jako mechaniczne opakowanie każdej metody ORM. Jeśli klasa tylko przekazuje wywołania do `DbSet`, może dodać więcej ceremonii niż wartości.
Przeczytaj również: Adapter pattern w C# - jak łączyć niezgodne interfejsy?
Nadmiar klas i pośredników
Po zastosowaniu kilku wzorców kod może wyglądać profesjonalnie, ale nadal być trudny do zrozumienia. Gdy prosta operacja przechodzi przez fabrykę, mediator, fasadę, trzy dekoratory i repozytorium, koszt poznawczy rośnie. Czytelność przepływu jest ważniejsza od liczby zastosowanych technik.
Jak rozwijać tę wiedzę bez tworzenia sztucznych projektów
Najlepiej zacząć od istniejącej aplikacji i znaleźć jeden fragment, który regularnie sprawia problemy. Może to być wybór dostawcy, wielki `switch`, powtarzające się logowanie albo klasa, której konstruktor przyjmuje zbyt wiele parametrów.
Wydziel małą zmianę, dodaj test i porównaj kod przed oraz po refaktoryzacji. Jeżeli nowa struktura ułatwiła dodanie kolejnego wariantu i nie ukryła logiki za nadmiarem abstrakcji, wzorzec spełnił swoje zadanie.
Nie chodzi o znajomość wszystkich nazw z katalogu. Największą korzyść daje umiejętność rozpoznania, co się zmienia, kto powinien za to odpowiadać i jak odizolować resztę systemu. Nazwa wzorca przychodzi dopiero później i służy głównie temu, by sprawniej rozmawiać o rozwiązaniu.
