JavaRush /Курси /Spring Core /Структура пакетів для сканування

Структура пакетів для сканування

Spring Core
Рівень 5 , Лекція 3
Відкрита

1. Структура пакетів як частина DI

Щойно межу сканування визначають пакетами, структура перестає бути справою смаку. Від того, де лежить клас, залежить, чи побачить його контейнер і чи взагалі збереться застосунок.

Якщо чесно, до Spring багато хто з нас ставиться до пакетів приблизно як до папок на робочому столі: «головне, щоб компілювалося, а порядок… ну… колись». Але щойно ви вмикаєте сканування компонентів, контейнер починає шукати класи в пакетах, і хаос у структурі миттєво перестає бути естетичною проблемою — він стає технічною. Раптом виявляється, що «куди поклали файл» впливає на те, «чи побачить його контейнер» і чи запуститься застосунок узагалі.

Коли сканування ввімкнено, Spring робить дуже просту річ: проходить по заданому root package та його підпакетах, знаходить класи зі стереотипними анотаціями і реєструє їх як BeanDefinition. Тобто пакети — це не просто імена, а карта місцевості, якою «ходить» контейнер. Якщо карта намальована дивно, пошуковий механізм або не знайде потрібне, або знайде забагато.

Уявіть, що @ComponentScan — це робот-пилосос. Ви кажете йому: «Прибирай ось у цій кімнаті». Якщо важливі класи залишилися в коридорі, контейнер їх не побачить, а ви будете лаяти техніку, хоча винні самі. А якщо сказати: «Прибирай усю планету Земля», робот поїде кудись в Антарктиду, втомиться й привезе пінгвінів — тобто зайві компоненти.

2. Root package як корінь застосунку

Коли ми говоримо «root package застосунку», ми фактично обираємо, де закінчується наш код і починається все інше. У світі Spring це особливо важливо, тому що сканування за своєю природою рекурсивне: контейнер іде всередину підпакетів, ніби відкриває папку й дивиться все, що всередині. Тому найбезпечніший і найзрозуміліший підхід для навчального проєкту — та й для більшості невеликих реальних сервісів також — мати один кореневий пакет, усередині якого живе все, що належить застосунку.

Для ContextFlow кореневий пакет за планом — com.example.contextflow. Усе, що ми хочемо віддати на відкуп скануванню, має опинитися всередині підпакетів цього кореня: com.example.contextflow.application..., com.example.contextflow.infrastructure... і так далі. У цьому є й психологічний плюс: відкриваєте проєкт — і відразу бачите межі своєї території. Не потрібно гадати, де «наша земля».

Приклад мінімальної конфігурації сканування — уже знайомої, але тут важливий саме корінь:

package com.example.contextflow.config;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration // Підказуємо Spring: це конфігураційний клас, з нього збираємо контекст
@ComponentScan(basePackages = "com.example.contextflow") // Корінь сканування: усе нижче за цей пакет буде переглядатися
public class AppConfig {
    // Тут можуть бути @Bean-методи, якщо нам потрібна ручна реєстрація бінів
}

Тут рядок com.example.contextflow — це буквально «паркан». Усе всередині — потенційно може стати біном, якщо позначене анотаціями. Усе зовні контейнер за замовчуванням не чіпає.

І так, це трохи схоже на вибір папки проєкту в IDE: якщо ви раптом почнете складати важливі класи в com.example.somethingElse, то самі ж їх виставите за паркан. Spring не злий, він просто чесний.

3. Чотири зони: domain, application, infrastructure, config

Щоб структура пакетів справді допомагала, вона вже за самим шляхом пакета має відповідати на питання «хто ти?». На цьому етапі курсу нам не потрібен архітектурний трактат на 300 сторінок, але потрібна мінімальна дисципліна. Гарна новина: навіть простий поділ на чотири зони різко підвищує читабельність і передбачуваність сканування, особливо для новачка, який ще не вміє «зчитувати» проєкт очима за 30 секунд.

Нижче — базова схема, яка добре лягає на ContextFlow і на наші поточні теми:

