Nowa reguła biznesowa nie powinna zmuszać zespołu do przebudowywania stabilnego kodu i dokładania kolejnych warunków w tych samych metodach. Właśnie temu służy zasada otwarte-zamknięte, znana też jako open closed principle. Pokazuję, jak rozumieć ją w praktyce, jak zastosować w C# i .NET oraz kiedy świadomie z niej zrezygnować, żeby nie stworzyć architektury przeprojektowanej.
Najważniejsze decyzje dotyczące rozszerzalnego kodu
- Cel: dodawanie nowych zachowań bez modyfikowania stabilnego modułu.
- Najczęstszy sygnał problemu: rozrastający się if, switch albo seria kontroli typu.
- Praktyczne narzędzia: interfejsy, polimorfizm, wzorzec Strategy i wstrzykiwanie zależności.
- Korzyść: mniejsze ryzyko regresji i łatwiejsze testowanie nowych wariantów.
- Ograniczenie: nie każda klasa powinna być otwarta na rozszerzenia od pierwszego dnia.
Na czym naprawdę polega zasada otwarte-zamknięte
Najprostsza wersja brzmi następująco: moduł powinien być otwarty na rozszerzanie, ale zamknięty na modyfikacje. Oznacza to, że nowe zachowanie dokładamy przez nowe klasy, implementacje lub konfigurację, zamiast za każdym razem zmieniać kod, który już działa.
Nie chodzi jednak o zakaz dotykania istniejących plików. To byłoby nierealne, szczególnie w rozwijanej aplikacji. Chodzi o to, aby najbardziej stabilna część systemu nie musiała znać każdego przyszłego wariantu funkcjonalności.
W praktyce projektuję granicę między kodem, który koordynuje działanie, a kodem, który zawiera zmienne reguły. Proces płatności może pozostać bez zmian, podczas gdy obsługę karty, przelewu i portfela cyfrowego dostarczają osobne implementacje wspólnego kontraktu.
To ważne rozróżnienie. OCP nie mówi, że nie wolno zmieniać kodu. Mówi raczej, że zmiana powinna trafić w miejsce, które z natury jest zmienne, a nie rozlewać się po całym systemie.
Co daje takie podejście
- Nową funkcję można dodać bez naruszania sprawdzonej logiki.
- Testy istniejących scenariuszy pozostają bardziej stabilne.
- Każdy wariant ma własne miejsce i własną odpowiedzialność.
- Moduły łatwiej rozwijać niezależnie, także w większym zespole.
Z mojego doświadczenia największą wartością nie jest sama elegancja kodu. Najwięcej zyskujemy wtedy, gdy wymagania zmieniają się często, a każda modyfikacja wspólnej metody mogłaby wywołać regresję w kilku niezależnych scenariuszach.
Przykład, który pokazuje różnicę w kodzie C#
Wyobraźmy sobie kalkulator rabatów. Pierwsza wersja często wygląda niewinnie, ale szybko zaczyna puchnąć:
public decimal CalculateDiscount(Order order)
{
if (order.CustomerType == "Regular")
{
return order.Total * 0.05m;
}
if (order.CustomerType == "Premium")
{
return order.Total * 0.15m;
}
if (order.CustomerType == "Partner")
{
return order.Total * 0.20m;
}
return 0;
}
Dodanie kolejnego typu klienta wymaga edycji tej samej metody. To jeszcze nie katastrofa, ale metoda zaczyna mieć wiele powodów do zmiany. Trzeba ją modyfikować przy każdym nowym wariancie, a testy muszą sprawdzać coraz dłuższy zestaw warunków.
Lepszym punktem rozszerzenia jest kontrakt opisujący sposób naliczania rabatu:
public interface IDiscountPolicy
{
bool AppliesTo(Order order);
decimal Calculate(Order order);
}
Każdy wariant dostaje własną klasę:
public sealed class PremiumDiscount : IDiscountPolicy
{
public bool AppliesTo(Order order)
=> order.CustomerType == CustomerType.Premium;
public decimal Calculate(Order order)
=> order.Total * 0.15m;
}
public sealed class PartnerDiscount : IDiscountPolicy
{
public bool AppliesTo(Order order)
=> order.CustomerType == CustomerType.Partner;
public decimal Calculate(Order order)
=> order.Total * 0.20m;
}
Serwis obliczający rabat nie musi już znać szczegółów każdego klienta:
public sealed class DiscountCalculator
{
private readonly IReadOnlyCollection _policies;
public DiscountCalculator(IEnumerable policies)
{
_policies = policies.ToArray();
}
public decimal Calculate(Order order)
{
var policy = _policies.FirstOrDefault(x => x.AppliesTo(order));
return policy is null
? 0
: policy.Calculate(order);
}
}
Nowy rabat oznacza dodanie nowej implementacji i rejestrację zależności. Nie zmieniamy kalkulatora, który odpowiada za wyszukanie właściwej polityki. To właśnie praktyczny efekt zasady otwarte-zamknięte, a nie samo przeniesienie warunków do większej liczby plików.
Trzeba przy tym uważać na metodę AppliesTo. Jeżeli wiele polityk może pasować jednocześnie, potrzebujemy jasno określonego priorytetu albo mechanizmu łączenia reguł. Sam polimorfizm nie rozwiązuje problemu niejednoznacznych wymagań.
Jak zastosować tę zasadę w aplikacji .NET
W ekosystemie .NET najczęściej łączę OCP z Dependency Injection, czyli wstrzykiwaniem zależności. Klasa korzystająca z kontraktu nie tworzy konkretnej implementacji przez operator new, tylko otrzymuje ją z zewnątrz.
Interfejs jako punkt rozszerzenia
Interfejs powinien opisywać potrzebne zachowanie, a nie wszystkie możliwości każdej przyszłej implementacji. Dla systemu wysyłki powiadomień wystarczy na przykład:
public interface INotificationSender
{
Task SendAsync(
Notification notification,
CancellationToken cancellationToken);
}
Implementacje mogą obsługiwać e-mail, SMS lub komunikat push. Kod aplikacyjny pracuje z jednym kontraktem, dlatego dodanie nowego kanału nie wymaga przebudowy całego procesu wysyłki.
Rejestracja implementacji
W ASP.NET Core można zarejestrować wiele implementacji tego samego interfejsu:
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
Wstrzykiwanie kolekcji pozwala później przejść po wszystkich senderach, a fabryka lub selektor może wybrać właściwy kanał. Kontener DI nie stosuje OCP za nas. On tylko ułatwia dostarczenie zależności. Dobrą granicę odpowiedzialności nadal trzeba zaprojektować samodzielnie.
Przeczytaj również: var w JavaScripcie - zakres, hoisting i pułapki starego kodu
Wzorzec Strategy
Wzorzec Strategy pasuje do tej zasady szczególnie dobrze. Polega na zamknięciu wymiennych algorytmów za wspólnym interfejsem. Można go wykorzystać przy naliczaniu podatku, wycenie przesyłki, autoryzacji płatności albo wyborze modelu AI.
Przykładowo proces generowania odpowiedzi może korzystać z interfejsu ITextGenerator. Implementacja dla Azure OpenAI, lokalnego modelu i usługi testowej będzie różna, ale kod aplikacji pozostanie oparty na tym samym kontrakcie. To daje łatwą podmianę dostawcy bez mieszania szczegółów infrastruktury z logiką biznesową.
Kiedy OCP pomaga, a kiedy prowadzi do przesady
Najczęstszy błąd polega na tworzeniu abstrakcji na zapas. Jeśli mamy prostą regułę, która zmieniła się raz przez dwa lata, budowanie pięciu interfejsów i fabryki może kosztować więcej niż późniejsza modyfikacja jednej metody.
Ja szukam najpierw miejsca, w którym zmienność jest realna. Dobrymi kandydatami są reguły biznesowe zależne od typu klienta, kanału komunikacji, dostawcy zewnętrznego, regionu albo planu abonamentowego. Słabszym kandydatem jest każda prywatna metoda, którą ktoś teoretycznie może kiedyś rozszerzyć.
| Sytuacja | Rozsądne podejście | Ryzyko |
|---|---|---|
| Jedna prosta reguła używana w jednym miejscu | Pozostawić prostą implementację | Niepotrzebna abstrakcja |
| Kilka wariantów rozwijanych niezależnie | Interfejs i osobne strategie | Rozrost liczby klas |
| Wiele dostawców zewnętrznych | Adaptery za wspólnym kontraktem | Ukrycie różnic, których nie da się ujednolicić |
| Reguły konfigurowalne przez biznes | Konfiguracja lub silnik reguł | Przeniesienie złożoności poza kod |
OCP nie oznacza też, że istniejący kod nigdy nie może zostać poprawiony. Jeżeli kontrakt jest źle zaprojektowany, jego zmiana może być najlepszą decyzją. Stabilność nie jest ważniejsza od poprawnego modelu domeny, a utrzymywanie złej abstrakcji tylko po to, by jej nie modyfikować, szybko staje się pułapką.
Jak rozpoznać naruszenie i poprawić kod bez rewolucji
W pierwszej kolejności sprawdzam historię zmian. Jeśli ta sama klasa jest edytowana przy każdym nowym typie biznesowym, prawdopodobnie skupia zbyt wiele wariantów. Dobrym sygnałem ostrzegawczym są też duże instrukcje switch, kontrole typu oraz metody, które znają szczegóły wielu integracji.
Nie zaczynam od mechanicznego tworzenia interfejsu. Najpierw nazywam zmienność. Pytam, co faktycznie się różni, które fragmenty muszą pozostać wspólne i kto powinien wybierać właściwy wariant.
- Zidentyfikuj kod zmieniany przy każdym nowym wymaganiu.
- Oddziel reguły wspólne od wariantów.
- Zdefiniuj mały kontrakt opisujący potrzebne zachowanie.
- Przenieś każdy wariant do osobnej implementacji.
- Wstrzyknij implementacje i usuń zależności od konkretnego typu.
- Dodaj testy dla istniejących oraz nowego scenariusza.
Ważny jest zakres refaktoryzacji. Nie przebudowywałbym całego modułu tylko dlatego, że znalazł się w nim jeden warunek. Często wystarczy wyciągnąć jeden stabilny punkt rozszerzenia, zostawić resztę bez zmian i dopiero później obserwować, czy granica rzeczywiście działa.
Warto sprawdzić również relację z zasadą podstawienia Liskov. Nowa implementacja musi zachowywać się zgodnie z kontraktem, inaczej formalne zastosowanie interfejsu będzie tylko pozorem rozszerzalności. Jeśli każda strategia wymaga wyjątkowych warunków w kodzie wywołującym, abstrakcja prawdopodobnie jest zbyt słaba.
OCP a architektura aplikacji i utrzymanie projektu
W większej aplikacji zasada otwarte-zamknięte pomaga ograniczyć efekt domina. Zmiana dostawcy płatności, modelu językowego czy sposobu naliczania opłaty może zamknąć się w jednym module, zamiast dotykać kontrolerów, przypadków użycia i warstwy danych.
Najlepiej działa wtedy, gdy łączy się ją z modułowym podziałem odpowiedzialności. Sam interfejs umieszczony w przypadkowym wspólnym projekcie nie gwarantuje dobrej architektury. Liczy się również to, kto jest właścicielem kontraktu i czy zależności wskazują w stronę stabilniejszych reguł.
W projektach wykorzystujących Azure lub usługi AI szczególnie przydatne są adaptery. Kod domenowy nie powinien wiedzieć, czy odpowiedź pochodzi z konkretnego endpointu, biblioteki SDK albo lokalnego mocka. Zmiana usługi może wymagać aktualizacji konfiguracji i adaptera, ale nie powinna wymuszać zmian w logice biznesowej.
Trzeba jednak zachować proporcje. Każdy dodatkowy interfejs, fabryka i rejestracja zwiększają koszt poznawczy projektu. Rozszerzalność ma sens wtedy, gdy chroni przed prawdopodobną zmianą, a nie wtedy, gdy jedynie dobrze wygląda na diagramie architektury.
Granica między elastycznością a przeprojektowaniem
Najpraktyczniej traktuję tę zasadę jako pytanie o koszt kolejnej zmiany. Jeżeli nowe wymaganie wymaga edycji stabilnego kodu, zwiększa ryzyko regresji i powtarza wzorzec z poprzednich zmian, warto wydzielić punkt rozszerzenia.
Jeżeli natomiast abstrakcja powstała wyłącznie na podstawie wyobrażonego scenariusza, lepiej zachować prostszy kod. Dobre OCP nie usuwa wszystkich modyfikacji. Sprawia, że zmiany trafiają w miejsca, które zostały do nich przygotowane, a reszta systemu pozostaje przewidywalna.
W C# najczęściej osiągam ten efekt przez małe interfejsy, polimorfizm, wzorzec Strategy, adaptery i Dependency Injection. Najważniejsza decyzja nie dotyczy jednak konkretnego wzorca, lecz rozpoznania, co w systemie naprawdę będzie się zmieniać. To od tego zaczyna się rozszerzalna architektura.