1. Rola @Bean obok skanowania
Ograniczenie skanowania widać niemal natychmiast: świetnie znajduje nasze @Service i @Component, ale nie odpowiada na pytanie, jak umieścić w kontenerze Clock, DateTimeFormatter czy starannie skonfigurowany obiekt z biblioteki zewnętrznej. Nie wystarczy tu powiedzieć Springowi „znajdź odpowiednią klasę w pakietach". Trzeba dać mu regułę tworzenia.
Właśnie to robi @Bean: nie szuka kandydata, lecz jawnie mówi kontenerowi, jaki obiekt ma się pojawić i jak go złożyć. Skanowanie i @Bean nie konkurują ze sobą. Skanowanie sprawdza się dla zwykłych komponentów projektu, a @Bean obsługuje konkretne miejsca, gdzie liczy się cudzy typ, konfiguracja lub jawny wybór implementacji.
Czasem obiekt pochodzi z JDK lub biblioteki zewnętrznej. Czasem chcesz zwrócić interfejs, ale stworzyć konkretną implementację. Czasem ważna jest precyzyjna konfiguracja przy tworzeniu: jeden parametr — i obiekt zachowuje się inaczej. W takich miejscach @Bean staje się uczciwym, przejrzystym narzędziem: „kontenerze, oto reguła — stwórz obiekt w taki sposób".
Żeby to poczuć w praktyce, można wyobrazić sobie dwa podejścia jako dwa tryby sterowania światłem w pokoju. Skanowanie to czujnik ruchu: wygodny, szybki, prawie zawsze działa. @Bean to włącznik, który montujesz tam, gdzie czujnik zaczyna szwankować (np. w korytarzu włącza się od kota, a ty chcesz, żeby reagował wyłącznie na człowieka).
2. Co rejestruje @Bean
Gdy po raz pierwszy widzisz @Bean, łatwo pomyśleć, że Spring „rejestruje metodę". Ale tak naprawdę Spring rejestruje obiekt, który ta metoda zwróci, a metoda to tylko fabryczny sposób powiedzenia kontenerowi, jak obiekt stworzyć. To ważna różnica, bo dalej będziemy myśleć nie „metodami", lecz właśnie beanami: nazwami, typami, cyklem życia w kontenerze.
Najprostszy przykład — umieszczenie w kontenerze Clock. To klasa z JDK, w której na pewno nie napiszemy @Component (i nie możemy — ona nie jest nasza).
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TimeConfig {
@Bean
Clock clock() {
// Rejestrujemy „czas" jako infrastrukturalną zależność:
// teraz można go wstrzykiwać do dowolnych klas przez DI.
return Clock.systemUTC();
}
}
Są tu od razu trzy praktyczne kwestie.
Po pierwsze, metoda clock() staje się metodą beanową: uczestniczy w Java config i zwraca bean.
Po drugie, nazwa beana domyślnie pochodzi z nazwy metody. Czyli bean będzie miał nazwę "clock".
Po trzecie, zwracany typ nie musi być „nasz", a sam kod tworzenia to zwykła Java. I to jest piękne: zero magii, wszystko czyta się jak fabryka.
Sprawdźmy, że kontener rzeczywiście wie o tym beanie:
import java.time.Clock;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class DemoApp {
public static void main(String[] args) {
// Uruchamiamy kontekst tylko z jednej klasy @Configuration
try (var context = new AnnotationConfigApplicationContext(TimeConfig.class)) {
// Pobieramy bean po nazwie i typie (demonstracja reguły nazewnictwa)
Clock clock = context.getBean("clock", Clock.class);
// Sprawdzamy, że to zegar UTC
System.out.println(clock.getZone()); // Z
}
}
}
Zauważ: pobraliśmy bean po nazwie "clock", bo to nazwa metody. W prawdziwym projekcie częściej będziesz pobierać po typie, ale żeby zrozumieć „skąd pochodzi nazwa", warto raz zrobić to właśnie tak.
Czasem trzeba nadać nazwę wprost, żeby nie zależała od tego, jak nazwałeś metodę (lub żeby lepiej wyglądała w błędach wiringu). Wtedy można podać ją bezpośrednio w adnotacji:
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TimeConfig {
@Bean("utcClock")
Clock clock() {
// Jawnie ustalamy nazwę beana, żeby była stabilna i czytelna
return Clock.systemUTC();
}
}
Teraz bean nosi nazwę "utcClock". A metoda nadal może się nazywać clock(). Wygląda dziwnie, jeśli się tym nadużywa, ale jako narzędzie do czytelności — jest użyteczne.
Dla orientacji zebermy regułę nazewnictwa w małą tabelkę:
| Jak napisano | Jaka nazwa beana wyjdzie |
|---|---|
| @Bean Clock clock() | clock |
| @Bean("utcClock") Clock clock() | utcClock |
I właśnie dlatego metodę beanową warto nazywać tak, jak „rola obiektu w kontenerze", a nie „jakieś przypadkowe słowo". Nazwanie metody beanowej a() jest technicznie możliwe, ale to bardzo uczciwy sposób powiedzenia przyszłemu sobie: „nie lubię siebie".
3. @Bean dla zewnętrznych klas
Bardzo często @Bean w prawdziwym życiu zaczyna się od zewnętrznych klas. I nie chodzi tylko o „jakąś bibliotekę z Mavena", ale nawet o JDK. Jeśli piszesz aplikację non-web (jak nasz ContextFlow) i chcesz porządnie zarządzać czasem, datami, formatowaniem, ścieżkami do plików, pojawia się mnóstwo obiektów, które chcesz mieć jedne na całą aplikację, konfigurowalne i pod kontrolą kontenera.
Na przykład możemy potrzebować jednego DateTimeFormatter do audytu i raportów (tak, nawet w aplikacji konsolowej ludzie lubią, gdy daty wyglądają jednakowo).
import java.time.format.DateTimeFormatter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FormattingConfig {
@Bean
DateTimeFormatter auditDateTimeFormatter() {
// Centralizujemy formatowanie dat: jeden format na całą aplikację
return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
}
}
Ważna myśl: nie musimy „rozrzucać formatera po kodzie" przez new tam, gdzie jest potrzebny. Możemy dać kontenerowi jeden obiekt, a potem wstrzykiwać go gdzie trzeba przez DI.
Tu pojawia się kolejny bonus: jeśli chcesz zmienić format, zmieniasz go w jednym miejscu — w konfiguracji, a nie w dziesięciu różnych klasach. Dla początkującego to może być pierwszy moment, gdy Spring zaczyna wyglądać nie jak „adnotacja dla adnotacji", lecz jak narzędzie do pielęgnowalności kodu.
Podobny przykład to java.nio.file.Path. W naszym projekcie wszystkie artefakty mają trafiać do build/, i to idealne miejsce, żeby zarejestrować bazową ścieżkę wyjściową jako bean:
import java.nio.file.Path;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OutputConfig {
@Bean
Path reportsOutputDir() {
// Ustalamy bazowy katalog dla raportów w jednym miejscu
return Path.of("build", "contextflow", "reports");
}
}
To nadal „zwykła Java", ale widać już dwie korzyści: obiekt jest scentralizowany i łatwy do zmiany, a poza tym można go reużywać w różnych klasach infrastrukturalnych bez ręcznego przekazywania.
4. @Bean dla obiektów infrastrukturalnych
@Bean nie jest potrzebny tylko dla „cudzych" klas. Nawet własne klasy czasem uczciwiej rejestrować jako @Bean niż jako @Component. Powód jest zwykle jeden: obiekt wymaga konfiguracji przy tworzeniu i chcesz, żeby ta konfiguracja była widoczna bezpośrednio w miejscu montażu, a nie schowana w polach, setterach czy „magicznych wartościach domyślnych".
Weźmy nasz ContextFlow. Mamy kontrakt generowania identyfikatorów zamówień:
package com.example.contextflow.domain.ports;
public interface OrderIdGenerator {
// Kontrakt: zewnętrzny świat chce „następne id", bez wnikania w implementację
String nextId();
}
I implementację, której chcemy używać domyślnie:
package com.example.contextflow.infrastructure.id;
import java.util.UUID;
import com.example.contextflow.domain.ports.OrderIdGenerator;
public class UuidOrderIdGenerator implements OrderIdGenerator {
@Override
public String nextId() {
// Wybrana strategia generowania: UUID (decyzja poziomu infrastrukturalnego)
return UUID.randomUUID().toString();
}
}
Gdybyśmy zrobili UuidOrderIdGenerator komponentem, formalnie wszystko by działało. Ale czytając projekt, natychmiast pojawia się pytanie: „A dlaczego akurat UUID? A gdzie to jest ustalane?". Na poziomie domeny ta decyzja nie powinna żyć, ale na poziomie montażu aplikacji — jak najbardziej.
Wtedy konfiguracja wygląda tak:
import com.example.contextflow.domain.ports.OrderIdGenerator;
import com.example.contextflow.infrastructure.id.UuidOrderIdGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class IdConfig {
@Bean
OrderIdGenerator orderIdGenerator() {
// Zwracamy interfejs, ale tworzymy konkretną implementację:
// ułatwia to zamianę strategii w przyszłości.
return new UuidOrderIdGenerator();
}
}
Ważne są tu dwie rzeczy.
Pierwsza: metoda zwraca interfejs (OrderIdGenerator), ale tworzy konkretną implementację. To „mały, ale bardzo dorosły" nawyk. Pomaga nie przywiązywać reszty kodu do implementacji.
Druga: decyzja „w tej wersji aplikacji używamy UUID" leży w miejscu, gdzie logicznie powinna leżeć — w konfiguracji, obok wiringu.
@Bean jest też wygodny dla obiektów, które uważasz za infrastrukturę i nie chcesz, żeby „równorzędnie" stały na liście komponentów. Na przykład menedżer zapisu raportów (ReportOutputManager) może być zwykłą klasą przyjmującą Path i zapewniającą zapis w odpowiednim katalogu. Nie musimy zamieniać każdej takiej rzeczy w @Component, szczególnie jeśli jest tworzona z parametrami.
5. Parametry metody @Bean
Najużyteczniejsza część @Bean dla początkującego to fakt, że metoda beanowa może mieć parametry. Spring traktuje je tak samo jak parametry konstruktora: „potrzebuję tego — znajdź odpowiedni bean i przekaż mi go". W efekcie @Bean zamienia się w schludną fabrykę, która nie robi ręcznego new do wszystkiego, lecz uczciwie opiera się na kontenerze.
Wyobraźmy sobie, że chcemy audyt zapisujący wiadomości z timestampem. Niech mamy klasę-dekorator, która potrzebuje Clock:
package com.example.contextflow.infrastructure.audit;
import java.time.Clock;
import java.time.Instant;
import com.example.contextflow.domain.ports.AuditWriter;
public class TimeStampedAuditWriter implements AuditWriter {
private final Clock clock;
private final AuditWriter delegate;
public TimeStampedAuditWriter(Clock clock, AuditWriter delegate) {
// Clock używamy do pobrania „bieżącego czasu" (i dla testowalności)
this.clock = clock;
// Delegate wykonuje rzeczywisty zapis (do konsoli/pliku itp.)
this.delegate = delegate;
}
@Override
public void write(String message) {
// Dodajemy timestamp i przekazujemy dalej do prawdziwego writera
delegate.write(Instant.now(clock) + " | " + message);
}
}
Nie interesuje nas teraz „doskonałość" tej klasy — jest czysto demonstracyjna: potrzebuje zależności Clock i bazowego AuditWriter, który realnie pisze do konsoli/pliku.
Teraz pytanie: jak go zarejestrować? Gdybyśmy zrobili @Component, musielibyśmy myśleć o wyborze delegata i przekazywaniu Clock. A przez @Bean czyta się to jak „złożyć obiekt z części":
import java.time.Clock;
import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.infrastructure.audit.ConsoleAuditWriter;
import com.example.contextflow.infrastructure.audit.TimeStampedAuditWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AuditConfig {
@Bean
Clock clock() {
// Jedno źródło czasu dla aplikacji
return Clock.systemUTC();
}
@Bean
AuditWriter auditWriter(Clock clock) {
// Spring sam podstawi Clock z kontekstu jako parametr metody
return new TimeStampedAuditWriter(clock, new ConsoleAuditWriter());
}
}
Tu pojawia się „uprawnione następne pytanie": a dlaczego ConsoleAuditWriter tworzony jest przez new, a nie też jako bean? Świetne pytanie — i właśnie ono prowadzi do właściwego nawyku: jeśli kontener może dostarczyć zależność, niech ją dostarcza. Wtedy konfig staje się jeszcze czystszy:
import java.time.Clock;
import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.infrastructure.audit.ConsoleAuditWriter;
import com.example.contextflow.infrastructure.audit.TimeStampedAuditWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AuditConfig {
@Bean
Clock clock() {
// Jeden Clock w kontenerze (wygodny do zmiany i do testowania)
return Clock.systemUTC();
}
@Bean
AuditWriter consoleAuditWriter() {
// Osobny bean dla „prawdziwego" writera (konsola w naszym przykładzie)
return new ConsoleAuditWriter();
}
@Bean
AuditWriter auditWriter(Clock clock, AuditWriter consoleAuditWriter) {
// Składamy dekorator z zależności dostarczonych przez kontener
return new TimeStampedAuditWriter(clock, consoleAuditWriter);
}
}
Może tu dziwić parametr AuditWriter consoleAuditWriter: „a skąd Spring wie, że to dokładnie ten AuditWriter, a nie inny?". Kontener może jednoznacznie powiązać zależność z beanem consoleAuditWriter po nazwie parametru i to nam wystarczy. Na razie ważne jest zobaczenie samej zasady: zależność pochodzi z kontenera, a nie jest składana przez zagnieżdżone new.
Ważne, że parametry metody @Bean to nie „przekazywanie parametrów w Javie". To część modelu kontenerowego: Spring podstawia wartości sam i nie musisz ręcznie wywoływać context.getBean(). I to dobra wiadomość, bo getBean() wewnątrz kodu biznesowego szybko prowadzi do stylu service locator (a właśnie od niego uciekaliśmy już przy historii o composition root).
Żeby wizualnie utrwalić, jak to działa, można wyobrazić sobie taki pipeline:
flowchart TD
A["@Configuration class"] --> B["@Bean method: auditWriter(Clock, AuditWriter)"]
B --> C["Spring resolves parameters by type"]
C --> D["Spring calls method once (singleton default)"]
D --> E["Returned object becomes a bean in context"]
To nie „magia wywoływania metod", lecz zwykła fabryka, gdzie kontener bierze na siebie wyszukiwanie zależności i kolejność tworzenia.
6. Praktyka: Clock i id w konfigu
W tym miejscu warto złożyć najkrótszy działający hybrid. Warstwę biznesową zostawiamy na skanowaniu, a przez @Bean dodajemy tylko kilka infrastrukturalnych zależności. To nie jest jeszcze ostateczny układ konfigów ContextFlow; tu ważny jest sam mechanizm: jak skanowanie i Java config współistnieją w jednym kontekście.
Załóżmy, że mamy już ScenarioRunner zarejestrowany przez adnotację stereotypu, np. @Component. Wtedy możemy uruchomić kontekst z dwóch źródeł: skanowanie znajdzie ScenarioRunner, a @Bean dostarczy mu potrzebnych infrastrukturalnych zależności.
Zróbmy główny konfig w pakiecie com.example.contextflow.config.core i świadomie zostawmy go krótki: teraz ważny jest zasada hybrydowego montażu, a nie ostateczna dekompozycja.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackages = {
"com.example.contextflow.application",
"com.example.contextflow.infrastructure"
})
public class CoreConfig {
}
Pakiet config celowo nie skanujemy: jawne konfigi podłączamy ręcznie, bo inaczej pierwsze hybrydowe składanie szybko staje się mętne.
Teraz osobnym konfigiem zarejestrujemy to, czego nie chcemy lub nie możemy skanować:
import java.time.Clock;
import com.example.contextflow.domain.ports.OrderIdGenerator;
import com.example.contextflow.infrastructure.id.UuidOrderIdGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class InfrastructureConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
@Bean
OrderIdGenerator orderIdGenerator() {
return new UuidOrderIdGenerator();
}
}
Tu InfrastructureConfig pełni rolę wspólnego kontenera dla jawnych rejestracji @Bean. Gdy jawnej infrastruktury będzie więcej, konfigi można rozłożyć bardziej szczegółowo; na razie tylko przeszkadzałoby to w zobaczeniu samego mechanizmu.
A teraz start aplikacji — uczciwy AnnotationConfigApplicationContext, bez Boota:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApp {
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext(
CoreConfig.class,
InfrastructureConfig.class
)) {
var runner = context.getBean("scenarioRunner");
System.out.println("Runner bean = " + runner.getClass().getSimpleName());
// Runner bean = ScenarioRunner
}
}
}
Celowo pokazałem tu pobieranie beana po nazwie jako stringu, bo to dobrze łączy się z tematem „nazwa beana i skąd pochodzi". W prawdziwym kodzie częściej będziemy pobierać po typie, a (jeszcze częściej) w ogóle nie pobierać ręcznie, lecz po prostu uruchamiać „główny scenariusz" z jednego dobrze znanego beana.
Co ważne w tej mini-praktyce: nie „przepisaliśmy projektu na @Bean". Dodaliśmy infrastrukturę punktowo, zostawiając warstwę biznesową na skanowaniu. Na pierwszy hybrydowy szkic to wystarczy: widać, że oba sposoby rejestracji spokojnie współistnieją w jednym kontekście i nie wymagają przepisywania wszystkiego.
7. Typowe błędy przy używaniu @Bean
Błąd nr 1: traktowanie @Bean jako „bardziej poprawnego sposobu" i próba przepisania na niego całego projektu.
To częsta reakcja po pierwszym sukcesie z Java config: chce się kontrolować wszystko, tworzyć wszystko ręcznie i żyć w konfigu. Zazwyczaj prowadzi to do gigantycznych klas konfiguracyjnych, w których gubisz strukturę aplikacji. Dobry drogowskaz na dziś jest prosty: klasy biznesowe, które naturalnie się skanują i nie wymagają konfiguracji, spokojnie zostają na stereotypach, a @Bean stosuj tam, gdzie realnie daje przejrzystość lub kontrolę.
Błąd nr 2: robienie w metodach @Bean „życia", a nie „tworzenia".
Metoda beanowa powinna odpowiadać na pytanie „jak stworzyć obiekt", a nie „co zrobić przy starcie aplikacji". Jeśli wewnątrz @Bean drukujesz banery, czytasz pliki, chodzisz po sieci, tworzysz tymczasowe dane „dla scenariusza" — mieszasz montaż z działaniem. Na pierwszych krokach może to „przejść", ale potem takie efekty uboczne stają się niespodziankami przy starcie, testowaniu i debugowaniu.
Błąd nr 3: tworzenie zależności przez zagnieżdżone new, gdy kontener już może je przekazać jako parametry.
Konfig w stylu return new X(new Y(new Z())) wygląda jak „wróciliśmy do ręcznego wiringu, tylko wewnątrz Springa". Przyzwyczajaj się do prostego stylu: niech zależności przychodzą jako parametry metody @Bean. Tak wiring zostaje czytelny, a Spring sam kontroluje tworzenie i reużywanie zależności.
Błąd nr 4: zapominanie, że nazwa metody to domyślna nazwa beana, i nazywanie metod „jak leci".
Gdy projekt jest mały, możesz nazwać metodę a() i nic nie poczujesz. Ale gdy złapiesz błąd wiringu i zobaczysz w wyjątku listę kandydatów „a, b, c", zrozumiesz, że zrobiłeś sobie małą pułapkę. Nazwa metody beanowej to część czytelności kontenera, szczególnie gdy zaczynasz diagnozować problemy.
Błąd nr 5: duplikowanie rejestracji tego samego obiektu przez skanowanie i przez @Bean.
Czasem tę samą klasę oznacza się @Component, a potem jeszcze zwraca się ją z metody @Bean. Kontener nie lubi niejednoznaczności: albo dostaniesz konflikt nazw, albo dwa różne beany tego samego typu, a potem — dziwne błędy wyboru kandydata. Umów się ze sobą: dla każdej klasy wybieramy jedną jasną drogę rejestracji. Jeśli trzymamy obiekt w Java config — to tam żyje, bez „podwójnego meldunku".
GO TO FULL VERSION