Зона Приклад пакета Що там лежить Стереотип найчастіше
domain com.example.contextflow.domain.model звичайні доменні класи (Order, Customer) зазвичай без анотацій
application com.example.contextflow.application.service сервіси сценаріїв та оркестрації @Service / іноді @Component
infrastructure com.example.contextflow.infrastructure.store технічні реалізації портів (in-memory store, console audit) @Repository / @Component
config com.example.contextflow.config вхідна конфігурація, точка входу @Configuration

Ключова думка тут не в тому, що це «єдино правильна архітектура». Ми робимо навчальний проєкт, і нам важливе інше: щоб за шляхом пакета було видно, що це за клас, і щоб сканування можна було ввімкнути одним зрозумілим коренем.

І ще важлива деталь: domain.model зазвичай не має перетворюватися на звалище бінів. Доменні сутності — це просто об’єкти, які створюються під час бізнес-сценарію, наприклад через new Order(...) усередині сервісу, і не живуть у контейнері постійно. Якщо ви почнете чіпляти @Component на Order, це буде щонайменше дивно: замовлення — не сервіс, його не можна створити один раз і використовувати всюди. Воно виникає багато разів і щоразу з різними даними.

4. Приклад: дерево пакетів ContextFlow

Щоб не обговорювати структуру пакетів як абстрактну філософію, давайте подивимося на дуже приземлену картину: як має виглядати проєкт і у файловій системі, і в імені пакета. На цьому кроці нам достатньо невеликої структури, без майбутніх ускладнень, — просто щоб сканування працювало передбачувано, а код читався без зайвої археології.

Ось приклад мінімального дерева пакетів для поточного етапу 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

Зверніть увагу, що це дерево розв'язує одразу дві проблеми. По-перше, сканування стає простим: ми скануємо com.example.contextflow, і контейнер побачить application та infrastructure. По-друге, структура стає читабельною: коли ви бачите infrastructure.store.InMemoryOrderStore, вам не потрібно читати весь код, щоб припустити відповідальність класу — вона вже зашита в назві пакета.

Спеціально тримаємо ці фрагменти мінімальними: тут важливо побачити розкладку по пакетах, а не зібрати повний робочий сценарій замовлення.

Давайте закріпимо це маленькими фрагментами коду. Доменний клас — без stereotype:

package com.example.contextflow.domain.model;

public class Order {
    // Доменна сутність: це не бін, а звичайний об’єкт із даними
    private final String id;

    public Order(String id) {
        // Зазвичай доменні об’єкти створюються під час сценарію, а не живуть як singleton у контексті
        this.id = id;
    }

    public String id() {
        // Простий геттер: доменний шар не зобов’язаний знати щось про Spring
        return id;
    }
}

Сервісний клас — @Service:

package com.example.contextflow.application.service;

import org.springframework.stereotype.Service;

@Service // Application-сервіс: Spring створить один екземпляр і інжектуватиме його туди, де потрібно
public class OrderPricingService {

    public int calculatePrice(int itemsCount) {
        // Умовна бізнес-логіка: тут важливо, що це частина application-шару
        return itemsCount * 100; // умовно, щоб було просто
    }
}

Інфраструктурна реалізація сховища — @Repository:

package com.example.contextflow.infrastructure.store;

import org.springframework.stereotype.Repository;

@Repository // Інфраструктура: технічна деталь (зберігання), яку зручно підміняти іншими реалізаціями
public class InMemoryOrderStore {

    public void save(String orderId) {
        // Для прикладу просто пишемо в консоль, але в реальності тут міг би бути Map/DB/файл тощо
        System.out.println("Збережено orderId=" + orderId); // Збережено orderId=...
    }
}

Сценарний вхід (runner) — тут достатньо @Component:

package com.example.contextflow.application.scenario;

import org.springframework.stereotype.Component;

@Component // «Точка запуску сценарію»: нехай буде компонентом, щоб його можна було отримати з контексту
public class ScenarioRunner {

    public void run() {
        // Демонстрація того, що бін знайдено і сценарій може стартувати
        System.out.println("ContextFlow запущено"); // ContextFlow запущено
    }
}

З погляду сканування все це виглядає дуже «нудно» — і це комплімент. Нудне сканування зазвичай означає передбачуваний старт.

5. Структура пакетів і помилки старту

