1. Struktura pakietów jako część DI
Gdy tylko granica scanning jest zadana pakietami, struktura przestaje być kwestią gustu. Od tego, gdzie leży klasa, zależy, czy kontener ją zobaczy i czy aplikacja się w ogóle złoży.
Jeśli być szczerym, do Spring większość z nas traktuje pakiety mniej więcej jak foldery na pulpicie: „ważne, żeby się kompilowało, a porządek… no… kiedyś". Ale gdy tylko włączysz component scanning, kontener zaczyna szukać klas w pakietach, i chaos w strukturze natychmiast przestaje być problemem estetycznym — staje się technicznym. Nagle okazuje się, że „gdzie położono plik" wpływa na to, „czy zobaczy go kontener" i czy aplikacja w ogóle wystartuje.
Gdy scanning jest włączony, Spring robi bardzo prostą rzecz: przechodzi przez zadany root package i jego podpakiety, znajduje klasy z adnotacjami stereotype i rejestruje je jako BeanDefinition. Czyli pakiety — to nie tylko nazwy, lecz mapa terenu, po której „spaceruje" kontenerowy wyszukiwacz. Jeśli mapa jest narysowana dziwnie, wyszukiwacz albo nie znajdzie potrzebnego, albo znajdzie zbyt wiele.
Wyobraź sobie, że @ComponentScan — to odkurzacz-robot. Mówisz mu: „Sprzątaj w tym pokoju". Jeśli ważne klasy zostały w korytarzu — kontener ich nie zobaczy, a ty będziesz narzekać na technologię, choć sam jesteś winien. A jeśli powiedzieć: „Sprzątaj całą planetę Ziemię", robot pojedzie gdzieś na Antarktydę, zmęczy się i przywiezie pingwiny — czyli zbędne komponenty.
2. Root package jako korzeń aplikacji
Gdy mówimy „root package aplikacji", faktycznie wybieramy, gdzie kończy się nasz kod i zaczyna się wszystko inne. W świecie Spring jest to szczególnie ważne, bo scanning jest z natury rekurencyjny: kontener wchodzi w podpakiety, jakby otwierał folder i przeglądał wszystko wewnątrz. Dlatego najbezpieczniejsze i najbardziej zrozumiałe podejście dla projektu szkoleniowego — i dla większości małych prawdziwych serwisów też — mieć jeden główny pakiet, wewnątrz którego żyje wszystko, co należy do aplikacji.
Dla ContextFlow główny pakiet z planu to com.example.contextflow. Wszystko, co chcemy oddać na skanowanie, powinno znaleźć się wewnątrz podpakietów tego korzenia: com.example.contextflow.application..., com.example.contextflow.infrastructure... i tak dalej. Jest tu też psychologiczny plus: otwierasz projekt — i od razu widzisz granice. Nie trzeba zgadywać, gdzie jest „nasze terytorium".
Przykład minimalnej konfiguracji scanning — już znajomej, ale tu ważny jest właśnie korzeń:
package com.example.contextflow.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration // Mówimy Springowi: to klasa konfiguracyjna, z niej składamy kontekst
@ComponentScan(basePackages = "com.example.contextflow") // Korzeń scanning: wszystko poniżej tego pakietu będzie przeglądane
public class AppConfig {
// Tu mogą być metody @Bean, jeśli potrzebna jest ręczna rejestracja bean-ów
}
Tu łańcuch com.example.contextflow — to dosłownie „płot". Wszystko wewnątrz — potencjalnie może stać się bean-em, jeśli jest oznaczone adnotacjami. Wszystko na zewnątrz kontener domyślnie nie rusza.
I tak, to trochę przypomina wybór folderu projektu w IDE: jeśli nagle zaczniesz składać ważne klasy w com.example.somethingElse, sam je wygnasz za płot. Spring nie jest złośliwy, on jest po prostu uczciwy.
3. Cztery strefy: domain, application, infrastructure, config
Żeby struktura pakietów naprawdę pomagała, powinna już po ścieżce pakietu odpowiadać na pytanie „kim jesteś?". Na tym etapie kursu nie potrzebujemy architektonicznego traktatu na 300 stron, ale potrzebna jest minimalna dyscyplina. Dobra wiadomość: nawet prosty podział na cztery strefy gwałtownie zwiększa czytelność i przewidywalność scanning, szczególnie dla nowicjusza, który jeszcze nie umie „skanować" projektu oczami w 30 sekund.
Poniżej bazowy schemat, który dobrze pasuje do ContextFlow i do naszych bieżących tematów:
| Strefa | Przykładowy pakiet | Co tam leży | Stereotype najczęściej |
|---|---|---|---|
| domain | com.example.contextflow.domain.model | zwykłe klasy domenowe (Order, Customer) | zazwyczaj bez adnotacji |
| application | com.example.contextflow.application.service | serwisy scenariuszy i orkiestracji | @Service / niekiedy @Component |
| infrastructure | com.example.contextflow.infrastructure.store | techniczne implementacje portów (in-memory store, console audit) | @Repository / @Component |
| config | com.example.contextflow.config | wejściowa konfiguracja, punkt startowy | @Configuration |
Kluczowa myśl tu nie polega na tym, że to „jedyna słuszna architektura". Robimy projekt szkoleniowy i ważne jest dla nas coś innego: żeby po ścieżce pakietu było widać, co to jest za klasa, i żeby scanning można było włączyć jednym zrozumiałym korzeniem.
I jeszcze ważny szczegół: domain.model zazwyczaj nie powinien zamieniać się w schowek na bean-y. Encje domenowe — to po prostu obiekty, które tworzone są w toku scenariusza biznesowego, na przykład przez new Order(...) wewnątrz serwisu, i nie żyją w kontenerze stale. Jeśli zaczniesz wieszać @Component na Order, będzie to co najmniej dziwne: zamówienie — to nie serwis, nie można go raz stworzyć i używać wszędzie. Pojawia się wiele razy i za każdym razem z innymi danymi.
4. Przykład: drzewo pakietów ContextFlow
Żeby nie omawiać struktury pakietów jako abstrakcyjnej filozofii, przyjrzyjmy się bardzo przyziemnemu obrazkowi: jak powinien wyglądać projekt zarówno w systemie plików, jak i w nazwie pakietu. Na tym kroku wystarczy nam mała struktura, bez przyszłych komplikacji — po prostu żeby scanning działał przewidywalnie, a kod czytał się bez zbędnej archeologii.
Oto przykład minimalnego drzewa pakietów dla bieżącego etapu ContextFlow:
com.example.contextflow
├── domain
│ └── model
│ ├── Order.java
│ └── Customer.java
├── application
│ ├── service
│ │ ├── OrderPlacementService.java
│ │ └── OrderPricingService.java
│ └── scenario
│ └── ScenarioRunner.java
├── infrastructure
│ ├── store
│ │ └── InMemoryOrderStore.java
│ ├── audit
│ │ └── ConsoleAuditWriter.java
│ └── notification
│ └── ConsoleNotificationSender.java
└── config
└── AppConfig.java
Zauważ, że to drzewo rozwiązuje jednocześnie dwa problemy. Po pierwsze, scanning staje się prosty: skanujemy com.example.contextflow i kontener zobaczy application i infrastructure. Po drugie, struktura staje się czytelna: gdy widzisz infrastructure.store.InMemoryOrderStore, nie musisz czytać całego kodu, żeby przewidzieć odpowiedzialność klasy — jest już zawarta w nazwie pakietu.
Celowo trzymamy te fragmenty minimalne: tu ważne jest zobaczenie układu pakietów, a nie złożenie pełnego roboczego scenariusza zamówienia.
Ustalmy to małymi fragmentami kodu. Klasa domenowa — bez stereotype:
package com.example.contextflow.domain.model;
public class Order {
// Encja domenowa: to nie bean, lecz zwykły obiekt z danymi
private final String id;
public Order(String id) {
// Zazwyczaj obiekty domenowe tworzone są w toku scenariusza, a nie żyją jako singleton w kontekście
this.id = id;
}
public String id() {
// Prosty getter: warstwa domenowa nie musi wiedzieć niczego o Spring
return id;
}
}
Klasa serwisowa — @Service:
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service // Application-serwis: Spring stworzy jeden egzemplarz i będzie wstrzykiwać go tam, gdzie potrzeba
public class OrderPricingService {
public int calculatePrice(int itemsCount) {
// Warunkowa logika biznesowa: tu ważne, że to część warstwy application
return itemsCount * 100; // umownie, żeby było prosto
}
}
Infrastrukturalna implementacja magazynu — @Repository:
package com.example.contextflow.infrastructure.store;
import org.springframework.stereotype.Repository;
@Repository // Infrastructure: szczegół techniczny (przechowywanie), który wygodnie podmienić na inne implementacje
public class InMemoryOrderStore {
public void save(String orderId) {
// Dla przykładu po prostu piszemy do konsoli, ale w rzeczywistości tu mógłby być Map/DB/plik itp.
System.out.println("Saved orderId=" + orderId); // Saved orderId=...
}
}
Scenariuszowy punkt wejścia (runner) — tu wystarczy @Component:
package com.example.contextflow.application.scenario;
import org.springframework.stereotype.Component;
@Component // „Punkt uruchomienia scenariusza": niech będzie komponentem, żeby można go było pobrać z kontekstu
public class ScenarioRunner {
public void run() {
// Demonstracja tego, że bean jest znaleziony i scenariusz może startować
System.out.println("ContextFlow started"); // ContextFlow started
}
}
Z punktu widzenia scanning wszystko to wygląda bardzo „nudno" — i to komplement. Nudny scanning zazwyczaj oznacza przewidywalny start.
5. Struktura pakietów a błędy startu
Teraz będzie subtelny moment: struktura pakietów jest ważna nie tylko dlatego, że to ładne. Bezpośrednio wpływa na to, jakie błędy zobaczysz i jak szybko będziesz mógł je diagnozować. Widzieliśmy już, że zbyt wąski lub zbyt szeroki basePackages psuje start na różne sposoby. Ale nawet przy poprawnie wybranej granicy można utrudnić projekt: jeśli klasy są rozłożone tak, że nie wiadomo, gdzie co leży, będziesz tracić czas nie na rozwiązanie problemu, lecz na szukanie potrzebnego pliku.
Najczęstsza praktyczna sytuacja wygląda tak. Dodałeś nową klasę, uczciwie napisałeś @Service, nawet konstruktor zrobiłeś ładny, a kontener przy starcie narzeka, że bean nie znaleziony. Nowicjusz w tym momencie zaczyna „naprawiać" serwis: dodaje @Autowired, przepisuje konstruktor, a czasem nawet mentalnie próbuje przeinstalować Spring. A problem jest zupełnie nie w tym: klasa po prostu leży nie tam, czyli poza skanowanym drzewem pakietów. I zobaczyć to można w pięć sekund, jeśli struktura projektu jest czytelna.
Pokażemy typowy błąd na przykładzie. Powiedzmy, że przez pomyłkę położyłeś ConsoleAuditWriter w pakiecie com.example.audit — poza korzeniem com.example.contextflow — a scanning zostawiłeś poprzedni:
@Configuration // Konfiguracja kontekstu
@ComponentScan(basePackages = "com.example.contextflow") // Skanujemy tylko drzewo com.example.contextflow
public class AppConfig {
// Wszystko, co leży poza com.example.contextflow, tu automatycznie nie trafi
}
Kontener po prostu nie jest zobowiązany widzieć com.example.audit.ConsoleAuditWriter. Tego pakietu nie skanuje. Dlatego dalej, gdy jakiś serwis spróbuje dostać AuditWriter — nieważne jak, czy przez konstruktor, czy przez setter — zobaczysz starych znajomych: NoSuchBeanDefinitionException lub UnsatisfiedDependencyException. I to nie będzie „skomplikowany problem ze Spring", lecz po prostu „klasa leży za płotem".
Jeśli patrzeć na scanning jak na trasę, można to narysować tak:
flowchart TD
A["@ComponentScan: com.example.contextflow"] --> B["application.*"]
A --> C["infrastructure.*"]
A --> D["domain.* (zazwyczaj bez @Component)"]
A -. NIE SKANUJE .-> X["com.example.audit.* (poza root)"]
Diagnostyka w takich sytuacjach powinna iść nie przez „magiczne adnotacje", lecz przez bardzo przyziemne pytania: „W jakim pakiecie leży klasa?" i „Czy ten pakiet trafia pod scanning?"
6. Pakiety jako nawigacja po projekcie
Gdy projekt rośnie, czytanie kodu staje się osobną umiejętnością: coraz rzadziej przechodzisz każdą linijkę z rzędu i coraz częściej budujesz mapę w głowie. Tu struktura pakietów działa jak nawigacja w mieście. Jeśli w mieście są ulice o nazwach „Piekarnia", „Apteka" i „Park", bez GPS domyślisz się, gdzie kupić chleb. A jeśli wszystko nazywa się „Ulica 1", „Ulica 2" i „Ulica 3", to bez przewodnika — lub bardzo cierpliwego mózgu — już trudno.
W ContextFlow chcemy, żeby rola klasy czytała się w dwóch krokach: najpierw po pakiecie, potem po adnotacji stereotype. Na przykład, jeśli widzisz:
com.example.contextflow.application.service.OrderPlacementService + @Service
to prawie na pewno rozumiesz, że to application-serwis scenariusza. A jeśli widzisz:
com.example.contextflow.infrastructure.store.InMemoryOrderStore + @Repository
to rozumiesz, że to techniczna implementacja przechowywania.
To daje kilka praktycznych bonusów.
Pierwszy bonus — rozmowa w zespole. Powiedzieć „w infrastructure.notification leżą nadawcy powiadomień" jest dużo łatwiej niż „no to jest gdzieś w util-ach, ale nie w tych util-ach, a w innych util-ach…".
Drugi bonus — lokalizacja zmian. Jeśli zmieniasz przechowywanie, prawie nie ruszasz domain i rzadko ruszasz application. To nie „czysta architektura", lecz zwykły zdrowy rozsądek: mniej przypadkowych poprawek — mniej przypadkowych błędów.
Trzeci bonus — mniej poczucia magii. Scanning wydaje się magią dokładnie do momentu, gdy nie rozumiesz, po jakich folderach chodzi i jakich ról oczekujesz znaleźć w każdej strefie.
7. Antywzorce: jeden pakiet i util/common/misc
Są takie pakiety, które prędzej czy później pojawiają się prawie w każdym projekcie, jeśli na czas nie postawić im zapory. Zazwyczaj wszystko zaczyna się od szlachetnej idei „złożymy tu wszystko wspólne", a kończy tym, że wrzuca się tam w ogóle wszystko, dla czego nie znalazło się normalnego miejsca. W efekcie pakiet zaczyna przypominać kuchenny szuflad, gdzie leżą i łyżki, i baterie, i czyjaś zapomniana pamięć USB, i instrukcja do mikrofalówki z 2011 roku. Nazywa się zazwyczaj util, common, misc lub helpers.
Z punktu widzenia scanning takie pakiety są niebezpieczne tym, że zamieniają kontener w loterię: włączyłeś skanowanie — i do kontekstu trafiło jakieś „wspólne". Co dokładnie? Dlaczego stało się bean-em? Niespecjalnie wiadomo. A z punktu widzenia czytania projektu są jeszcze gorsze: util nie wyjaśnia odpowiedzialności, lecz uczciwie komunikuje tylko jedno — „tu składano wszystko, dla czego nie wymyślono gdzie położyć".
Jeśli bardzo chce się „wspólne", lepiej zrobić to uczciwie: nazwać pakiet po odpowiedzialności. Na przykład, jeśli masz klasy związane z czasem, to support.time lub infrastructure.time będzie bardziej zrozumiałe niż util. Jeśli jest formatowanie łańcuchów — support.format lub infrastructure.format. Ważne, żeby nazwa pakietu odpowiadała na pytanie „co tu żyje", a nie „zmęczyliśmy się myśleć".
Jest też inny antywzorzec, szczególnie częsty u nowicjuszy: „złóżmy wszystko w jeden pakiet service". Wydaje się wygodne dokładnie do momentu, gdy w service pojawi się 25 klas i zaczniesz otwierać pliki losowo, jakbyś próbował zgadnąć, gdzie schowany jest potrzebny klucz.
Naszym celem teraz — nie idealna architektura. Naszym celem — struktura, która pomaga scanning i pomaga mózgowi. I prosty podział na domain/application/infrastructure/config już prawie zawsze jest lepszy niż bałagan.
Konflikty nazw bean-ów
Jest jeden nieprzyjemny moment: nazwa bean-a domyślnie nie zawiera pakietu. Brana jest z prostej nazwy klasy. Czyli com.example.contextflow.infrastructure.audit.ConsoleWriter i com.example.contextflow.infrastructure.notification.ConsoleWriter domyślnie obydwa będą chciały zostać bean-em o nazwie consoleWriter. Pakiety różne, a nazwa bean-a jest jedna. Kontenerowi od tego nie jest łatwiej.
To nie powód do paniki, ale dobry powód, żeby trzymać w głowie prostą zasadę: jeśli używasz scanning, staraj się nie mnożyć jednorodnych prostych nazw dla komponentów. A jeśli już tak się stało, nazwę można podać jawnie:
package com.example.contextflow.infrastructure.audit;
import org.springframework.stereotype.Component;
@Component("auditConsoleWriter") // Jawna nazwa bean-a, żeby nie konfliktować z innymi ConsoleWriter
public class ConsoleWriter {
public void write(String msg) {
// Tu jawnie pokazujemy przeznaczenie: to wyjście audytowe
System.out.println("[AUDIT] " + msg);
}
}
I analogicznie dla powiadomień:
package com.example.contextflow.infrastructure.notification;
import org.springframework.stereotype.Component;
@Component("notificationConsoleWriter") // Inna jawna nazwa bean-a dla powiadomień
public class ConsoleWriter {
public void send(String msg) {
// Tu przeznaczenie inne: powiadomienia, nie audyt
System.out.println("[NOTIFY] " + msg);
}
}
Tak, to trochę dłużej. Za to nie dostaniesz konfliktu rejestracji przy starcie i nie będziesz siedzieć nad stack trace z miną „chciałem tylko dwa ConsoleWriter…".
Zwróć uwagę: nie wchodzimy teraz w temat wyboru między kilkoma implementacjami jednego interfejsu — to osobna historia. Tu mowa tylko o tym, że struktura pakietów nie ratuje przed konfliktami nazw bean-ów, bo domyślna nazwa bean-a o pakietach nic nie wie.
Mini-refaktoryzacja pakietów i weryfikacja skanowania
W praktyce dobra struktura pojawia się nie dlatego, że usiadłeś i od razu zrobiłeś idealną architekturę. Zazwyczaj najpierw pisze się „jak wyszło", a potem następuje mała refaktoryzacja: klasy przeprowadzają się po pakietach i projekt staje się bardziej czytelny. Ważne jest robić to ostrożnie, bo Java ma ścisły związek „ścieżka pliku ↔ package na początku pliku". Dobra wiadomość polega na tym, że IDE to umieją robić, a nam ważne jest rozumienie logiki procesu i brak strachu.
Wyobraź sobie, że miałeś klasę ScenarioRunner w pakiecie com.example.contextflow — wprost w korzeniu — a chcesz przenieść ją do com.example.contextflow.application.scenario. Minimalne zmiany w kodzie wyglądają tak.
Po pierwsze, zmienia się linijka package:
package com.example.contextflow.application.scenario;
import org.springframework.stereotype.Component;
@Component // Po przeniesieniu po pakietach adnotacja pozostaje, ważne tylko, żeby pakiet trafił pod scanning
public class ScenarioRunner {
public void run() {
// Ciało metody nie jest ważne dla scanning: ważne, że Spring w ogóle znalazł tę klasę
System.out.println("ContextFlow started"); // ContextFlow started
}
}
Po drugie, zmieniają się importy tam, gdzie go używasz. Na przykład w klasie main aplikacji, którą można zostawić w korzeniu pakietu jako punkt wejścia projektu:
package com.example.contextflow;
import com.example.contextflow.application.scenario.ScenarioRunner;
import com.example.contextflow.config.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApp {
public static void main(String[] args) {
// Podnosimy kontekst z konfiguracji i bierzemy bean runnera z kontenera
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
context.getBean(ScenarioRunner.class).run();
}
}
}
I teraz główny test refaktoryzacji — nie „czy się kompiluje". Główny test — „czy kontekst startuje i czy widzi runner". Bo strukturę pakietów porządkujemy nie dla piękna, lecz dla przewidywalnego scanning.
Jeśli nagle po przeniesieniu widzisz błąd „bean not found", pierwsza myśl powinna być nie „Spring się zepsuł", lecz „runner na pewno leży wewnątrz com.example.contextflow i jego podpakietów?" i „czy @ComponentScan na pewno patrzy tam, gdzie trzeba?". W takiej diagnostyce jest przyjemna prostota: najpierw sprawdzasz mapę, a nie szukasz błędów w logice serwisów biznesowych.
8. Typowe błędy w package structure dla scanning
Błąd №1: ważne klasy znalazły się poza głównym pakietem, który skanujesz.
To najczęstsza przyczyna pytania „dlaczego Spring nie widzi mojego @Service?". Zazwyczaj zdarza się to po refaktoryzacji: przeniosłeś klasę do nowego pakietu, a granicy scanning nie sprawdziłeś ponownie. W efekcie kontener uczciwie skanuje jedno drzewo, a ty czekasz, że znajdzie klasę w innym. Leczy się to nie adnotacjami, lecz dyscypliną: jeden korzeń aplikacji i wszystkie komponenty wewnątrz niego.
Błąd №2: struktura „wszystko w jednym pakiecie", po czym scanning niby działa, ale projekt przestaje się czytać.
Technicznie kontenerowi jest wszystko jedno, czy OrderPlacementService leży obok Order i ConsoleAuditWriter. Ale tobie — nie jest wszystko jedno. Za tydzień będziesz tracić czas na szukanie klas, a za miesiąc zaczniesz bać się ruszać kod: „tam przecież wszystko jest ze wszystkim połączone". Nawyk dzielenia na domain, application i infrastructure ratuje od tego znacznie wcześniej, niż pojawią się prawdziwe trudności.
Błąd №3: pakiety-zombie util/common/misc rozrastają się i zamieniają się w czarną dziurę.
Na początku wpadają tam 2–3 „przydatne" klasy, potem 10, potem 30, i nagle util staje się głównym pakietem projektu, którego nikt nie rozumie. Dla scanning też jest to nieprzyjemne: zaczynasz adnotować „narzędzia" jako komponenty, a kontener zabiera do kontekstu przypadkowe rzeczy. Lepiej od razu nazywać pakiety po odpowiedzialności i zostawiać util co najwyżej jako tymczasowy kwarantannowy — i to krótko.
Błąd №4: jednorodne proste nazwy klas w różnych pakietach prowadzą do konfliktów nazw bean-ów.
Pakiety w Javie rozwiązują konflikty nazw klas, ale Spring przy scanning domyślnie używa prostej nazwy klasy jako nazwy bean-a. Dlatego dwa ConsoleWriter w różnych pakietach mogą konfliktować przy rejestracji. W prawdziwym projekcie zazwyczaj leczy się to normalnymi nazwami klas — na przykład „AuditConsoleWriter" i „NotificationConsoleSender" — a jeśli trzeba, to i jawnym nazewnictwem w @Component("...").
Błąd №5: konfiguracja i klasy biznesowe są pomieszane tak, że nie wiadomo, gdzie znajduje się „punkt składania".
Gdy obok leżą AppConfig, Order, OrderPlacementService i jeszcze dziesięć klas, tracisz poczucie, gdzie w ogóle jest wejście do aplikacji. Nawet teraz, gdy konfiguracja jest u nas minimalna, przydatne jest trzymanie jej w osobnej strefie config, żeby czytanie projektu zaczynało się od zrozumiałego miejsca: „oto punkt wejścia, oto granice scanning, oto główny scenariusz uruchamiania".
GO TO FULL VERSION