🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

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:

  1. Stworzyć EmailNotificationMessageBuilder
  2. Zarejestrować go w NotificationChannelRegistry
  3. Skonfigurować NotificationDispatchPolicy dla e-maila
  4. Wywołać NotificationPipeline z odpowiednim NotificationContext

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

KryteriumBez wzorcaOver-engineeringWłaściwy moment
Liczba klas1~155 (3 kanały + serwis + interfejs)
Czytelnośćwysokaniskawysoka
Dodanie nowego kanałumodyfikacja OrderServicezmiany w 5 miejscachnowa klasa + 1 linia DI
Testowalnośćdobratrudna (wiele zależności)dobra (mockujemy INotificationChannel)
Kiedy sens1 kanałnigdy3+ 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ówFactory Method, Builder
Klasa robi za dużo narazSRP + wydzielenie klas
Kod się duplikuje — wiele if/else na typieStrategy
Chcesz reagować na zdarzenia w wielu miejscachObserver
Klasy są za silnie powiązaneFacade, 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:

  1. Jaki konkretny problem rozwiązuję? Nie “ogólnie będzie lepiej” — konkretny problem w tym kodzie.
  2. Czy ten problem już istnieje, czy tylko przypuszczam, że wystąpi? Jeśli tylko przypuszczasz — poczekaj.
  3. Ile razy ten problem się powtarza? Raz — za wcześnie. Trzy razy — czas na refaktoring.
  4. Co tracę, stosując wzorzec? Więcej klas? Trudniejsze onboardowanie? Czy ta cena jest warta zysku?
  5. 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

czytanie to początek

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ę →