Тепер буде тонкий момент: структура пакетів важлива не тільки тому, що це красиво. Вона безпосередньо впливає на те, які помилки ви побачите і наскільки швидко зможете їх діагностувати. Ми вже бачили, що надто вузький або надто широкий basePackages ламає старт по-різному. Але й за правильно обраної межі проєкт можна зробити складним: якщо класи розкладені так, що незрозуміло, де що лежить, ви витрачатимете час не на розв'язання проблеми, а на пошук потрібного файла.

Найчастіша практична ситуація виглядає так. Ви додали новий клас, чесно написали @Service, навіть конструктор зробили гарним, а контейнер на старті скаржиться, що бін не знайдено. Новачок у цей момент починає «лагодити» сервіс: додає @Autowired, переписує конструктор, а іноді навіть подумки намагається перевстановити Spring. А проблема зовсім не в цьому: клас просто лежить не там, тобто поза сканованим деревом пакетів. І побачити це можна за п'ять секунд, якщо структура проєкту читабельна.

Покажемо типову помилку на прикладі. Припустімо, ви помилково поклали ConsoleAuditWriter у пакет com.example.audit — поза коренем com.example.contextflow, — а сканування залишили попереднім:

@Configuration // Конфігурація контексту
@ComponentScan(basePackages = "com.example.contextflow") // Скануємо лише дерево com.example.contextflow
public class AppConfig {
    // Усе, що лежить поза com.example.contextflow, сюди автоматично не потрапить
}

Контейнер просто не зобов’язаний бачити com.example.audit.ConsoleAuditWriter. Він цей пакет не сканує. Тому далі, коли якийсь сервіс спробує отримати AuditWriter — неважливо як, хоч через конструктор, хоч через сетер, — ви побачите старих знайомих: NoSuchBeanDefinitionException або UnsatisfiedDependencyException. І це буде не «складна spring-проблема», а просто «клас лежить за парканом».

Якщо дивитися на сканування як на маршрут, це можна намалювати так:

flowchart TD
    A["@ComponentScan: com.example.contextflow"] --> B["application.*"]
    A --> C["infrastructure.*"]
    A --> D["domain.* (зазвичай без @Component)"]
    A -. НЕ СКАНУЄ .-> X["com.example.audit.* (поза root)"]

Діагностика в таких ситуаціях має йти не через «магічні анотації», а через дуже земні питання: «У якому пакеті лежить клас?» і «Чи потрапляє цей пакет під сканування?»

6. Пакети як навігація по проєкту

Коли проєкт росте, читання коду стає окремою навичкою: ви дедалі рідше читаєте кожен рядок підряд і дедалі частіше будуєте карту в голові. Тут структура пакетів працює як навігація в місті. Якщо в місті є вулиці з назвами «Пекарня», «Аптека» і «Парк», ви без GPS здогадаєтеся, де купувати хліб. А якщо все називається «Вулиця 1», «Вулиця 2» і «Вулиця 3», то без провідника — або дуже терплячого мозку — уже важко.

У ContextFlow ми хочемо, щоб роль класу читалася у два кроки: спочатку за пакетом, потім за стереотипною анотацією. Наприклад, якщо ви бачите:

com.example.contextflow.application.service.OrderPlacementService + @Service

то майже напевно розумієте, що це application-сервіс сценарію. А якщо бачите:

com.example.contextflow.infrastructure.store.InMemoryOrderStore + @Repository

то розумієте, що це технічна реалізація зберігання.

Це дає кілька практичних бонусів.

Перший бонус — розмова в команді. Сказати «в infrastructure.notification лежать відправники сповіщень» набагато простіше, ніж «ну це десь у util-ах, але не в тих util-ах, а в інших util-ах…».

Другий бонус — локалізація змін. Якщо ви змінюєте сховище, ви майже не чіпаєте domain і рідко чіпаєте application. Це не «чиста архітектура», а звичайний здоровий глузд: менше випадкових правок — менше випадкових багів.

Третій бонус — менше відчуття магії. Сканування здається магією рівно доти, доки ви не розумієте, по яких папках воно ходить і які ролі ви очікуєте знайти в кожній зоні.

7. Антипатерни: один пакет і util/common/misc

