Czym jest Optional DI, a czym Null Object?
Wyobraź sobie klasę PaymentService, która „chciałaby" wysyłać powiadomienia i zapisywać metryki — ale potrafi działać także bez tych rzeczy. Powiadomienia i metryki to jej opcjonalni współpracownicy: przydatni, lecz nieobowiązkowi. Pojawia się pytanie projektowe: jak reprezentować sytuację, w której taki współpracownik jest nieobecny?
Zacznijmy od ważnego zastrzeżenia: nie istnieje formalny wzorzec projektowy o nazwie „Optional Dependency Injection". To potoczne określenie techniki, w której kontener DI (albo konstruktor) dopuszcza brak zarejestrowanej zależności. W zależności od mechanizmu użytego przez framework konsument może otrzymać null, Optional.empty() albo obiekt pośredniczący — taki jak springowy ObjectProvider, za pomocą którego sam sprawdza dostępność zależności — a kod musi zdecydować, jak obsłużyć brak.
Null Object to natomiast wzorzec projektowy. Refactoring Guru opisuje też refaktoryzację „Introduce Null Object", która polega na zastąpieniu sprawdzeń null właśnie tym wzorcem. Zamiast przekazywać null, dostarczamy prawdziwy obiekt, który implementuje oczekiwany interfejs, ale jego metody nic nie robią — mają „neutralne" zachowanie. Klasa konsumująca wywołuje metody bez żadnych warunków, bo zawsze ma z czym rozmawiać.
Ważne rozróżnienie klasyfikacyjne: żadne z tych rozwiązań nie jest modelem AI ani biblioteką. Optional DI to technika w ramach Dependency Injection (element szerszej zasady Inversion of Control). Null Object to wzorzec projektowy. Oba są konstrukcjami czysto architektonicznymi — sposobami organizacji kodu, niezależnymi od języka czy frameworka.
Kto za tym stoi?
Termin Dependency Injection spopularyzował Martin Fowler w artykule z 2004 roku; „opcjonalność" nie ma jednego autora — to praktyczna cecha, którą dodały kontenery DI. Wzorzec Null Object opisał Thomas Kühne w 1996 roku (jako „Void Value"), a rozpowszechnił Bobby Woolf w 1998 (por. Wikipedia). Geneza jest jednak mniej istotna niż pytanie kiedy którego użyć — do tego wracamy niżej.
Jak to działa?
Przy podejściu opcjonalnym pole bywa puste i trzeba to obsłużyć. W „starszym" stylu wygląda to tak:
Jednym ze sposobów jawnego wyrażenia takiej opcjonalności jest Optional:
Spring daje na to kilka sposobów: @Autowired(required = false), wstrzyknięcie Optional<Notifier>, adnotację @Nullable, a od wersji 4.3 również ObjectProvider<Notifier> z metodami getIfAvailable() / ifAvailable(...):
W .NET, jak opisuje dokumentacja Microsoftu, konstruktor może przyjąć argument, którego kontener nie dostarcza — o ile parametr ma wartość domyślną; metoda GetService zwraca null, gdy usługi nie zarejestrowano.
W przypadku Null Object tworzymy dodatkową implementację interfejsu o pustym zachowaniu:
Konsument wywołuje wtedy notifier.send(receipt) bezwarunkowo. I tu leży sedno: kod konsumujący nie interesuje się, czy dostał EmailNotifier, SmsNotifier, czy NullNotifier — rozmawia wyłącznie z interfejsem Notifier. Największą zaletą tego wzorca nie jest więc eliminacja instrukcji if, lecz wykorzystanie polimorfizmu po stronie klienta.
Oba podejścia nie wykluczają się. Optional<Notifier> może posłużyć wyłącznie na etapie budowy grafu zależności — na jego podstawie wybieramy konkretną implementację (EmailNotifier albo NoOpNotifier) i dopiero ją wstrzykujemy. Optional pozostaje wtedy szczegółem konfiguracji, a kod biznesowy pracuje wyłącznie z interfejsem Notifier.
Z jakich elementów się składa?
Podejście opcjonalne potrzebuje: punktu wstrzyknięcia oznaczonego jako opcjonalny, konfiguracji kontenera dopuszczającej brak rejestracji oraz logiki obsługi nieobecności po stronie konsumenta.
Null Object składa się z: interfejsu (kontraktu), co najmniej jednej „realnej" implementacji, implementacji-pustaka o neutralnym zachowaniu oraz punktu konfiguracji, który podstawia obiekt pusty jako domyślny. Cała decyzja „co robić przy braku" jest tu zamknięta w jednej klasie, a nie powielana u każdego wywołującego.
Do czego może być używane?
Typowe zastosowania to logowanie, metryki, cache, flagi funkcji i powiadomienia — czyli miejsca, w których nieobecność współpracownika jest sytuacją prawidłową, a nie błędem.
Rozważmy praktyczny przykład: NotificationService wysyłający powiadomienia przez SNS. Bez Null Object trzeba pilnować flagi w kodzie wywołującym:
Z Null Object warunek znika z kodu biznesowego i zostaje obsłużony w konfiguracji grafu zależności:
a to, którą implementację dostanie klient, rozstrzyga się przy montażu zależności: SnsNotifier, gdy SNS jest włączony, oraz NoOpNotifier (obiekt pusty), gdy wyłączony. Ten sam wzorzec jest też wygodny w testach — pozwala podstawić „ciche" zachowanie zamiast budować mocka.
Czym różni się od innych rozwiązań?
Kluczowa różnica to miejsce decyzji o braku: podejście opcjonalne zostawia ją wywołującym, Null Object zamyka ją w jednej klasie. Żadne z nich nie jest jednak uniwersalnie lepsze — wybór zależy od tego, czy „nic nie rób" jest sensownym zachowaniem dla danej zależności:
| Zależność | Podejście opcjonalne | Null Object |
|---|---|---|
| Logger | ❌ | ✅ |
| Metryki | ❌ | ✅ |
| Cache | ⚠️ | ✅ |
| Bramka płatności | ✅ | ❌ |
| Baza danych | ✅ | ❌ |
| Powiadomienia e-mail | zależy | zależy |
Dla loggera czy metryk „nic nie rób" jest w pełni akceptowalne — pasuje Null Object. Dla bramki płatności czy bazy danych ciche pominięcie operacji byłoby groźne — tu chcemy, by brak był jawny (a najlepiej: by zależność była obowiązkowa i zawiodła od razu). W wielu systemach cache jest przypadkiem pośrednim: pusty cache (zawsze „miss") działa poprawnie, ale może maskować problem wydajnościowy.
W szerszym ujęciu warto pamiętać też o zależności obowiązkowej (najbezpieczniejsza, gdy współpracownik jest niezbędny), o typie Optional<T> (czyni opcjonalność jawną w systemie typów) oraz o wyjątkach — każde rozwiązuje inną klasę problemów.
Najważniejsze ograniczenia i wyzwania
Null Object działa poprawnie tylko wtedy, gdy spełnia zasadę podstawienia Liskov (LSP): NullNotifier musi zachowywać się tak, by klient nie musiał wiedzieć, że dostał wariant pusty — innymi słowy, klient powinien móc użyć NullNotifier w miejscu EmailNotifier bez zmiany własnej logiki sterującej i bez naruszenia kontraktu interfejsu. Wzorzec najlepiej sprawdza się tam, gdzie istnieje naturalne, neutralne zachowanie lub wartość: czasem łatwo ją wskazać (NoDiscountPolicy zwracająca Money.ZERO — zależnie od sposobu modelowania można ją traktować jako Null Object albo zwykłą strategię reprezentującą brak rabatu), a czasem sensownej wartości neutralnej po prostu nie ma — i wtedy wzorzec nie pasuje. Druga pułapka to ciche awarie — skoro obiekt pusty niczego nie zgłasza, zapomniana rejestracja prawdziwej usługi może pozostać niezauważona. I zdroworozsądkowa uwaga: nie twórz Null Object tylko po to, by usunąć jednego if-a — czasem zwykłe if (dependency != null) jest najprostszym i najczytelniejszym rozwiązaniem.
Podejście opcjonalne z kolei cierpi na rozrost sprawdzeń null i realne ryzyko NullPointerException, gdy ktoś zapomni o warunku. Wielu praktyków traktuje dużą liczbę opcjonalnych zależności jako „zapach projektowy" — sygnał, że klasa robi zbyt wiele i warto ją podzielić.
Dlaczego to jest istotne?
Wybór między tymi podejściami to w istocie decyzja o tym, gdzie w systemie ma żyć złożoność związana z brakiem — rozproszona po wywołujących czy skupiona w jednym obiekcie. Dla juniora najważniejszy wniosek jest taki: nieobecność współpracownika to decyzja projektowa, a nie przypadek — i warto ją modelować świadomie, dobierając narzędzie do konkretnej zależności.
Szerszy trend w inżynierii idzie w stronę eliminowania null jako reprezentacji „braku" — widać to w Optional w Javie, typach nullowalnych w C# i null-safety w Kotlinie; tę samą filozofię wyraża klasyczna rada z Effective Java Joshuy Blocha, by zwracać puste kolekcje lub tablice zamiast null. Praktyczna reguła: Null Object stosuj, gdy „nic nie rób" jest poprawnym zachowaniem; zależność obowiązkową, gdy brak powinien zatrzymać start aplikacji; a zwykły null-check tam, gdzie jest po prostu najprostszy — bez dogmatyzmu.
Optional DI i Null Object rozwiązują podobny problem, ale modelują go inaczej: Optional DI jawnie reprezentuje możliwość braku zależności, a Null Object zastępuje ten brak obiektem o neutralnym zachowaniu. Umiejętność świadomego przełączania się między nimi to jedna z tych rzeczy, które odróżniają kod „działający" od kodu, który da się utrzymywać.
Źródła
- Martin Fowler — „Inversion of Control Containers and the Dependency Injection pattern" — link
- Wikipedia — „Null object pattern" — link
- Refactoring Guru — „Introduce Null Object" — link
- Spring Framework — „Using @Autowired" (zależności opcjonalne, ObjectProvider) — link
- Microsoft Learn — „Dependency injection in .NET" — link
- Joshua Bloch — Effective Java — „Return empty arrays or collections, not nulls" — link
