1. Potrzebny jest uczciwy baseline
Jeśli po rozmowie o Spring Core od razu przejść do kontenera, łatwo odnieść wrażenie, że framework rozwiązuje jakiś abstrakcyjny, wręcz akademicki problem. Żeby tak się nie stało, potrzebny jest uczciwy baseline: mała aplikacja bez Springa, gdzie widać gołym okiem, kto tworzy obiekty, kto co dostaje w konstruktorze i gdzie w ogóle żyje kod uruchomienia 🛕.
To ważny punkt metodyczny 🗿. Spring lepiej tłumaczyć nie przez frazę „kontener jest wygodny", lecz przez kontrast. Dopóki nie zobaczysz zwykłego kodu Java, który trzeba składać ręcznie, słowa takie jak wiring, object graph i ApplicationContext brzmią zbyt abstrakcyjnie. A gdy tylko zobaczysz wersję plain Java, staje się zrozumiałe, dlaczego temat budowania aplikacji zasługuje na osobną rozmowę inżynierską 🔧.
Dlatego pojawia się u nas przekrojowy projekt kursu — ContextFlow: Order Processing, Notifications & Audit. Nie jako „duży produkt", lecz jako poligon szkoleniowy. Nie budujemy sklepu internetowego na sto encji, nie wchodzimy w web, nie podłączamy bazy i nie udajemy, że pierwszego dnia potrzebujemy świat na poziomie produkcji. Potrzebujemy scenariusza, w którym naturalnie istnieje kilka powiązanych obiektów i kilka efektów ubocznych. To już wystarczy, żeby pokazać, skąd bierze się ból 💣.
2. Pierwszy kawałek przyszłego projektu
Dzisiejszy przekrój ContextFlow jest celowo niewielki. Jest scenariusz „utwórz zamówienie". Żeby zadziałał, trzeba przynajmniej zapisać zamówienie, wysłać powiadomienie i zapisać audyt. Już w tej skali pojawiają się różne role: ktoś odpowiada za przechowywanie, ktoś — za powiadomienie, ktoś — za rejestrację działania. A więc pojawiają się zależności, a wraz z nimi — pytanie o budowanie.
Taka domena jest dobra z dwóch powodów. Po pierwsze, jest zrozumiała: zamówienie, powiadomienie i audyt nie wymagają osobnej lektury o dziedzinie. Po drugie, rośnie naturalnie. Wystarczy chcieć anulowania zamówienia, raportu lub innego kanału powiadomień — i graf obiektów zacznie się komplikować bez żadnej sztucznej dramaturgii. To znaczy sama tema kursu będzie się ujawniać przez naturalny wzrost aplikacji, a nie przez specjalnie wymyśloną złożoność.
Scenariusz można przedstawić bardzo prosto:
sequenceDiagram
participant R as ScenarioRunner
participant S as OrderPlacementService
participant St as OrderStore
participant N as NotificationSender
participant A as AuditWriter
R->>S: placeOrder(command)
S->>St: save(order)
S->>N: send("order created")
S->>A: write("order created")
S-->>R: orderId
Nie ma tu jeszcze nic o Springu 🪄 — i właśnie o to chodzi. Jest zwykły serwis use-case, kilka zależnych obiektów i punkt uruchomienia, który musi to wszystko złożyć. Jeśli dalej trzymać w głowie właśnie ten przekrój, wszystkie kolejne rozmowy o kontenerze będą odbierane nie jako abstrakcja, lecz jako rozwinięcie konkretnej sytuacji.
3. Wersja plain Java projektu
Przyjrzyjmy się najzwyklejszemu startowi aplikacji. Jest main, jest kilka zależności, jest serwis scenariusza i jest ScenarioRunner, który ten scenariusz uruchamia. Na razie wszystko składane ręcznie, bez żadnej magii i bez Springa.
public class ContextFlowApp {
public static void main(String[] args) {
// Ręcznie tworzymy zależności infrastrukturalne (wybór konkretnych implementacji).
OrderStore store = new InMemoryOrderStore(); // Magazyn zamówień: na razie in-memory
NotificationSender sender = new ConsoleNotificationSender(); // Kanał powiadomień: na razie konsola
AuditWriter audit = new ConsoleAuditWriter(); // Audyt: też piszemy do konsoli
// Ręcznie składamy serwis use-case: jawnie przekazujemy zależności przez konstruktor.
OrderPlacementService placement = new OrderPlacementService(store, sender, audit);
// Ręcznie tworzymy punkt uruchomienia scenariusza (bootstrap): ktoś musi „odpalić" use-case.
ScenarioRunner runner = new ScenarioRunner(placement);
runner.run(); // Uruchamiamy scenariusz (np. „utwórz zamówienie")
}
}
Taki kod ma ważną zaletę: jest bardzo uczciwy. Nic nie jest ukryte. Od razu widać, że OrderPlacementService nie może działać bez store, sender i audit. Dla tekstu szkoleniowego jest to szczególnie wartościowe, bo student widzi prawdziwą formę zależności, a nie domyśla się jej z opisu.
Sam serwis też wygląda jak zwykła klasa Java. Nic specjalnego, niepowiązana z frameworkiem i nieoznaczona magią. Używa po prostu tego, co jej przekazano.
public class OrderPlacementService {
// Jawne zależności serwisu: bez nich use-case nie może się wykonać.
private final OrderStore store; // odpowiada za zapisanie zamówienia
private final NotificationSender sender; // odpowiada za wysłanie powiadomienia
private final AuditWriter audit; // odpowiada za zapis audytu
public OrderPlacementService(
OrderStore store,
NotificationSender sender,
AuditWriter audit
) {
// Zapisujemy zależności — dalej serwis będzie ich używał w metodach biznesowych.
this.store = store;
this.sender = sender;
this.audit = audit;
}
public String placeOrder(CreateOrderCommand command) {
// Generujemy identyfikator zamówienia (uproszczony, tylko dla przykładu).
String orderId = "ORD-" + System.currentTimeMillis();
// Zapisujemy obiekt domenowy do magazynu.
store.save(new Order(orderId, command.customerName()));
// Efekt uboczny №1: wysyłamy powiadomienie.
sender.send("Order created: " + orderId);
// Efekt uboczny №2: piszemy do audytu.
audit.write("Created order: " + orderId);
// Zwracamy identyfikator — wynik scenariusza.
return orderId;
}
}
Na razie czyta się to spokojnie 🤯. Nawet przyjemnie. Klasa wygląda normalnie, zależności są jawne, kod uruchomienia krótki. W tej skali projektu ręczne składanie nie wydaje się problemem — i w tym jest uczciwość pierwszego poziomu. Spring nie trzeba ogłaszać zbawicielem ludzkości z powodu jednego konstruktora. Ale forma przyszłego wzrostu jest już widoczna: jeden serwis ma trzy zależności, i ktoś musi je wybrać, stworzyć i powiązać 🔗.
4. Graf obiektów po ludzku
Gdy ktoś mówi „graf obiektów", nowicjusz łatwo słyszy w tym coś matematycznie strasznego 😅. W rzeczywistości to coś znacznie bardziej codziennego. To po prostu mapa tego, jakie obiekty istnieją w aplikacji i kto na kim się opiera. W naszym przykładzie graf jest na razie mały, ale już istnieje: ContextFlowApp tworzy OrderPlacementService, a ten zależy od OrderStore, NotificationSender i AuditWriter.
flowchart TD
App[ContextFlowApp.main] --> Runner[ScenarioRunner]
App --> Placement[OrderPlacementService]
Placement --> Store[OrderStore]
Placement --> Notify[NotificationSender]
Placement --> Audit[AuditWriter]
Gdy graf jest mały, zarządza się nim wzrokowo 👀. Ale właśnie tu rodzi się ważne pytanie: kto odpowiada za reguły tego składania? Kto zdecydował, że NotificationSender będzie konsolowy? Kto zdecydował, że AuditWriter też jest konsolowy? Kto trzyma w głowie, który obiekt jest wspólny, a który można spokojnie tworzyć oddzielnie? Te decyzje już istnieją, tylko jest ich na razie niewiele.
Jeszcze jedno przydatne słowo tutaj — bootstrap. To kod startu aplikacji, miejsce, gdzie składa się ona sama przed wykonaniem pierwszego scenariusza. W prostym świecie Java bootstrap bardzo często żyje bezpośrednio w main. I to jest normalne, gdy aplikacja jest mała. Ale gdy w bootstrap zaczynają spływać szczegóły z różnych zakątków systemu, przestaje być „kilkoma linijkami uruchomienia" i staje się osobną strefą troski.
5. Kod startu rośnie szybciej niż się wydaje
Najzdradliwszą częścią ręcznego łączenia obiektów (wiring) nie jest liczba new. Zdrada leży gdzie indziej: kod uruchomienia zaczyna wiedzieć zbyt wiele o wewnętrznej budowie aplikacji. Powiedzmy, że dodałeś drugi scenariusz — anulowanie zamówienia. Teraz potrzebny jest kolejny serwis, i też wymaga zależności.
// Nowy serwis use-case: anulowanie zamówienia (używa już stworzonych zależności).
OrderCancellationService cancellation = new OrderCancellationService(store, audit); // ważne: ten sam store/audit
// Kolejny scenariusz: raporty (potrzebuje tylko store).
ReportingService reporting = new ReportingService(store); // znowu ważne: ten sam store, żeby widzieć te same dane
// Punkt uruchomienia teraz zna kilka scenariuszy i musi to wszystko połączyć.
ScenarioRunner runner = new ScenarioRunner(placement, cancellation, reporting);
W tym momencie main już wie, jakich zależności potrzebuje placement, jakich potrzebuje cancellation, które obiekty muszą być wspólne, a które nie. I właśnie tu zaczyna się ciekawa inżynierska rozwidlenie 🤔. Na poziomie jednej nowej linijki wydaje się, że nic strasznego się nie stało. Ale na poziomie architektury kod uruchomienia stopniowo zamienia się w miejsce, gdzie skupia się zbyt wiele wiedzy o aplikacji.
Jest jeszcze bardziej subtelny moment 👀. Niektóre obiekty nie wystarczy tylko stworzyć — trzeba je stworzyć właściwie względem siebie nawzajem. Na przykład jeśli OrderPlacementService zapisuje zamówienie w jednym InMemoryOrderStore, a OrderCancellationService szuka go już w innym, aplikacja będzie działać, ale niepoprawnie. Czyli ręczny wiring rodzi nie tylko długi bootstrap, ale i nową klasę błędów: błędów budowania grafu, które nie zawsze ujawniają się jawnym wyjątkiem 🧨.
Z tego powodu wzrost ręcznego wiring jest zwodniczy. Z zewnątrz wydaje się, że po prostu dodajesz kolejny serwis. Ale wewnątrz pojawiają się nowe reguły uzgadniania: które obiekty muszą być wspólne, gdzie jest punkt wyboru implementacji, który obiekt powinien żyć przez cały runtime aplikacji, a który można tworzyć na każde wywołanie. I właśnie ta niewidzialna część rośnie potem szybciej niż same metody biznesowe.
6. Ręczne wiązanie nie jest przestępstwem, ale ma swój sufit
W tym miejscu ważne jest, żeby nie wyciągnąć błędnego wniosku: „znaczy, ręczne składanie jest zawsze złe" 🤬. Nie. Dla małego narzędzia, krótkiego skryptu jednorazowego lub bardzo prostej aplikacji ręczny wiring może być zupełnie adekwatnym wyborem. I uczciwy kurs o Springu powinien to powiedzieć. Framework nie jest potrzebny tam, gdzie problem jeszcze nie powstał.
Ale ręczny wiring ma całkiem obserwowalny sufit. Uderza nie w liczbę linii, lecz w liczbę decyzji o składaniu, które trzeba trzymać w głowie. Gdy jest jedna zależność i jeden scenariusz, wszystko jeszcze do zniesienia. Gdy scenariuszy jest kilka, implementacji robi się więcej niż jedna, a zachowanie aplikacji zaczyna zależeć od konfiguracji lub trybu uruchomienia, składanie staje się osobnym zadaniem.
Można to zobaczyć w bardzo kompaktowym kontraście:
| Jeszcze spokojnie | Zaczyna się napięcie inżynierskie |
|---|---|
| jeden serwis use-case | kilka scenariuszy ze wspólnymi obiektami |
| jedna implementacja zależności | kilka implementacji jednej roli |
| main zna parę new | bootstrap zna wewnętrzną strukturę połowy aplikacji |
| błędy zwykle widać od razu | część problemów to błędy samego wiringu |
Właśnie tu pojawia się dojrzała myśl 😱. Okazuje się, że aplikacja ma nie tylko logikę biznesową, lecz i logikę własnego składania. Dopóki programista trzyma tę logikę ręcznie, żyje ona albo w main, albo w ręcznie robionych fabrykach, albo rozmywa się po kodzie. Spring nie pojawia się tam, gdzie leniwo pisać new, lecz tam, gdzie składanie aplikacji stało się już osobnym zadaniem inżynierskim 🔧.
7. Potrzebujemy ContextFlow
Projekt przekrojowy jest potrzebny nie dla ładnej nazwy. Jest potrzebny po to, żeby wszystkie kolejne tematy opierały się na jednym i tym samym kontekście dziedzinowym. Jeśli dziś tłumaczyć ręczny wiring na zamówieniach, jutro lifecycle — na bibliotece książek, a pojutrze events — na przykładzie bankowym, student traci siły nie na mechanikę Springa, lecz na ciągłą zmianę dekoracji.
ContextFlow jest dobry tym, że pozwala rosnąć naturalnie. Dziś wystarczyło nam OrderPlacementService, OrderStore, NotificationSender, AuditWriter i ScenarioRunner. Trochę dalej projekt spokojnie udźwignie kilka implementacji powiadomień, konfigurację trybów, zasoby z szablonami, application events, post-processors i nawet zachowanie oparte na proxy. Przy tym domena pozostanie na tyle prosta, żeby nie zasłonić tematu kursu.
Właśnie dlatego dziś nie wyrzucamy całego katalogu encji, serwisów i pakietów. Dla pierwszego dnia byłoby to nie plusem, lecz przeciążeniem. Na razie wystarczy zobaczyć jeden żywy łańcuch biznesowy i zauważyć, jak szybko wokół niego pojawia się pytanie o składanie. Następny naturalny krok dotyczy już nie samej domeny, lecz jakości tego kodu plain Java: czy może działać, będąc jednocześnie architektonicznie niewygodnym? Odpowiedź, niestety, tak 😐. I właśnie tu zaczyna się silne powiązanie, ukryte zależności i cena zmian.
8. Typowe błędy 👺
Błąd №1: od razu i na zawsze uznać ręczny wiring za zło.
Taki wniosek jest zbyt gruby. Na bardzo małym kodzie ręczne składanie może być normalne. Ważne nie jest zakaz new, lecz zrozumienie, gdzie liczba decyzji o składaniu staje się samodzielnym problemem.
Błąd №2: mierzyć wzrost złożoności tylko liczbą linii.
Czasem main jest wciąż krótki, ale wie już zbyt wiele o strukturze aplikacji. Problem ręcznego składania częściej ujawnia się jako wzrost powiązań i reguł uzgadniania, a nie po prostu jako długa metoda.
Błąd №3: zbyt wcześnie rozdymać domenę.
Jeśli pierwszego dnia wciągnąć płatności, dostawę, użytkowników, statusy, rabaty i integracje, temat kursu rozpłynie się w dziedzinie. Potrzebujemy nie „całego biznesu naraz", lecz właściwego przekroju, na którym dobrze widać problem składania.
Błąd №4: myśleć, że ból zaczyna się dopiero gdy aplikacja padnie.
Jedna z najbardziej nieprzyjemnych cech ręcznego składania polega na tym, że część błędów nie powala programu od razu. Po prostu sprawia, że jest on trudny do zmiany, czytania i lokalnej weryfikacji. A to często groźniejsze niż jawne padnięcie 👍.
GO TO FULL VERSION