Є такі пакети, які рано чи пізно з’являються майже в будь-якому проєкті, якщо вчасно не поставити їм заслін. Починається все зазвичай із благородної ідеї «складемо туди все спільне», а закінчується тим, що туди летить узагалі все, для чого не знайшлося нормального місця. У результаті пакет починає нагадувати кухонну шухляду, де лежать і ложки, і батарейки, і чиясь забута флешка, і інструкція до мікрохвильовки 2011 року. Називається він зазвичай util, common, misc або helpers.

З погляду сканування такі пакети небезпечні тим, що перетворюють контейнер на лотерею: ви вмикаєте сканування — і в контекст потрапляє якесь «спільне». Що саме? Чому воно стало біном? Не дуже зрозуміло. А з погляду читання проєкту вони ще гірші: util не пояснює відповідальність, а чесно повідомляє лише одне — «сюди складали все, що не придумали, куди покласти».

Якщо дуже хочеться «спільного», краще зробити це чесно: назвати пакет за відповідальністю. Наприклад, якщо у вас є класи, пов'язані з часом, то support.time або infrastructure.time буде зрозуміліше, ніж util. Якщо є форматування рядків — support.format або infrastructure.format. Головне, щоб назва пакета відповідала на питання «що тут живе», а не «ми втомилися думати».

Є й інший антипатерн, особливо частий у новачків: «давайте все складемо в один пакет service». Це здається зручним рівно до того моменту, поки в service не з’являється 25 класів і ви не починаєте відкривати файли навмання, ніби намагаєтеся вгадати, де заховано потрібний ключ.

Наша мета зараз — не ідеальна архітектура. Наша мета — структура, яка допомагає скануванню і допомагає мозку. І простий поділ на domain/application/infrastructure/config уже майже завжди кращий, ніж звалище.

Конфлікти імен бінів

Є один неприємний момент: імʼя біна за замовчуванням не включає пакет. Воно береться з простого імені класу. Тобто com.example.contextflow.infrastructure.audit.ConsoleWriter і com.example.contextflow.infrastructure.notification.ConsoleWriter за замовчуванням обидва захочуть зареєструватися як бін з імʼям consoleWriter. Пакети різні, а імʼя біна одне й те саме. Контейнеру від цього не легше.

Це не привід панікувати, але хороший привід тримати в голові просте правило: якщо ви використовуєте сканування, намагайтеся не плодити однакові прості імена для компонентів. А якщо таке все ж сталося, імʼя можна задати явно:

package com.example.contextflow.infrastructure.audit;

import org.springframework.stereotype.Component;

@Component("auditConsoleWriter") // Явне ім’я біна, щоб не конфліктувати з іншими ConsoleWriter
public class ConsoleWriter {

    public void write(String msg) {
        // Тут ми явно показуємо призначення: це audit-вивід
        System.out.println("[AUDIT] " + msg);
    }
}

І аналогічно для сповіщень:

package com.example.contextflow.infrastructure.notification;

import org.springframework.stereotype.Component;

@Component("notificationConsoleWriter") // Інше явне ім’я біна для сповіщень
public class ConsoleWriter {

    public void send(String msg) {
        // Тут призначення інше: сповіщення, а не аудит
        System.out.println("[NOTIFY] " + msg);
    }
}

Так, це трохи довше. Зате ви не отримаєте конфлікт реєстрації на старті й не будете сидіти над stack trace з обличчям «я просто хотів два ConsoleWriter…».

Зверніть увагу: ми зараз не йдемо в тему вибору між кількома реалізаціями одного інтерфейсу — це окрема історія. Тут ідеться лише про те, що структура пакетів не рятує від конфліктів імен, тому що імʼя біна за замовчуванням нічого не знає про пакети.

Міні‑рефакторинг пакетів і перевірка сканування

На практиці хороша структура з’являється не тому, що ви сіли й одразу зробили ідеальну архітектуру. Зазвичай спочатку пишеться «як вийшло», а потім приходить маленький рефакторинг: класи переїжджають по пакетах, і проєкт стає читабельнішим. Важливо робити це акуратно, тому що в Java є жорсткий зв'язок «шлях файла ↔ package на початку файла». Гарна новина в тому, що IDE вміє це робити, а нам важливо розуміти логіку процесу й не боятися.

