Wzorce projektowe mają sprawić, że Twój kod będzie czystszy, bardziej elastyczny i łatwiejszy w utrzymaniu. Tak przynajmniej obiecują książki i kursy. Mam na ten temat inne zdanie. Przez lata pracy z .NET-em widziałem więcej projektów zepsutych przez wzorce niż przez ich brak. Fabryki, które niczego nie produkują. Repozytoria opakowane w repozytoria. 7 warstw abstrakcji, żeby zapisać 1 rekord w bazie.
Żeby było jasne od początku: nie twierdzę, że wzorce projektowe są złe. Twierdzę, że są nadużywane. A w nowoczesnym C# wiele z nich straciło rację bytu, bo język i framework rozwiązują te same problemy same - często w 1 linijce.
W tym artykule pokażę Ci na konkretnym kodzie, które wzorce najczęściej szkodzą w projektach .NET: Singleton, generyczne Repository nad EF Core, Strategy, Factory i Observer - i co stosować zamiast nich. Do tego szybki przegląd 5 kolejnych wzorców, które C# już dawno wchłonął, realny koszt nadmiarowych abstrakcji, sytuacje, w których wzorce naprawdę mają sens, i prosta zasada, która uchroni Cię przed większością tych problemów.
Wersje C# i .NET: przykłady korzystają z nowoczesnej składni - switch expression (C# 8), rekordów i with (C# 9), required (C# 11) oraz primary constructors (C# 12). Wszystko to jest dostępne w .NET 8, 9 i 10. Na starszych wersjach część zamienników (np. rekordy z with) nie zadziała, ale sama idea się nie zmienia.
Skąd się wzięły wzorce projektowe? Rok 1994 i Gang of Four
Żeby zrozumieć, dlaczego wzorce tak często szkodzą w dzisiejszym .NET, trzeba cofnąć się w czasie. Do roku 1994. Windows 95 jeszcze nie istnieje, a internet dopiero raczkuje.
Wtedy właśnie 4 autorów - Erich Gamma, Richard Helm, Ralph Johnson i John Vlissides, znani jako Gang of Four - wydaje książkę "Design Patterns: Elements of Reusable Object-Oriented Software". Opisują w niej 23 wzorce, które na dekady stały się biblią programowania obiektowego.
Jest jednak jeden haczyk, o którym mało kto pamięta: przykłady w tej książce są napisane w C++ i Smalltalku. C# 1.0 wyjdzie dopiero w 2002 roku. Kiedy powstawały wzorce, nie było jeszcze C#/.NET i wygód, do których dziś jesteśmy przyzwyczajeni:
• delegatów i eventów,
• generyków,
• LINQ,
• async/await,
• wbudowanego dependency injection we frameworku,
• rekordów i pattern matchingu.
Autorzy rozwiązywali problemy w realiach C++ i Smalltalka. Innymi słowy: część wzorców to protezy na braki języka. A dzisiejszy C# wiele z tych problemów rozwiązuje sam. Zobaczmy to na 5 konkretnych przykładach.
Wzorzec #1: Singleton - zamień go na AddSingleton
Zaczynamy od najsłynniejszego wzorca świata. I moim zdaniem najbardziej toksycznego. Tak wygląda klasyka z podręcznika: prywatny konstruktor, statyczne pole i lock, żeby całość była thread-safe. Każdy z nas to kiedyś napisał. Ja też.
public sealed class Logger
{
private static Logger? _instance;
private static readonly object _lock = new();
private Logger() { }
public static Logger Instance
{
get { lock (_lock) { return _instance ??= new Logger(); } }
}
}Problem? Razem z tym kodem dostajesz cały pakiet:
• Globalny stan. Instancja jest dostępna z każdego miejsca w aplikacji, więc każdy może jej użyć i każdy może od niej zależeć.
• Ukryte zależności. Patrząc na konstruktor klasy, nie widzisz, że gdzieś w środku woła ona Logger.Instance. Dowiadujesz się o tym dopiero, czytając cały kod.
• Trudniejsze testy jednostkowe. Nie podmienisz instancji mockiem, bo klasa sama sięga po statyczne pole.
• Thread-safety na Twojej głowie. Ręczna inicjalizacja, lock, volatile, double-check - to wszystko musisz ogarnąć sam.
A teraz nowoczesny .NET. Wystarczy 1 linijka w kontenerze DI:
builder.Services.AddSingleton<ILogger, FileLogger>();
/* A tam, gdzie go potrzebujesz: */
public class OrderService(ILogger logger)
{
/* zależność jawna, testowalna,
cyklem życia zarządza kontener DI */
}Dostajesz tę samą gwarancję - 1 instancja na całą aplikację. Tylko że tym razem zależność jest jawna w konstruktorze, w testach podmieniasz ją normalnie, jak każdą inną, a cyklem życia (lifetime) zarządza framework, a nie Ty. Ręcznie pisany singleton? W większości aplikacji .NET nie ma po co po niego sięgać.
Wzorzec #2: generyczne Repository nad EF Core
Numer 2 to mój ulubiony grzech w projektach .NET: generyczne repozytorium nakładane na Entity Framework Core. Znasz to na pewno:
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}GetById, GetAll, Add, Update, Delete. Wygląda profesjonalnie, prawda? Jest tylko jeden problem. Dokumentacja Microsoftu w opisie klasy DbContext mówi wprost: "DbContext łączy w sobie wzorce Unit of Work i Repository".
Abstrakcja nad abstrakcją. Skoro DbContext już jest repozytorium, to pisząc IRepository<T>, często tworzysz abstrakcję... nad abstrakcją. Opakowujesz repozytorium w repozytorium. A potem dziwisz się, że prosty odczyt robi się ciężki.
Co przy tym tracisz? Chowając DbContext za 5 generycznymi metodami, odcinasz się od tego, co w EF Core najlepsze:
• Include() - wygodne ładowanie powiązanych encji,
• Select() - projekcje prosto do DTO, bez pobierania całych encji,
• AsNoTracking() - szybkie odczyty bez śledzenia zmian,
• IQueryable - swobodne składanie zapytań tam, gdzie ich potrzebujesz.
Bez repozytorium pośrodku wszystko to masz w 1 czytelnym zapytaniu:
var users = await _db.Users
.AsNoTracking()
.Where(u => u.IsActive)
.Select(u => new UserListItemDto(u.Id, u.Name, u.Orders.Count))
.ToListAsync(ct);Pobierasz dokładnie te dane, których potrzebuje ekran, bez śledzenia zmian i bez ciągnięcia całych encji. Przez GetAllAsync() z generycznego repozytorium tak się nie da - dostajesz całą listę i filtrujesz ją w pamięci albo dopisujesz kolejną metodę.
To samo repo pół roku później. I właśnie tu życie dopisuje ciąg dalszy. Potrzebujesz filtrowania. Potem sortowania. Potem stronicowania. I Twoje "czyste" repozytorium po pół roku wygląda tak:
public interface IUserRepository
{
Task<List<User>> GetActiveAsync();
Task<List<User>> GetActiveWithOrdersAsync();
Task<List<User>> GetActiveWithOrdersSortedAsync();
Task<List<User>> GetPremiumForReportAsync();
Task<List<User>> GetPremiumForReportPagedAsync();
/* ...i 47 kolejnych metod "pod konkretny ekran" */
}50 metod pod konkretne ekrany. To nie jest abstrakcja. To ściana.
Kiedy repozytorium MA sens. Czy repozytorium ma kiedykolwiek sens? Ma - w 3 sytuacjach:
• Dapper / czyste SQL. Nie masz wtedy DbContext, więc nie masz też gotowego repozytorium. Własna warstwa dostępu do danych jest jak najbardziej na miejscu.
• Naprawdę wymienne źródła danych. Dziś SQL, jutro zewnętrzne API - i to jest realny plan, a nie fantazja.
• DDD. Gdy domena nie może wiedzieć o infrastrukturze.
Ale "bo tak się wszędzie robi" - to nie jest powód.
Wzorzec #3: Strategy - switch expression zamiast 4 plików
Numer 3: Strategy. W prostych przypadkach C# zjadł ten wzorzec na śniadanie. Podręcznikowa wersja wygląda tak: interfejs IDiscountStrategy i osobna klasa na każdy rodzaj rabatu. Plus zwykle jeszcze fabryka, która wybiera odpowiednią strategię.
public interface IDiscountStrategy
{
decimal Apply(decimal price);
}
public class PremiumDiscount : IDiscountStrategy
{
public decimal Apply(decimal price) => price * 0.9m;
}
/* + RegularDiscount.cs, VipDiscount.cs, fabryka strategii... */Podsumujmy: 4 pliki, około 40 linii kodu, żeby policzyć 1 rabat. Serio?
To samo w nowoczesnym C# to switch expression. 6 linii:
var finalPrice = customer.Type switch
{
CustomerType.Premium => price * 0.9m,
CustomerType.Vip => price * 0.8m,
_ => price
};Cała logika na jednym ekranie, a nowy przypadek to 1 linijka. Nie skaczesz po plikach, żeby sprawdzić, ile rabatu dostaje klient VIP.
Delegat to strategia wbudowana w język. A jeśli naprawdę potrzebujesz podmieniać zachowanie w locie, masz delegaty. Delegat to strategia wbudowana w C# od pierwszej wersji. Func to tylko wygodny, późniejszy skrót (doszedł w .NET Framework 3.5):
Func<decimal, decimal> discount = GetFor(customer);Zmienna discount przechowuje zachowanie, które możesz przekazać dalej, podmienić w teście albo wybrać zależnie od klienta. Dokładnie to robi wzorzec Strategy - tylko bez interfejsu i osobnych klas.
Wzorzec #4: Factory - fabryki, które niczego nie produkują
Numer 4: fabryki. Wszędzie fabryki. Które niczego nie produkują. Klasyka gatunku: interfejs IUserFactory, klasa UserFactory, a w środku...
public interface IUserFactory
{
User Create(string name);
}
public class UserFactory : IUserFactory
{
public User Create(string name)
=> new User(name); /* ...i to wszystko */
}...new User. I to wszystko. Naprawdę wszystko. 2 pliki, żeby zawołać new.
Fabryka, która tylko woła konstruktor, nie daje Ci wzorca projektowego. Daje Ci szum: 2 dodatkowe pliki i zero korzyści. Do tego każdy, kto czyta kod, musi przejść przez interfejs i implementację, żeby odkryć, że w środku nie dzieje się nic ciekawego.
Kiedy fabryka MA sens. Fabryka broni się wtedy, gdy jest w niej realna logika:
• Realny wybór typu w runtime. Inna implementacja per klient, per konfiguracja, per feature flag.
• Złożone tworzenie z zależnościami. Gdy tworzony obiekt potrzebuje usług z kontenera DI.
• IHttpClientFactory. Najlepszy przykład, prosto z frameworka. Pomaga uniknąć problemów z wyczerpaniem socketów (gdy przy każdym żądaniu tworzysz nowy HttpClient) i z DNS-em (gdy jeden HttpClient żyje przez cały czas działania aplikacji i nie zauważa zmian adresów). To fabryka, która naprawdę zarządza czymś nietrywialnym.
Ale jeśli logiki wyboru nie ma - wystarczy new.
Wzorzec #5: Observer - słowo kluczowe event
Numer 5: Observer. Lista obserwatorów, metoda Subscribe, metoda Notify, pętla foreach po subskrybentach:
public class OrderService
{
private readonly List<IOrderObserver> _observers = new();
public void Subscribe(IOrderObserver o) => _observers.Add(o);
private void NotifyShipped(Order order)
{
foreach (var o in _observers)
o.OnOrderShipped(order);
}
}Implementujesz ręcznie coś, co C# ma wbudowane w język. Słowo kluczowe event: 1 linijka deklaracji, Invoke i gotowe.
public class OrderService
{
public event EventHandler<Order>? OrderShipped;
private void Ship(Order order)
=> OrderShipped?.Invoke(this, order);
}Subskrypcja to zwykłe orderService.OrderShipped += handler, a wypisanie: -= handler. Bez interfejsu IOrderObserver, bez listy i bez pętli. Eventy są w C# od pierwszej wersji, wydanej w 2002 roku. Dlatego prostego Observera w .NET najczęściej nie musisz pisać ręcznie.
Szybka runda: Iterator, Builder, Prototype, Command i Template Method
5 kolejnych wzorców, które język już dawno wchłonął. Krótko i na temat.
Iterator. Masz foreach. A własny iterator piszesz jednym yield return:
foreach (var order in orders) { /* ... */ }
yield return next; /* własny iterator? proszę bardzo */Builder. W wielu prostych przypadkach wystarczy object initializer:
var user = new User { Name = "Ala", Email = "ala@dev.pl" };Resztę dopinają słowo kluczowe required (kompilator nie pozwoli pominąć wymaganej właściwości) i porządne konstruktory.
Prototype. Dla rekordów masz słowo kluczowe with. Kopia z modyfikacją w 1 linii:
var kopia = zamowienie with { Status = Status.Wyslane };Command. Dla prostych przypadków wystarczy Action albo Func. Delegat reprezentuje operację bez dokładania osobnej klasy:
Action zapisz = () => repo.Save(order);Uczciwie: jeśli budujesz kolejkę poleceń albo undo, wtedy pełny wzorzec Command faktycznie ma sens.
Template Method. Gdy chodzi tylko o wstrzyknięcie jednego kroku algorytmu, zamiast dziedziczenia i metod abstrakcyjnych po prostu przekaż delegat jako parametr:
Process(order, validate: o => o.Total > 0);Koniec rundy. Widzisz wzór? W każdym z tych przypadków to, co w 1994 roku wymagało osobnej struktury klas, dziś jest po prostu elementem języka.
Prawdziwy koszt nadużywania wzorców
No dobrze, ale co to konkretnie znaczy, że nadużywanie wzorców psuje kod? Policzmy koszty.
Koszt #1: debugowanie przez warstwy. Wciskasz F12, żeby zobaczyć implementację... i lądujesz w interfejsie. I znowu. I znowu:
IOrderService → IOrderProcessor → IOrderHandler → IOrderValidator → OrderHandlerImpl
7 skoków F12, żeby znaleźć 1 linijkę logiki. Powodzenia o 3:00 w nocy na produkcji.
Koszt #2: interfejs z jedną implementacją. IUserService i UserService. IOrderService i OrderService. 2 razy więcej plików, często zero dodatkowej elastyczności. Uczciwie: jeśli mockujesz taki interfejs w testach, to jakiś zysk. Ale jeśli nie mockujesz? To czysty koszt.
Koszt #3: absurd. Dokąd to zmierza, najlepiej pokazuje prawdziwa klasa z frameworka Spring w Javie: AbstractSingletonProxyFactoryBean. Przeczytaj to sobie na głos. Nie idźmy tą drogą.
Abstrakcję opłacasz dziś. I najważniejsze: każdą warstwę abstrakcji opłacasz od razu. Więcej plików, dłuższy onboarding nowych osób, trudniejszy debugging - od pierwszego dnia. A zyski? Może. Kiedyś. Jeśli w ogóle.
Kiedy wzorce projektowe mają sens?
Żeby było jasne: nie mówię, że wzorce są złe. Mówię, że są nadużywane. Kiedy więc mają sens?
Po pierwsze: wspólny język zespołu. "Opakuj to w dekorator" - 5 sekund. Tłumaczenie tego samego bez nazwy wzorca - 5 minut. Wzorce to świetny słownik. Nawet jeśli nie zawsze świetny kod. Dlatego warto je znać, nawet jeśli w kodzie sięgasz po nie rzadko.
Po drugie: gdy problem istnieje DZIŚ. Nie "może kiedyś będziemy mieli drugą bazę danych", tylko realny problem, który masz w kodzie teraz. Na całą resztę jest prosta zasada: YAGNI - You Aren't Gonna Need It.
Po trzecie - i to jest najważniejsze zdanie tego artykułu:
Refaktoryzuj DO wzorca. Nie projektuj OD wzorca.
Wzorzec ma być odpowiedzią na realny ból w kodzie, a nie dekoracją zakładaną na zapas.
Zasada trzech. Jak to wygląda w praktyce? Pomaga prosta zasada trzech:
1. Pierwszy przypadek - napisz wprost. Zero abstrakcji.
2. Drugi przypadek - skopiuj. Tak, serio: Ctrl+C, Ctrl+V. Jeszcze nie abstrahuj na siłę. Czasem zwykła kopia jest uczciwsza niż zgadywanie przyszłości.
3. Trzeci przypadek - wyciągnij abstrakcję. Teraz już wiesz, jak ma wyglądać.
Dlaczego dopiero przy trzecim? Bo dopiero powtórzenie pokazuje, co naprawdę jest wspólne. Przy 1 przypadku zgadujesz, przy 2 podobieństwo może być przypadkowe, a przy 3 masz już dane, a nie przeczucia.
3 pytania, zanim sięgniesz po wzorzec
Zanim następnym razem sięgniesz po wzorzec projektowy, zadaj sobie 3 pytania:
1. Jaki problem rozwiązuję DZISIAJ? Nie "może kiedyś", nie "na zapas" - dzisiaj.
2. Czy C# albo .NET nie ma już tego wbudowanego? Delegaty, eventy, LINQ, DI, rekordy, pattern matching - sprawdź, zanim napiszesz własną strukturę klas.
3. Co powie osoba, która przeczyta ten kod za rok? "Dzięki" - czy "co tu się odbywa?!"
A jeśli masz zapamiętać z tego artykułu jedno zdanie, niech to będzie to: najpierw prosty kod. Wzorzec - dopiero, gdy zaboli.
Ściąga: wzorzec → co zamiast w nowoczesnym C#
Wszystkie omówione wzorce w 1 tabeli:
| Wzorzec z podręcznika | Co zamiast w nowoczesnym C#/.NET |
| Singleton | AddSingleton w kontenerze DI |
| Repository nad EF Core | DbContext bezpośrednio (własne repozytorium: Dapper, realnie wymienne źródła danych, DDD) |
| Strategy | switch expression albo delegat (Func) |
| Factory, która tylko woła new | zwykłe new (fabryka: realna logika wyboru, zależności z DI, IHttpClientFactory) |
| Observer | event |
| Iterator | foreach + yield return |
| Builder | object initializer + required i konstruktory |
| Prototype | record + with |
| Command | Action / Func (pełny wzorzec: kolejka poleceń, undo) |
| Template Method | delegat przekazany jako parametr |
Podsumowanie
Wzorce projektowe powstały w zupełnie innych realiach językowych niż dzisiejszy C# i .NET. Dlatego zanim po któryś sięgniesz, sprawdź, czy język albo framework nie rozwiązuje już Twojego problemu. A jeśli sięgasz - rób to dlatego, że kod boli, a nie dlatego, że "tak wypada".
W skrócie:
• Singleton → AddSingleton w DI: ta sama 1 instancja, ale jawna zależność i łatwe testy.
• Repository nad EF Core → DbContext już łączy Repository i Unit of Work, nie opakowuj go drugi raz.
• Strategy → switch expression albo delegat zamiast 4 plików.
• Factory → tylko z realną logiką wyboru, w pozostałych przypadkach wystarczy new.
• Observer → słowo kluczowe event.
• Koszt abstrakcji płacisz od pierwszego dnia, a zyski są niepewne.
• Refaktoryzuj DO wzorca, nie projektuj OD wzorca - i trzymaj się zasady trzech.
Najpierw prosty kod. Wzorzec - dopiero, gdy zaboli.
Jeśli chcesz więcej takiego konkretnego mięsa o C# i .NET, dołącz do mojej społeczności .NET - jest nas już ponad 10 000 programistów. Raz w tygodniu wysyłam 1 maila z najważniejszymi newsami ze świata .NET i C#. Bez spamu, sama esencja. Członkowie społeczności dostają też rabaty na moje kursy - zawsze najlepsze ceny.
Wystarczy, że zostawisz swój adres tutaj: modestprogrammer.pl/vip. Wypisujesz się jednym kliknięciem. Do zobaczenia w środku :)