1. Konflikt kandydatów i wybór domyślny
Gdy po raz pierwszy łapiesz NoUniqueBeanDefinitionException, pojawia się bardzo ludzkie pragnienie: „No wybierz już cokolwiek, muszę uruchomić projekt, potem się rozejrzę". To uczucie zna każdy deweloper, szczególnie w poniedziałkowy poranek. Ale właśnie w tym momencie Spring robi nam prezent: zmusza do sformułowania umowy — co uważać za wariant główny, a co za zapasowy.
Wyobraź sobie, że masz interfejs NotificationSender i dodałeś dwie implementacje: EmailNotificationSender i SmsNotificationSender. Obie uczciwie zarejestrowane jako beany. Kontener widzi: „pasuje po typie — pasuje". Ale gdy serwis prosi o jednego NotificationSender, kontener nie ma prawa wybrać losowo: inaczej zachowanie aplikacji będzie zależeć od kolejności skanowania klas, wewnętrznych szczegółów budowania i fazy księżyca (na produkcji — szczególnie lubi pełnię).
Znowu weźmiemy celowo prosty wariant one-sender NotificationDispatchService: potrzebny tylko jako czysty punkt wstrzyknięcia, na którym widać zachowanie default-choice.
Oto minimalna ilustracja „miejsca bólu" — serwis nie robi nic podejrzanego, po prostu chce jeden obiekt:
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
// Spring wstrzykuje tu jeden bean typu NotificationSender.
// Jeśli kandydatów jest kilku i nie ma reguł wyboru — będzie NoUniqueBeanDefinitionException.
this.sender = sender;
}
}
Jeśli NotificationSender jest zarejestrowany w kontenerze w dwóch egzemplarzach, Spring musi powiedzieć: „Okej, ale który konkretnie?". I właśnie tu pojawiają się dwa narzędzia, które dziś omówimy: @Primary i @Fallback. Pomagają opisać „zwykły" wybór i wariant „rezerwowy" na poziomie kontenera, a nie przez if/else wewnątrz metod biznesowych.
2. @Primary: wariant główny
Z @Primary rozwiązujemy bardzo przyziemne zadanie: „W większości miejsc aplikacji potrzebuję właśnie tego wariantu, bez dodatkowych doprecyzowań". To podobne do ustawienia „domyślna przeglądarka" w systemie operacyjnym. Możesz mieć pięć przeglądarek, ale gdy po prostu klikasz w link, system musi wybrać jedną — główną. @Primary to takie samo ustawienie, tylko dla kontenera Springa.
Sens jest prosty: jeśli po typie pasuje kilka beanów, ale potrzebny jest jeden, i dokładnie jeden z nich jest oznaczony @Primary, Spring wybierze właśnie jego. Przy tym pozostałe beany nigdzie nie znikają: pozostają w kontenerze i mogą być używane tam, gdzie wybór odbywa się jawnie lub gdzie serwisowi potrzebny jest cały zestaw implementacji.
@Primary można pokazać na dwa naturalne sposoby: na klasie-komponencie lub na metodzie @Bean w konfiguracji. Znaczeniowo to jedno i to samo — oznaczenie trafia do definicji beana.
2.1. @Primary na komponencie
W naszym projekcie ContextFlow logicznie jest zrobić „najbezpieczniejszy" kanał powiadomień głównym — konsolę. Dla szkolnej aplikacji non-web to idealny domyślny: zawsze działa, nie wymaga zewnętrznej infrastruktury i nie zmusza studenta do konfigurowania SMTP w środku dnia.
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
@Primary // Ten bean zostanie wybrany domyślnie, jeśli kandydatów jest kilku
@Component
public class ConsoleNotificationSender implements NotificationSender {
@Override
public void send(String message) {
// Najbezpieczniejszy kanał: zawsze dostępny, nie wymaga infrastruktury
System.out.println("CONSOLE: " + message); // CONSOLE: Order created
}
}
Teraz jeśli masz jeszcze EmailNotificationSender i SmsNotificationSender, i jakiś serwis prosi po prostu o NotificationSender, kontener wybierze ConsoleNotificationSender, bo jest „główny".
2.2. @Primary na metodzie @Bean
Czasem implementację wygodniej trzymać nie jako osobny @Component, lecz jako @Bean (np. dlatego że trzeba precyzyjnie skonfigurować obiekt, przekazać parametry, złożyć łańcuch zależności itd.). Wtedy @Primary wiesza się na metodzie:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class NotificationConfig {
@Bean
@Primary // Domyślnie wybieramy właśnie ten NotificationSender
NotificationSender consoleNotificationSender() {
// Tu rejestrujemy bean ręcznie, a nie przez @Component
return message -> System.out.println("CONSOLE: " + message);
}
}
Zwróć uwagę na ważną subtelność: serwis biznesowy w ogóle się nie zmienia. Nie grzebiemy w NotificationDispatchService, nie wstawiamy if/else, nie tworzymy zależności ręcznie. Po prostu mówimy kontenerowi: „Gdy nie precyzują — bierz to".
Jeśli chcesz utrwalić myśl: @Primary to „główna ścieżka", którą Spring wybiera domyślnie, gdy typ pasuje do kilku beanów.
I tu Spring znowu zachowuje się po dorosłemu. Jeśli oznaczysz @Primary dwie implementacje jednego typu, kontener nie będzie wybierał „bardziej głównej". Padnie przy starcie z czytelnym błędem: „znaleziono kilka primary beanów". I to jest dokładnie to, czego chcemy: lepiej niech aplikacja nie startuje, niż startuje z nieprzewidywalnym wyborem.
Ważna praktyczna myśl: @Primary to globalna umowa. Jeśli chcesz, żeby w jednym miejscu był używany jeden wariant, a w innym — drugi, to nie jest historia o „domyślnym". Wtedy potrzebny jest jawny wybór w punkcie wstrzyknięcia, a nie kolejny przypadkowy default.
3. @Fallback: wariant zapasowy
Jeśli @Primary odpowiada na pytanie „co brać zwykle?", to @Fallback odpowiada na bardziej subtelne pytanie: „Co można wziąć, jeśli innych normalnych kandydatów nie ma?". To jak zapasowe koło w bagażniku: nie wybierasz go zamiast normalnego opony, gdy wszystko jest w porządku, ale ratuje, gdy inaczej w ogóle nie jedziesz.
Sens @Fallback (w ludzkiej formule): jeśli wśród kandydatów jest choć jeden bean bez @Fallback, to beany fallback są uznawane za „drugą kategorię" i przegrywają. Jeśli jednak okazało się, że kandydatami są tylko beany typu fallback, wtedy kontener może wybrać fallback (jeśli jest jeden) i kontynuować montaż.
Brzmi jak „jakby @Primary, tylko odwrotnie", ale ważne jest uchwycenie różnicy: @Primary to jawnie wyznaczony zwycięzca. @Fallback to kandydat, który zgadza się być wybrany tylko jeśli nie ma już kogo innego.
3.1. No-Op / No-Discount jako ubezpieczenie
W ContextFlow idealnie pasuje przykład z rabatami. Wyobraźmy sobie, że mamy „prawdziwą" politykę rabatów (np. dla lojalnych klientów) i „zerową" politykę, która nic nie zmienia. Zerowa polityka to świetna rezerwa: jeśli z jakiegoś powodu nie podłączyliśmy prawdziwej polityki, system i tak będzie mógł obliczyć sumę zamówienia.
import org.springframework.stereotype.Component;
@Component
public class LoyalCustomerDiscountPolicy implements DiscountPolicy {
@Override
public int apply(int total) {
// „Prawdziwa" polityka: zmienia cenę (zrobione maksymalnie szkolnie)
return total - 10; // bardzo szkolny rabat, nie pytaj „dlaczego 10"
}
}
A teraz dodajmy wariant fallback:
import org.springframework.context.annotation.Fallback;
import org.springframework.stereotype.Component;
@Fallback // Ten bean zostanie wybrany tylko jeśli nie ma innych kandydatów DiscountPolicy
@Component
public class NoDiscountPolicy implements DiscountPolicy {
@Override
public int apply(int total) {
// Wariant zapasowy: nic nie zmienia, ale pozwala aplikacji wystartować
return total;
}
}
Jeśli w kontenerze jest LoyalCustomerDiscountPolicy, to zostanie wybrany (bo nie jest fallback). NoDiscountPolicy jako fallback pozostaje „na ławce rezerwowych". Ale jeśli usuniesz prawdziwą politykę (lub tymczasowo jej nie zarejestrujesz), wtedy fallback staje się przydatny: kontener i tak będzie mógł wstrzyknąć DiscountPolicy i aplikacja wystartuje.
Tu początkujący często oczekuje magii: „No skoro jest fallback, niech Spring wybierze jakiś normalny". Nie. Jeśli masz dwóch nie-fallback kandydatów (np. EmailNotificationSender i SmsNotificationSender), a ConsoleNotificationSender jest oznaczony @Fallback, kontener nadal nie będzie w stanie wybrać między email a sms. Fallback po prostu odpadnie z wyścigu, ale zwycięzcy to nie stworzy.
Czyli @Fallback to nie narzędzie wyboru między kilkoma równorzędnymi implementacjami. To narzędzie „dać zapasowy wariant", który nie przeszkadza, gdy są normalni kandydaci.
4. Algorytm wyboru: @Primary i @Fallback
Dopóki nie zobaczy się kilku kombinacji na jednym obrazku, mózg lubi się mylić: „Primary to default, fallback to też default, ale jak?". Zróbmy mini-mapę decyzji. Nie wchodzimy w pełny algorytm rozwiązywania, potrzebujemy szkolnie uczciwego poziomu: co się dzieje w typowych sytuacjach, gdy serwis prosi o jednego beana interfejsowego typu.
Poniżej — tabela „co będzie, gdy…". Przyjmijmy, że wstrzykujemy DiscountPolicy do serwisu przez konstruktor.
| Kandydaci w kontenerze | Co wybierze Spring | Dlaczego |
|---|---|---|
| 1 zwykły bean | Ten bean | Brak niejednoznaczności |
| 2 zwykłe beany | Błąd | Nie można zgadnąć |
| 1 @Primary + dowolne inne | @Primary | Jawnie wyznaczony zwycięzca |
| 2 @Primary | Błąd | Za dużo „głównych" |
| 1 zwykły + 1 @Fallback | Zwykły | Fallback ustępuje |
| Tylko 1 @Fallback | Ten fallback | Nie ma komu ustąpić, ale kandydat jest jeden |
| 2 @Fallback | Błąd | Nie ma komu ustąpić, ale kandydatów jest kilku |
A żeby utrwalić w formie „jak proces", oto schemat blokowy. Zawiera dokładnie to, czego potrzebujemy na tym etapie, bez przyszłych komplikacji.
flowchart TD
%% Uproszczony schemat: tylko przypadek „potrzebny jeden bean po typie"
A["Potrzebny jeden bean typu T"] --> B["Znaleziono kandydatów po typie"]
B -->|0| E["Błąd: brak beana potrzebnego typu"]
B -->|1| F["Wybrano jedynego kandydata"]
B -->|ponad 1| C["Jest dokładnie jeden @Primary?"]
C -->|tak| G["Wybrano @Primary"]
C -->|nie| D["Są kandydaci NIE-fallback?"]
D -->|tak| H["Ignorujemy fallback, ale jeśli zostało ponad 1 -> błąd"]
D -->|nie| I["Wszyscy kandydaci to fallback: jeśli ponad 1 -> błąd, jeśli 1 -> wybieramy"]
Główna myśl z tego schematu: @Primary pomaga „wybrać zwycięzcę", a @Fallback pomaga „nie przeszkadzać zwycięzcy" i jednocześnie mieć zapasowy wariant, gdy zwycięzcy nie ma.
5. Przyrost w ContextFlow
Teraz zróbmy najważniejsze ćwiczenie dnia — w głowie, a najlepiej i w kodzie projektu: dodajemy kilka implementacji interfejsu, ale nie zamieniamy serwisów w fabryki. Czyli OrderPlacementService, NotificationDispatchService i OrderPricingService pozostają proste: są o scenariuszu biznesowym, a nie o wyborze konkretnych implementacji.
5.1. Domyślny kanał powiadomień przez @Primary
Niech NotificationDispatchService nadal celowo zależy od jednego NotificationSender. Na takim kontrakcie najłatwiej zobaczyć, jak kontener stosuje domyślnego kandydata i jednocześnie nie zmusza serwisu do ręcznego wyboru implementacji.
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
this.sender = sender;
// Wygodny wydruk diagnostyczny: pomaga zobaczyć, co dokładnie zostało wstrzyknięte
System.out.println("Sender = " + sender.getClass().getSimpleName());
// Sender = ConsoleNotificationSender
}
}
Tu ważne jest tylko jedno: serwis prosi o jednego sendera, a reguła wyboru żyje na zewnątrz.
Jeśli dodasz kilka senderów i oznaczysz konsolowy jako @Primary, kodu serwisu nie trzeba będzie w ogóle zmieniać. To najważniejsze kryterium zdrowia architektury: wybór implementacji odbywa się „z zewnątrz", w warstwie wiringu.
5.2. DiscountPolicy: wariant główny + ubezpieczenie fallback
Niech OrderPricingService prosi o jedną DiscountPolicy. Chcemy, żeby przy dostępnej „mądrej" polityce rabatów ona wygrywała, ale żeby pod jej nieobecność projekt nie zamieniał się w cegłę.
import org.springframework.stereotype.Service;
@Service
public class OrderPricingService {
private final DiscountPolicy discountPolicy;
public OrderPricingService(DiscountPolicy discountPolicy) {
// Tu też wstrzykiwany jest dokładnie jeden bean DiscountPolicy
this.discountPolicy = discountPolicy;
}
public int price(int total) {
// Cała logika wyboru implementacji jest wyeksponowana do kontenera, serwis po prostu stosuje kontrakt
return discountPolicy.apply(total);
}
}
Dalej po prostu zapewniamy kontenerowi reguły wyboru: „mądra" polityka — zwykła, „zerowa" — fallback. To wszystko. Serwis znowu się nie zmienia.
5.3. Gdzie to jest szczególnie przyjemne w praktyce
Gdy zaczynasz rozwijać projekt, takich „domyślnych" staje się wiele: gdzieś potrzebny jest jeden „zwykły" kanał powiadomień, gdzieś jedna „zwykła" polityka rabatu, gdzieś jeden „zwykły" writer do audytu. Jeśli każdy serwis będzie sam wybierał implementację, dostaniesz chaos wiringu rozlany po całym projekcie. Jeśli natomiast ustawiasz domyślne i fallback przez @Primary i @Fallback, dostajesz dwa bonusy jednocześnie: serwisy czytają się prościej, a kontener nadal pozostaje centralnym punktem montażu aplikacji.
I tak, to właśnie ten moment, gdy Spring przestaje być „magią" i staje się po prostu zdyscyplinowanym montażem grafu obiektów: ty sam mówisz, co jest główne, a co zapasowe. Spring tylko pilnuje, żebyś sobie nie zaprzeczał.
6. Typowe błędy przy używaniu @Primary i @Fallback
Błąd nr 1: stawianie @Primary „na wszelki wypadek" i utrata czytelności.
Jeśli nie sprawdzisz, gdzie dokładnie jest używany default, możesz dostać sytuację, gdy nowy deweloper otwiera serwis i nie rozumie, która implementacja naprawdę zostanie wstrzyknięta, bo wybór jest schowany gdzieś głęboko w projekcie. @Primary jest dobry wtedy, gdy wybór jest naprawdę masowy i oczywisty, a nie gdy „nie chce ci się myśleć".
Błąd nr 2: oznaczenie dwóch beanów jako @Primary i zdziwienie, że jest jeszcze gorzej.
Intuicyjnie wydaje się: „no mam dwie główne — niech Spring wybierze jedną". Ale kontener tak nie działa (i chwała mu). Dwa primary to nadal niejednoznaczność, tylko w bardziej oficjalnej formie. W efekcie aplikacja pada przy starcie — i słusznie: typ powinien mieć jednego jawnego domyślnego zwycięzcę.
Błąd nr 3: oczekiwanie, że @Fallback rozwiąże konflikt między dwoma zwykłymi implementacjami.
@Fallback to nie „słaby primary" i nie „wybierz dowolny normalny". To właśnie wariant zapasowy. Jeśli masz dwóch zwykłych kandydatów, kontener nadal nie wie, którego wybrać. Fallback w takiej sytuacji po prostu gwarantowanie przegra, ale zwycięzcy nie stworzy.
Błąd nr 4: leczenie niejednoznaczności powrotem do new wewnątrz serwisu.
To najbardziej „serdeczny" błąd: widzisz czerwony stack trace i myślisz „no dobra, stworzę teraz new SmsNotificationSender() i jedziemy". Niestety to krok wstecz do ręcznego wiringu i ukrytych zależności. Wybór implementacji powinien być kontenerowy, inaczej DI zamienia się w dekorację, a nie architekturę.
Błąd nr 5: próba używania @Primary/@Fallback do zarządzania kolekcjami beanów.
Czasem deweloper wstrzykuje List<NotificationSender> i oczekuje, że primary będzie „pierwszy" albo że fallback „nie trafi na listę". Tak myśleć nie należy: @Primary i @Fallback rozwiązują zadanie „potrzebny jeden bean". Jeśli potrzebujesz zestawu — to osobny kontrakt zależności i należy go projektować osobno, a nie czekać, że primary nagle zacznie zarządzać kolejnością i składem kolekcji.
GO TO FULL VERSION