Уявімо, що у вас був клас ScenarioRunner у пакеті com.example.contextflow — прямо в корені, — а ви хочете перенести його в com.example.contextflow.application.scenario. Мінімальні зміни в коді виглядають так.

По-перше, змінюється рядок package:

package com.example.contextflow.application.scenario;

import org.springframework.stereotype.Component;

@Component // Після перенесення по пакетах анотація лишається, важливо лише щоб пакет потрапив під сканування
public class ScenarioRunner {

    public void run() {
        // Тіло методу не важливе для сканування: важливо, що Spring узагалі знайшов цей клас
        System.out.println("ContextFlow запущено"); // ContextFlow запущено
    }
}

По-друге, змінюються імпорти там, де ви його використовуєте. Наприклад, у main-класі застосунку, який можна залишити в корені пакета як точку входу проєкту:

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) {
        // Піднімаємо контекст із конфігурації та беремо runner із контейнера
        try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
            context.getBean(ScenarioRunner.class).run();
        }
    }
}

І тепер головний тест рефакторингу — не «чи компілюється». Головний тест — «чи стартує контекст і чи бачить він runner». Бо структуру пакетів ми наводимо в порядок не заради краси, а заради передбачуваного сканування.

Якщо раптом після перенесення ви бачите помилку «bean not found», перша думка має бути не «зламався Spring», а «runner точно лежить усередині com.example.contextflow та його підпакетів?» і «чи @ComponentScan дивиться туди, куди треба?». У такій діагностиці є приємна простота: ви спочатку перевіряєте карту, а не шукаєте баги в логіці бізнес-сервісів.

8. Типові помилки в структурі пакетів під час сканування

Помилка № 1: важливі класи опиняються поза кореневим пакетом, який ви скануєте.
Це найчастіша причина питання «чому Spring не бачить мій @Service?». Зазвичай таке стається після рефакторингу: ви перенесли клас у новий пакет, а межу сканування не перевірили ще раз. У результаті контейнер чесно сканує одне дерево, а ви очікуєте, що він знайде клас в іншому. Лікується це не анотаціями, а дисципліною: один корінь застосунку і всі компоненти всередині нього.

Помилка № 2: структура «все в одному пакеті», після чого сканування наче працює, але проєкт перестає читатися.
Технічно контейнеру байдуже, чи лежить OrderPlacementService поруч із Order і ConsoleAuditWriter. Але вам — не байдуже. Через тиждень ви витрачатимете час на пошук класів, а через місяць почнете боятися чіпати код: «там же все пов'язано з усім». Звичка розділяти domain, application і infrastructure рятує від цього набагато раніше, ніж з'являться справжні складнощі.

Помилка № 3: пакети‑зомбі util/common/misc розростаються і перетворюються на чорну діру.
Спочатку туди падають 2–3 «корисних» класи, потім 10, потім 30, і раптом util стає головним пакетом проєкту, який ніхто не розуміє. Для сканування це теж неприємно: ви починаєте анотувати «утиліти» як компоненти, а контейнер забирає в контекст випадкові речі. Краще одразу називати пакети за відповідальністю й залишати util максимум як тимчасовий карантин — і то ненадовго.

Помилка № 4: однакові прості імена класів у різних пакетах призводять до конфліктів bean names.
Пакети в Java розв'язують конфлікти імен класів, але Spring під час сканування за замовчуванням використовує просте ім'я класу як ім'я біна. Тому два ConsoleWriter у різних пакетах можуть конфліктувати під час реєстрації. У реальному проєкті це зазвичай лікується нормальними іменами класів — наприклад, «AuditConsoleWriter» і «NotificationConsoleSender», — а якщо потрібно, то й явним іменуванням у @Component("...").

Помилка № 5: конфігурація і бізнес-класи перемішані так, що незрозуміло, де знаходиться «точка збирання».
Коли поруч лежать AppConfig, Order, OrderPlacementService і ще десять класів, ви втрачаєте відчуття, де взагалі вхід у застосунок. Навіть зараз, коли конфігурація в нас мінімальна, корисно тримати її в окремій зоні config, щоб читання проєкту починалося з зрозумілого місця: «ось точка входу, ось межі сканування, ось основний сценарій запуску».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