Kiedy stosować wzorce projektowe — i kiedy NIE stosować

Kąt widzenia: pragmatyczny/decyzyjny — wzorzec jako narzędzie, nie cel
Przykład przewodni: system powiadomień w trzech wersjach: bez wzorca, z nadużyciem wzorców, poprawnie
Wzorzec to narzędzie, nie cel
Wzorce projektowe mają złą reputację w jednej sprawie: programiści, którzy je poznają, zaczynają widzieć je wszędzie. Każda klasa musi być Singleton. Każde tworzenie obiektu — Factory. Każda metoda z parametrem — Strategy.
To pattern fever — choroba na wczesnym etapie nauki wzorców.
Paradoksalnie, nadużycie wzorców jest gorsze niż ich brak. Kod z niepotrzebnym wzorcem jest trudniejszy do czytania, ma więcej klas, więcej abstrakcji i więcej miejsc, gdzie coś może pójść nie tak.
Ten post pokazuje ten problem na konkretnym przykładzie: ten sam feature, trzy różne implementacje — żebyś wiedział, kiedy wzorzec naprawdę pomaga.
Scenariusz: system powiadomień
Budujesz aplikację e-commerce. Po złożeniu zamówienia system ma wysłać powiadomienie do klienta. Na starcie projekt ma jeden wymóg: e-mail.
Za trzy miesiące dodajecie SMS. Za pół roku — push notification i Slack do wewnętrznego monitoringu.
To bardzo typowa historia. Zobaczmy, jak różne podejścia radzą sobie z tą ewolucją.
Wersja 1: bez wzorca (i to jest OK na starcie)
public class OrderService
{
private readonly IEmailSender _emailSender;
public OrderService(IEmailSender emailSender)
=> _emailSender = emailSender;
public async Task PlaceOrderAsync(Order order)
{
// logika zamówienia...
await SaveOrderAsync(order);
// powiadomienie — proste, bezpośrednie
await _emailSender.SendAsync(
to: order.CustomerEmail,
subject: $"Potwierdzenie zamówienia #{order.Number}",
body: BuildEmailBody(order));
}
}
Jest tu wzorzec? Nie. Czy to problem? Nie.
Masz jeden rodzaj powiadomienia. Kod jest prosty, czytelny, testowalny (możesz mockować IEmailSender). Żaden wzorzec nie uprości tego kodu — może go tylko skomplikować.
Zasada: nie wprowadzaj abstrakcji, której nie potrzebujesz jeszcze. Jeśli jutro pojawi się SMS — wtedy refaktorujesz. Nie “na zapas”.
Wersja 2: nadużycie wzorców (over-engineering)
Wyobraź sobie, że programista — który właśnie nauczył się wzorców — dostaje ticket “dodaj SMS do powiadomień”. Wie, że będą kolejne typy. Postanawia “zrobić to porządnie od razu”:
// Abstrakcja fabryki powiadomień
public interface INotificationChannelFactory
{
INotificationChannel Create(NotificationChannelType type);
}
// Abstrakcja budowniczego wiadomości
public interface INotificationMessageBuilder
{
INotificationMessageBuilder WithOrder(Order order);
INotificationMessageBuilder WithRecipient(string recipient);
NotificationMessage Build();
}
// Abstrakcja polityki wysyłki
public interface INotificationDispatchPolicy
{
bool ShouldDispatch(Order order, NotificationChannelType channel);
}
// Abstrakcja samego powiadomienia
public interface INotificationChannel
{
Task SendAsync(NotificationMessage message);
}
// Rejestr kanałów
public interface INotificationChannelRegistry
{
void Register(NotificationChannelType type, INotificationChannel channel);
INotificationChannel Resolve(NotificationChannelType type);
}
// ... i 8 konkretnych klas implementujących powyższe interfejsy
// ... i OrderNotificationOrchestrator
// ... i NotificationContext
// ... i NotificationPipeline
Żeby wysłać e-mail, muszę teraz:
- Stworzyć
EmailNotificationMessageBuilder - Zarejestrować go w
NotificationChannelRegistry - Skonfigurować
NotificationDispatchPolicydla e-maila - Wywołać
NotificationPipelinez odpowiednimNotificationContext
A wcześniej wystarczyło: await _emailSender.SendAsync(...).
Co poszło nie tak?
Programista rozwiązał problem, którego jeszcze nie ma — i stworzył problem, którego nikt się nie spodziewał. Teraz każdy nowy deweloper spędzi godzinę, żeby zrozumieć, jak wysłać e-mail.
“Make it work, make it right, make it fast — in that order.”
— Kent Beck
Sygnały, że robisz over-engineering
Warto je znać, bo są subtelne:
Tworzysz interfejs dla jednej klasy. Interfejs ma sens gdy masz lub spodziewasz się wielu implementacji. Jeśli IEmailSender ma jedną implementację SmtpEmailSender i nic nie sugeruje, że będzie ich więcej — interfejs dodaje złożoność bez wartości (testowanie to osobna kwestia — tam interfejs ma sens).
Tworzysz wzorzec “na przyszłość”. “Kiedyś to się przyda” to najdroższe zdanie w inżynierii. YAGNI — You Ain’t Gonna Need It. Refaktoruj gdy problem naprawdę się pojawi, nie wcześniej.
Wzorzec ma więcej klas niż logiki. Jeśli masz 6 interfejsów i 8 klas, a faktyczna logika biznesowa zajmuje 20 linii — proporcje są złe.
Każda zmiana wymaga zmiany w wielu miejscach. Dodanie nowego kanału powiadomień powinno być łatwe. Jeśli wymaga modyfikacji 5 klas — abstrakcja nie abstrakcji.
Wersja 3: wzorzec we właściwym momencie
Masz teraz realne wymaganie: e-mail, SMS, i push notification. Widzisz, że każda nowa metoda to inny kod, ale ta sama logika orkiestracji. Teraz ma sens refaktoring.
Interfejs (Strategy)
public interface INotificationChannel
{
string ChannelName { get; }
Task SendAsync(OrderNotification notification);
}
Jeden interfejs. Trzy rzeczy: nazwa (do logowania), metoda wysyłki, kontekst powiadomienia.
Konkretne kanały
public class EmailNotificationChannel : INotificationChannel
{
private readonly IEmailSender _emailSender;
private readonly IEmailTemplateRenderer _renderer;
public string ChannelName => "Email";
public EmailNotificationChannel(
IEmailSender emailSender,
IEmailTemplateRenderer renderer)
{
_emailSender = emailSender;
_renderer = renderer;
}
public async Task SendAsync(OrderNotification notification)
{
var body = await _renderer.RenderAsync("OrderConfirmation", notification);
await _emailSender.SendAsync(
to: notification.CustomerEmail,
subject: $"Zamówienie #{notification.OrderNumber} zostało przyjęte",
body: body);
}
}
public class SmsNotificationChannel : INotificationChannel
{
private readonly ISmsGateway _smsGateway;
public string ChannelName => "SMS";
public SmsNotificationChannel(ISmsGateway smsGateway)
=> _smsGateway = smsGateway;
public async Task SendAsync(OrderNotification notification)
{
if (string.IsNullOrWhiteSpace(notification.CustomerPhone))
return; // SMS opcjonalny — brak numeru = pomijamy
var message =
$"Zamówienie #{notification.OrderNumber} przyjęte. " +
$"Wartość: {notification.TotalAmount:C2}. Dziękujemy!";
await _smsGateway.SendAsync(notification.CustomerPhone, message);
}
}
public class SlackNotificationChannel : INotificationChannel
{
private readonly ISlackClient _slack;
private readonly string _alertChannel;
public string ChannelName => "Slack";
public SlackNotificationChannel(ISlackClient slack, IConfiguration config)
{
_slack = slack;
_alertChannel = config["Slack:OrderAlertsChannel"] ?? "#orders";
}
public async Task SendAsync(OrderNotification notification)
{
// Slack tylko dla dużych zamówień — logika pozostaje w kanale
if (notification.TotalAmount < 5000m)
return;
await _slack.PostMessageAsync(
channel: _alertChannel,
text: $":moneybag: Duże zamówienie #{notification.OrderNumber} " +
$"od {notification.CustomerName} | {notification.TotalAmount:C2}");
}
}
Orkiestrator (Context)
public class OrderNotificationService
{
private readonly IEnumerable<INotificationChannel> _channels;
private readonly ILogger<OrderNotificationService> _logger;
public OrderNotificationService(
IEnumerable<INotificationChannel> channels,
ILogger<OrderNotificationService> logger)
{
_channels = channels;
_logger = logger;
}
public async Task NotifyAsync(OrderNotification notification)
{
// Wysyłamy przez wszystkie kanały równolegle
var tasks = _channels.Select(async channel =>
{
try
{
await channel.SendAsync(notification);
_logger.LogInformation(
"Powiadomienie [{Channel}] wysłane dla zamówienia {OrderNumber}",
channel.ChannelName, notification.OrderNumber);
}
catch (Exception ex)
{
// Jeden kanał nie blokuje pozostałych
_logger.LogError(ex,
"Błąd powiadomienia [{Channel}] dla zamówienia {OrderNumber}",
channel.ChannelName, notification.OrderNumber);
}
});
await Task.WhenAll(tasks);
}
}
Rejestracja w DI
// Program.cs
builder.Services.AddScoped<INotificationChannel, EmailNotificationChannel>();
builder.Services.AddScoped<INotificationChannel, SmsNotificationChannel>();
builder.Services.AddScoped<INotificationChannel, SlackNotificationChannel>();
builder.Services.AddScoped<OrderNotificationService>();
Dodanie nowego kanału
Za tydzień product manager mówi: “Chcemy push notifications przez Firebase.”
public class PushNotificationChannel : INotificationChannel
{
// implementacja...
public string ChannelName => "Push";
public async Task SendAsync(OrderNotification notification) { /* ... */ }
}
Jedna nowa klasa. Jedna linijka w DI. OrderNotificationService się nie zmienia. OrderService się nie zmienia. Żadna istniejąca klasa się nie zmienia.
To jest moment, w którym wzorzec zarabia na siebie.
Porównanie trzech wersji
| Kryterium | Bez wzorca | Over-engineering | Właściwy moment |
|---|---|---|---|
| Liczba klas | 1 | ~15 | 5 (3 kanały + serwis + interfejs) |
| Czytelność | wysoka | niska | wysoka |
| Dodanie nowego kanału | modyfikacja OrderService | zmiany w 5 miejscach | nowa klasa + 1 linia DI |
| Testowalność | dobra | trudna (wiele zależności) | dobra (mockujemy INotificationChannel) |
| Kiedy sens | 1 kanał | nigdy | 3+ kanały, spodziewamy się więcej |
Reguły, które pomagają podjąć decyzję
Reguła “Rule of Three”
Nie abstrahujesz czegoś, co pojawia się raz. Nie abstrahujesz przy drugim powtórzeniu (jeszcze nie widzisz wzorca). Przy trzecim — refaktorujesz.
W naszym scenariuszu: przy jednym kanale (e-mail) — bez wzorca. Przy SMS — duplikacja widoczna, ale nie refaktorujesz od razu. Przy trzecim (push) — widzisz wzorzec, czas na Strategy.
Reguła “Problem First”
Wzorzec to rozwiązanie konkretnego problemu. Jeśli nie masz problemu z dziedziny wzorca — nie używaj go.
| Masz problem z… | Rozważ wzorzec |
|---|---|
| Tworzenie obiektów jest skomplikowane / zależy od warunków | Factory Method, Builder |
| Klasa robi za dużo naraz | SRP + wydzielenie klas |
| Kod się duplikuje — wiele if/else na typie | Strategy |
| Chcesz reagować na zdarzenia w wielu miejscach | Observer |
| Klasy są za silnie powiązane | Facade, Mediator |
| Tworzysz tylko jedną instancję (konfiguracja, cache) | Singleton |
Reguła “Koszt abstrakcji”
Każda abstrakcja ma cenę: więcej plików, więcej klas, więcej miejsc do zrozumienia. Ta cena jest uzasadniona gdy abstrakcja eliminuje większy koszt — duplikację, kruchość, trudność testowania.
Zadaj sobie pytanie: jaki konkretny problem rozwiązuje ta abstrakcja tutaj i teraz? Jeśli nie masz odpowiedzi — nie rób abstrakcji.
Edge case: wzorzec, który wygląda jak wzorzec, ale nim nie jest
// Czy to Strategy?
public interface IValidator<T>
{
bool IsValid(T value);
}
public class EmailValidator : IValidator<string> { ... }
public class PhoneValidator : IValidator<string> { ... }
Formalnie — tak, to przypomina Strategy. Ale Intent Strategy mówi o wymienialnych algorytmach. Walidatory nie są wymieniane — są używane razem. To bliżej Chain of Responsibility lub po prostu Collection of validators.
Nie przyklejaj nazwy wzorca do kodu, który przypomina wzorzec strukturalnie — sprawdź Intent. Jeśli problem nie pasuje do Intent, to nie jest ten wzorzec.
Kiedy wzorzec jest koniecznie potrzebny — sygnały w kodzie
Jeśli widzisz te sygnały, wzorzec prawdopodobnie rozwiąże realny problem:
Switch/if-else na typie rozciągający się przez wiele metod:
// Ten if-else pojawia się w 4 metodach — to sygnał
if (paymentMethod == "CreditCard") { ... }
else if (paymentMethod == "Blik") { ... }
else if (paymentMethod == "Transfer") { ... }
→ Kandydat na Strategy lub Command.
Klasa, która rośnie z każdym nowym typem:
// Każdy nowy raport dodaje nową metodę do tej klasy
public class ReportService
{
public SalesReport GenerateSales() { ... }
public InventoryReport GenerateInventory() { ... }
public FinancialReport GenerateFinancial() { ... }
// za miesiąc: CustomerReport, MarketingReport, ...
}
→ Kandydat na Strategy lub Template Method.
Konstruktor z parametrami flagi:
var service = new NotificationService(
sendEmail: true,
sendSms: false,
sendPush: true,
sendSlack: false);
→ Kandydat na Builder lub Strategy z kompozycją kanałów.
Podsumowanie: pytania przed zastosowaniem wzorca
Zanim napiszesz “tu damy Factory” — odpowiedz na te pytania:
- Jaki konkretny problem rozwiązuję? Nie “ogólnie będzie lepiej” — konkretny problem w tym kodzie.
- Czy ten problem już istnieje, czy tylko przypuszczam, że wystąpi? Jeśli tylko przypuszczasz — poczekaj.
- Ile razy ten problem się powtarza? Raz — za wcześnie. Trzy razy — czas na refaktoring.
- Co tracę, stosując wzorzec? Więcej klas? Trudniejsze onboardowanie? Czy ta cena jest warta zysku?
- Czy bez wzorca kod jest trudny do testowania lub zmiany? Jeśli nie — nie ma problemu do rozwiązania.
Wzorzec, który odpowiada na realne “tak” do pytań 1, 3 i 5 — ma sens. Reszta to engineering dla samego engineeringu.
Poprzedni post: Czym są wzorce projektowe i jak je czytać? — anatomia wzorca na przykładzie Strategy: Intent, Applicability, Structure, Consequences. Jak czytać każdy z 23 wzorców GoF.
2 comments
Dodaj komentarz
Musisz się zalogować, aby móc dodać komentarz.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →
Super wpis, bardzo pomocny.