1. Przeniesienie punktu wejścia z main
Jeśli już czujesz, że main stopniowo zamienia się w „pokój, gdzie leży wszystko" — gratuluję: kształtuje się normalna inżynierska alergia. W małym projekcie main z ręcznym składaniem jest jeszcze do zniesienia, ale w projekcie rozmiarów „trochę większy niż szkolny" szybko zaczyna przeszkadzać: rośnie, psuje się od zmian i zamienia w miejsce, do którego straszno wchodzić bez kasku.
Powiedzmy uczciwie: composition root w main — to już dobry krok w porównaniu z „tworzymy zależności wewnątrz serwisów". Ale ma nieprzyjemną dynamikę: im więcej klas, tym dłuższy kod startowy. I co najgorsze — ten kod nie niesie biznesowej wartości. Po prostu „łączy kabelki".
Widzieliśmy już na małych beanach, że kontener najpierw czyta opis składania, a potem materializuje go w obiekty. Teraz ten sam schemat przenosimy na prawdziwy fragment ContextFlow: klas biznesowych nie przepisujemy pod Spring, przenosimy tylko wiring i punkt wejścia.
Do tego momentu mamy już wersję plain Java projektu z normalnym DI: porty, ich implementacje i OrderPlacementService, który otrzymuje zależności przez konstruktor. Dziś domeny nie rozszerzamy. Naszym zadaniem jest, żeby ten sam graf zaczął się składać przez ApplicationContext, a main przestał być magazynem new.
Composition root w kontekście Spring
Wyobraź sobie, że twoja aplikacja — to klocki LEGO. W plain Java sam ręcznie bierzesz każdy klocek, łączysz i mówisz: „Proszę, robot gotowy". W Springu zamiast tego przynosisz kontenerowi instrukcję składania, a on już sam skleja robota. Instrukcja składania w naszym dzisiejszym formacie — to AppConfig z @Configuration i metodami @Bean.
Tu celowo pozostajemy przy maksymalnie uczciwym wariancie: bez component scanning, bez @Service i bez żadnej adnotacyjnej automatyki, która ukrywa wiring. Chcemy zobaczyć sam mechanizm: kontener zarządza tworzeniem obiektów, ale jawnie opisujemy, jakie obiekty tworzyć i jak zależą od siebie.
W rezultacie kompozycja pozostaje przejrzysta. Zmienia się tylko miejsce, gdzie się odbywa: zamiast main staje się konfiguracją kontenera. I tak, to wciąż „ręczne składanie", tylko teraz zarządza nim kontener, a nie przypadkowo rozrośnięty main.
2. Minimalna struktura projektu
Dopóki projekt jest mały, jest pokusa, żeby wrzucić wszystko do jednego pakietu i nazwać go service. Ale to jak trzymać makaron, cukier i śruby M6 w jednym słoiku: niby wlazło, ale radości mało. Na tym etapie wystarczy nam minimalna dyscyplina: punkt wejścia osobno, konfiguracja osobno, kod biznesowy osobno.
Jeden z wygodnych minimalnych wariantów wygląda tak:
src/main/java/com/example/contextflow
├─ Application.java
├─ config
│ └─ AppConfig.java
├─ application
│ ├─ ScenarioRunner.java
│ └─ OrderPlacementService.java
├─ domain/ports
│ ├─ OrderStore.java
│ ├─ NotificationSender.java
│ └─ AuditWriter.java
└─ infrastructure
├─ store
│ └─ InMemoryOrderStore.java
├─ notification
│ └─ ConsoleNotificationSender.java
└─ audit
└─ ConsoleAuditWriter.java
To nie dogmat, lecz minimalna dyscyplina, która nie miesza wejścia, składania i kodu biznesowego. Jeśli w twojej aktualnej wersji projektu są już dodatkowe porty jak DiscountPolicy lub OrderIdGenerator, podłącz je tym samym sposobem: do pierwszego uczciwego startu kontenera wystarczy nam ten minimalny graf.
3. Wersja „przed": start w plain Java
Żeby ocenić postęp, warto ustalić punkt startowy. W wersji plain Java uruchamialiśmy aplikację tak: tworzyliśmy implementacje portów, potem serwis, potem runner. To i był nasz composition root.
Przykład mocno uproszczonego startu wygląda tak:
public class PlainJavaMain {
public static void main(String[] args) {
// Infrastruktura (implementacje portów) tworzona ręcznie
var store = new InMemoryOrderStore();
var sender = new ConsoleNotificationSender();
var audit = new ConsoleAuditWriter();
// Serwis jawnie otrzymuje zależności przez konstruktor
var service = new OrderPlacementService(store, sender, audit);
// Startowy scenariusz też uruchamiany ręcznie
new ScenarioRunner(service).run();
}
}
Wczoraj było to już nieźle, bo klasy biznesowe nie tworzyły zależności same. Ale spójrz na to oczami przyszłego siebie, który dodał OrderIdGenerator, DiscountPolicy, sprawozdawczość, różne kanały powiadomień i audyt do pliku. main zacznie puchnąć jak żaba na kursach aktorskich: „potrzeba więcej powietrza".
Naszym celem dziś jest sprawić, żeby main znowu był mały. Nie „mały dla zasady", lecz dlatego, że powinna mieć jedną ograniczoną odpowiedzialność: uruchomić aplikację.
4. Infrastrukturalne @Bean: porty i implementacje
Teraz zrobimy kluczowy krok: przeniesiemy tworzenie niskopoziomowych zależności — portów i ich implementacji — z main do AppConfig. Przypomina to przenoszenie okablowania z „prowizorki" do rozdzielnicy: prąd będzie ten sam, ale teraz wszystko leży tam, gdzie leżeć powinno.
Zacznijmy od samego szkieletu konfiguracji:
import org.springframework.context.annotation.Configuration;
@Configuration // Mówimy Springowi: to klasa z opisem składania (wiring)
public class AppConfig {
// Na razie puste: to normalna starta dla przyszłych metod @Bean
}
Tak, na razie wygląda to jak „stworzyłem pusty plik, i mam już konfigurację". Ale to i jest uczciwy punkt składania: tu stopniowo pojawią się metody @Bean.
Teraz rejestrujemy OrderStore i jego implementację:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean // Rejestrujemy w kontenerze implementację portu OrderStore
public OrderStore orderStore() {
// Tu wybieramy konkretną implementację infrastrukturalną
return new InMemoryOrderStore();
}
}
Teraz dodajemy audyt i powiadomienia. Ważne: celowo wybieramy konsolowe implementacje — potrzebujemy przejrzystego startu, a nie inżynierskiego festiwalu.
@Bean // Port powiadomień: zwracamy wybraną implementację
public NotificationSender notificationSender() {
return new ConsoleNotificationSender();
}
@Bean // Port audytu: na razie piszemy do konsoli
public AuditWriter auditWriter() {
return new ConsoleAuditWriter();
}
Jeśli w twojej wersji projektu są już dodatkowe zależności jak DiscountPolicy lub generator identyfikatorów, nie potrzebują osobnej filozofii: na każdą przypada taka sama mała metoda @Bean. Teraz ważniejsze jest zobaczenie samego rysunku grafu niż ciąganie do pierwszego startu wszystkich możliwych wariantów.
5. Składanie serwisów przez parametry @Bean
Serwisy — to następny poziom grafu: zależą od portów. I tu potrzebne jest główne narzędzie dzisiejszego dnia — parametry metody @Bean. To sposób powiedzenia Springowi: „Stwórz mi serwis, a zależności podstaw z kontekstu".
Zostawiamy tę samą sygnaturę serwisu co w wersji plain Java. Nie zmienia się logika biznesowa, lecz miejsce, gdzie jest składana.
@Bean // Serwis — to też bean: kontener będzie zarządzał jego cyklem życia
public OrderPlacementService orderPlacementService(
OrderStore store, // Spring podstawi bean OrderStore
NotificationSender sender, // Spring podstawi bean NotificationSender
AuditWriter auditWriter // Spring podstawi bean AuditWriter
) {
// Ważne: nie tworzymy zależności ręcznie, tylko je opisujemy
return new OrderPlacementService(store, sender, auditWriter);
}
Jeśli w twojej wersji projektu są już dodatkowe porty, dodają się tu jako takie same parametry. Logika się nie zmienia: serwis nadal otrzymuje gotowe zależności z zewnątrz, a nie wyciąga ich sam.
Żeby main nie znał szczegółów scenariuszy, wydzielimy osobny startowy bean ScenarioRunner właśnie tu. On i stanie się pierwszym punktem, któremu przekażemy sterowanie po starcie kontekstu.
@Bean // Startowy scenariusz: osobny obiekt, który można wygodnie uruchamiać
public ScenarioRunner scenarioRunner(OrderPlacementService service) {
// Spring stworzy serwis i przekaże go tutaj
return new ScenarioRunner(service);
}
Zwróć uwagę na przyjemny efekt: ScenarioRunner staje się zwykłym obiektem, który też jest tworzony przez kontener, i przestajemy ręcznie przenosić serwisy do runnera.
6. Nowy punkt wejścia: start przez kontekst
Teraz będzie moment, dla którego to wszystko robiliśmy: main staje się krótki i nudny. A nudny main — to komplement. Im mniej niespodzianek przy starcie, tym mniej „magicznych padnięć" w runtime.
Oto nowy punkt wejścia aplikacji:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Application {
public static void main(String[] args) {
// Kontekst tworzymy na bazie konfiguracji, gdzie opisane są wszystkie @Bean
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// W warstwie bootstrap normalne jest pobranie „startowego" bean-a i uruchomienie scenariusza
context.getBean(ScenarioRunner.class).run();
} // Kontekst poprawnie się zamyka (zwalnia zasoby bean-ów)
}
}
Tu są tylko dwie logiczne operacje: stworzyć ApplicationContext i wziąć startowy bean. Tak, używamy getBean() i dziś jest to normalne, bo jesteśmy w warstwie bootstrap. Nie ciągniemy ApplicationContext do klas biznesowych, nie zamieniamy projektu w „service locator" i nie urządzamy kontenerowi codziennych rozmów w stylu „daj mi to, daj mi tamto".
I osobno ucieszymy się z try-with-resources: kontekst zamyka się poprawnie, nawet jeśli twój scenariusz padł. To nawyk poziomu „przyszłe ty powie dziękuję".
7. Kod biznesowy pozostaje plain Java
Gdy ludzie po raz pierwszy „podłączają Spring", często pojawia się u nich dziwna myśl: „Teraz trzeba przepisać wszystkie klasy pod Spring". To fałszywy alarm. W rzeczywistości Spring lubi, gdy twój kod biznesowy pozostaje zwykłymi klasami Java. Kontenerowi ważny jest fakt, że on je tworzy i łączy, a nie to, że obwiesiłeś wszystko adnotacjami jak choinkę.
Na przykład OrderPlacementService może wyglądać absolutnie nudno i bez specyfiki Spring:
public class OrderPlacementService {
private final OrderStore store;
private final NotificationSender sender;
private final AuditWriter auditWriter;
public OrderPlacementService(OrderStore store,
NotificationSender sender,
AuditWriter auditWriter) {
// Wszystkie zależności przychodzą z zewnątrz przez konstruktor
this.store = store;
this.sender = sender;
this.auditWriter = auditWriter;
}
public void placeOrder(String orderId) {
// Dalej idzie zwykła logika biznesowa, a nie odwoływanie się do kontenera
}
}
A ScenarioRunner całkowicie może przekazywać sterowanie serwisowi, w ogóle nie podejrzewając o istnieniu kontenera:
public void run() {
// Startowy scenariusz po prostu przekazuje sterowanie serwisowi
orderPlacementService.placeOrder("A-100");
}
Jeśli twój runner przekazuje nie łańcuch, lecz osobną komendę, dla kontenera nic się nie zmienia: Spring wciąż tylko składa graf obiektów.
Klasyczne zwycięstwo dzisiejszego dnia brzmi tak: kontener wie o twojej aplikacji, ale twoja aplikacja nie musi wiedzieć o kontenerze — przynajmniej na poziomie domeny i serwisów. Czyni to kod prostszym dla testów, refaktoryzacji i zwykłego zrozumienia.
8. Porównanie „ręczny start" i start Spring
Żeby utrwalić obraz, przydatne jest zobaczenie różnicy nie tylko intuicyjnie, lecz i w formie starannego porównania. Nie nazywamy poprzedniego podejścia „złym" — był to logiczny etap. Po prostu widzimy, dlaczego kontenerowe składanie daje bardziej stabilną trajektorię wzrostu.
| Pytanie | Composition root w plain Java | Spring AppConfig + ApplicationContext |
|---|---|---|
| Gdzie składany jest graf obiektów? | W main (lub ręcznej fabryce) | W AppConfig przez @Bean |
| Co robi main? | Tworzy wszystko i uruchamia scenariusz | Podnosi kontekst i uruchamia startowy bean |
| Co się stanie przy dodaniu nowych zależności? | main rośnie, często się psuje | Zmienia się konfiguracja, main prawie nie ruszamy |
| Jak podmienić implementację? | Poprawiamy main i składamy ręcznie | Zmieniamy jeden @Bean (lub jego logikę) |
| Na ile zrozumiały jest wiring? | Zrozumiały, ale szybko staje się długi | Zrozumiały i scentralizowany w konfiguracji |
I teraz mały schemat, żeby to „usiadło" wizualnie:
flowchart TD
A[main] -->|ręczny new| S[ScenarioRunner]
S --> OP[OrderPlacementService]
OP --> OS[OrderStore]
OP --> NS[NotificationSender]
OP --> AW[AuditWriter]
A to samo po przejściu na Spring. Ważne: graf biznesowy się nie zmienia, zmienia się tylko sposób składania.
flowchart TD
A[main] --> C[AnnotationConfigApplicationContext]
C -->|getBean| S[ScenarioRunner]
C -->|tworzy i łączy| OP[OrderPlacementService]
OP --> OS[OrderStore]
OP --> NS[NotificationSender]
OP --> AW[AuditWriter]
Na tych schematach łatwo zobaczyć główną myśl: punkt sterowania składaniem przenosi się do kontenera. To właśnie praktyczny sens IoC.
9. Weryfikacja startu kontekstu
Gdy zaczyna się życie z kontenerem, zmienia się też nawyk szukania błędów. Wcześniej myśl była prosta: „jeśli aplikacja padła, gdzieś NPE w kodzie biznesowym". Teraz dochodzi jeszcze jedna warstwa: „możliwe, że kontener nie mógł złożyć grafu obiektów". To normalne. Spring uczciwie pada na starcie, jeśli opisałeś sprzeczne składanie — i to nawet dobrze, bo błąd nie chowa się „gdzieś potem".
Jeśli kontekst się nie podnosi, zaczynaj od najprostszego: czy AppConfig jest naprawdę oznaczony jako @Configuration, czy potrzebne metody są naprawdę oznaczone jako @Bean, i czy nie ma sytuacji, gdy stworzyłeś @Bean dla serwisu, ale zapomniałeś @Bean dla jego zależności. Teraz nie ma jeszcze tylu poruszających się części, więc diagnostyka zwykle sprowadza się do tego, że kontenerowi nie ma co podstawić do parametrów.
Jeszcze jedna przydatna weryfikacja brzmi śmiesznie, ale zdarza się często: upewnij się, że uruchamiasz ten main, który myślisz. Po migracji wielu zostawia obok PlainJavaMain, a uruchamia z przyzwyczajenia właśnie jego — i potem dziwi się, czemu „Spring nie działa". Spring w tym momencie siedzi w kącie i po cichu się smuci, bo w ogóle nie zaproszono go na imprezę.
10. Typowe błędy przy przenoszeniu punktu wejścia
Błąd №1: dwa wejścia do aplikacji i zamieszanie, które z nich jest prawdziwe.
Często po migracji zostają PlainJavaMain i nowy Application. To normalne dla porównania, ale ważne jest wybranie jednego „bojowego" wejścia. Inaczej będziesz naprawiał AppConfig, a uruchamiał stary main — i dostawał poczucie, że kontener „nie działa".
Błąd №2: zapomnieć @Configuration lub @Bean i czekać, że Spring „sam zgadnie".
Spring nie jest telepatą — choć czasem wydaje się, że bardzo się stara. Jeśli klasa konfiguracyjna nie jest oznaczona jako @Configuration, to dla kontenera to po prostu zwykła klasa. Jeśli metoda nie jest oznaczona jako @Bean, Spring nie będzie jej wywoływał jako fabryki i nie zarejestruje wyniku.
Błąd №3: długi łańcuch new w main po podłączeniu kontenera.
Czasem main po migracji wygląda tak: „podnieść kontekst → ale dla pewności i tak ręcznie stworzyć serwis → uruchomić". To łamie ideę IoC: dostajesz dwa równoległe światy, które mogą się rozchodzić w konfiguracji i zachowaniu.
Błąd №4: mieszać odpowiedzialności i zamieniać AppConfig w drugą warstwę serwisową.
AppConfig powinien opisywać składanie grafu i minimum „klejarskiej" logiki. Jak tylko w konfiguracji pojawia się logika biznesowa — „jeśli zamówienie drogie, rób tak" — odpowiedzialność zaczyna się rozrastać. Konfiguracja powinna decydować, jakie obiekty stworzyć, a nie jak wykonywać scenariusze.
Błąd №5: ciąganie ApplicationContext do kodu biznesowego przez getBean() „dla wygody".
W tym dniu getBean() jest dopuszczalny w main jako część bootstrap. Ale jeśli zaczniesz wywoływać context.getBean() wewnątrz OrderPlacementService, cofniesz się do ukrytych zależności — tylko zamiast new będziesz miał „wyszukiwanie w kontenerze". To inny rodzaj chaosu, ale chaos i tak.
GO TO FULL VERSION