1. Структура пакетов как часть DI
Как только граница scanning задаётся пакетами, структура перестаёт быть делом вкуса. От того, где лежит класс, зависит, увидит ли его контейнер и вообще соберётся ли приложение.
Если честно, до Spring многие из нас относятся к пакетам примерно как к папкам на рабочем столе: «главное, чтобы компилилось, а порядок… ну… когда-нибудь». Но как только вы включаете component scanning, контейнер начинает искать классы в пакетах, и хаос в структуре мгновенно перестаёт быть эстетической проблемой — он становится технической. Вдруг выясняется, что «куда положили файл» влияет на то, «увидит ли его контейнер» и запустится ли приложение вообще.
Когда scanning включён, Spring делает очень простую вещь: проходит по заданному root package и его подпакетам, находит классы со stereotype-аннотациями и регистрирует их как BeanDefinition. То есть пакеты — это не просто имена, а карта местности, по которой «гуляет» контейнерный поисковик. Если карта нарисована странно, поисковик либо не найдёт нужное, либо найдёт слишком много.
Представьте, что @ComponentScan — это робот-пылесос. Вы говорите ему: «Убирай вот в этой комнате». Если важные классы остались в коридоре — контейнер их не увидит, а вы будете ругаться на технику, хотя виноваты сами. А если сказать: «Убирай всю планету Земля», робот поедет куда-то в Антарктиду, устанет и привезёт пингвинов — то есть лишние компоненты.
2. Root package как корень приложения
Когда мы говорим «root package приложения», мы фактически выбираем, где заканчивается наш код и начинается всё остальное. В мире Spring это особенно важно, потому что scanning по природе рекурсивный: контейнер идёт внутрь подпакетов, как будто открывает папку и смотрит всё, что внутри. Поэтому самый безопасный и понятный подход для учебного проекта — да и для большинства небольших реальных сервисов тоже — иметь один корневой пакет, внутри которого живёт всё, что относится к приложению.
Для ContextFlow корневой пакет по плану — com.example.contextflow. Всё, что мы хотим отдать на откуп scanning, должно оказаться внутри подпакетов этого корня: com.example.contextflow.application..., com.example.contextflow.infrastructure... и так далее. В этом есть и психологический плюс: открываете проект — и сразу видите границы государства. Не нужно гадать, где «наша территория».
Пример минимальной конфигурации scanning — уже знакомой, но здесь важен именно корень:
package com.example.contextflow.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration // Говорим Spring: это конфигурационный класс, из него собираем контекст
@ComponentScan(basePackages = "com.example.contextflow") // Корень scanning: всё ниже этого пакета будет просматриваться
public class AppConfig {
// Здесь может быть @Bean-методы, если нам нужна ручная регистрация бинов
}
Здесь строка com.example.contextflow — это буквально «забор». Всё внутри — потенциально может стать bean-ом, если помечено аннотациями. Всё снаружи контейнер по умолчанию не трогает.
И да, это немного похоже на выбор папки проекта в IDE: если вы вдруг начнёте складывать важные классы в com.example.somethingElse, то сами же их выгоните за забор. Spring не злой, он просто честный.
3. Четыре зоны: domain, application, infrastructure, config
Чтобы структура пакетов реально помогала, она должна уже по пути пакета отвечать на вопрос «кто ты?». На этом этапе курса нам не нужен архитектурный трактат на 300 страниц, но нужна минимальная дисциплина. Хорошая новость: даже простое деление на четыре зоны резко повышает читаемость и предсказуемость scanning, особенно для новичка, который ещё не умеет «сканировать» проект глазами за 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 |
Ключевая мысль здесь не в том, что это «единственно правильная архитектура». Мы делаем учебный проект, и нам важно другое: чтобы по пути пакета было видно, что это за класс, и чтобы scanning можно было включить одним понятным корнем.
И ещё важная деталь: domain.model обычно не должен превращаться в свалку bean-ов. Доменные сущности — это просто объекты, которые создаются в ходе бизнес-сценария, например через new Order(...) внутри сервиса, и не живут в контейнере постоянно. Если вы начнёте вешать @Component на Order, это будет как минимум странно: заказ — не сервис, его нельзя один раз создать и использовать везде. Он возникает много раз и каждый раз с разными данными.
4. Пример: дерево пакетов ContextFlow
Чтобы не обсуждать структуру пакетов как абстрактную философию, давайте посмотрим на очень приземлённую картину: как должен выглядеть проект и в файловой системе, и в имени пакета. На этом шаге нам достаточно небольшой структуры, без будущих усложнений, — просто чтобы scanning работал предсказуемо, а код читался без лишней археологии.
Вот пример минимального дерева пакетов для текущего этапа 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
Заметьте, что это дерево решает сразу две проблемы. Во‑первых, scanning становится простым: мы сканируем com.example.contextflow, и контейнер увидит application и infrastructure. Во‑вторых, структура становится читаемой: когда вы видите infrastructure.store.InMemoryOrderStore, вам не нужно читать весь код, чтобы предположить ответственность класса — она уже зашита в названии пакета.
Специально держим эти фрагменты минимальными: здесь важно увидеть раскладку по пакетам, а не собрать полный рабочий сценарий заказа.
Давайте закрепим это маленькими фрагментами кода. Доменный класс — без stereotype:
package com.example.contextflow.domain.model;
public class Order {
// Доменная сущность: это не bean, а обычный объект с данными
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 // Infrastructure: техническая деталь (хранение), которую удобно подменять на другие реализации
public class InMemoryOrderStore {
public void save(String orderId) {
// Для примера просто пишем в консоль, но в реальности тут мог бы быть Map/DB/файл и т.д.
System.out.println("Saved orderId=" + orderId); // Saved 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 started"); // ContextFlow started
}
}
С точки зрения scanning всё это выглядит очень «скучно» — и это комплимент. Скучный scanning обычно означает предсказуемый старт.
5. Структура пакетов и ошибки старта
Сейчас будет тонкий момент: структура пакетов важна не только потому, что это красиво. Она напрямую влияет на то, какие ошибки вы увидите и насколько быстро сможете их диагностировать. Мы уже видели, что слишком узкий или слишком широкий basePackages ломает старт по-разному. Но даже при правильно выбранной границе проект можно сделать трудным: если классы разложены так, что непонятно, где что лежит, вы будете тратить время не на решение проблемы, а на поиск нужного файла.
Самая частая практическая ситуация выглядит так. Вы добавили новый класс, честно написали @Service, даже конструктор сделали красивый, а контейнер на старте ругается, что bean не найден. Новичок в этот момент начинает «чинить» сервис: добавляет @Autowired, переписывает конструктор, а иногда даже мысленно пытается переустановить Spring. А проблема совсем не в этом: класс просто лежит не там, то есть вне сканируемого дерева пакетов. И увидеть это можно за пять секунд, если структура проекта читаемая.
Покажем типичную ошибку на примере. Допустим, вы по ошибке положили ConsoleAuditWriter в пакет com.example.audit — вне корня com.example.contextflow, — а scanning оставили прежним:
@Configuration // Конфигурация контекста
@ComponentScan(basePackages = "com.example.contextflow") // Сканируем только дерево com.example.contextflow
public class AppConfig {
// Всё, что лежит вне com.example.contextflow, сюда автоматически не попадёт
}
Контейнер просто не обязан видеть com.example.audit.ConsoleAuditWriter. Он этот пакет не сканирует. Поэтому дальше, когда какой-то сервис попытается получить AuditWriter — неважно как, хоть через конструктор, хоть через сеттер, — вы увидите старых знакомых: NoSuchBeanDefinitionException или UnsatisfiedDependencyException. И это будет не «сложная spring-проблема», а просто «класс лежит за забором».
Если смотреть на scanning как на маршрут, это можно нарисовать так:
flowchart TD
A["@ComponentScan: com.example.contextflow"] --> B["application.*"]
A --> C["infrastructure.*"]
A --> D["domain.* (обычно без @Component)"]
A -. НЕ СКАНИРУЕТ .-> X["com.example.audit.* (вне root)"]
Диагностика в таких ситуациях должна идти не через «магические аннотации», а через очень земные вопросы: «В каком пакете лежит класс?» и «Попадает ли этот пакет под scanning?»
6. Пакеты как навигация по проекту
Когда проект растёт, чтение кода становится отдельным навыком: вы всё реже проходите каждую строчку подряд и всё чаще строите карту в голове. Тут структура пакетов работает как навигация в городе. Если в городе есть улицы с названиями «Пекарня», «Аптека» и «Парк», вы без GPS догадаетесь, где покупать хлеб. А если всё называется «Улица 1», «Улица 2» и «Улица 3», то без проводника — или очень терпеливого мозга — уже тяжело.
В ContextFlow мы хотим, чтобы роль класса читалась в два шага: сначала по пакету, потом по stereotype-аннотации. Например, если вы видите:
com.example.contextflow.application.service.OrderPlacementService + @Service
то почти наверняка понимаете, что это application-сервис сценария. А если видите:
com.example.contextflow.infrastructure.store.InMemoryOrderStore + @Repository
то понимаете, что это техническая реализация хранения.
Это даёт несколько практических бонусов.
Первый бонус — разговор в команде. Сказать «в infrastructure.notification лежат отправители уведомлений» намного проще, чем «ну это где-то в util-ах, но не в тех util-ах, а в других util-ах…».
Второй бонус — локализация изменений. Если вы меняете хранение, вы почти не трогаете domain и редко трогаете application. Это не «чистая архитектура», а обычный здравый смысл: меньше случайных правок — меньше случайных багов.
Третий бонус — меньше ощущения магии. Scanning кажется магией ровно до тех пор, пока вы не понимаете, по каким папкам он ходит и какие роли вы ожидаете найти в каждой зоне.
7. Антипаттерны: один пакет и util/common/misc
Есть такие пакеты, которые рано или поздно появляются почти в любом проекте, если вовремя не поставить им заслон. Начинается всё обычно с благой идеи «сложим туда всё общее», а заканчивается тем, что туда летит вообще всё, для чего не нашлось нормального места. В итоге пакет начинает напоминать кухонный ящик, где лежат и ложки, и батарейки, и чья-то забытая флешка, и инструкция к микроволновке 2011 года. Называется он обычно util, common, misc или helpers.
С точки зрения scanning такие пакеты опасны тем, что превращают контейнер в лотерею: вы включили сканирование — и в контекст попало какое-то «общее». Что именно? Почему оно стало bean-ом? Не очень понятно. А с точки зрения чтения проекта они ещё хуже: util не объясняет ответственность, а честно сообщает только одно — «сюда складывали всё, что не придумали куда положить».
Если очень хочется «общее», лучше сделать это честно: назвать пакет по ответственности. Например, если у вас есть классы, связанные со временем, то support.time или infrastructure.time будет понятнее, чем util. Если есть форматирование строк — support.format или infrastructure.format. Главное, чтобы название пакета отвечало на вопрос «что здесь живёт», а не «мы устали думать».
Есть и другой антипаттерн, особенно частый у новичков: «давайте всё сложим в один пакет service». Это кажется удобным ровно до того момента, пока в service не появляется 25 классов и вы не начинаете открывать файлы случайным образом, как будто пытаетесь угадать, где спрятан нужный ключ.
Наша цель сейчас — не идеальная архитектура. Наша цель — структура, которая помогает scanning и помогает мозгу. И простое деление на domain/application/infrastructure/config уже почти всегда лучше, чем свалка.
Конфликты имён bean-ов
Есть один неприятный момент: имя bean-а по умолчанию не включает пакет. Оно берётся из простого имени класса. То есть com.example.contextflow.infrastructure.audit.ConsoleWriter и com.example.contextflow.infrastructure.notification.ConsoleWriter по умолчанию оба захотят стать bean-ом с именем consoleWriter. Пакеты разные, а имя bean-а одно и то же. Контейнеру от этого не легче.
Это не повод паниковать, но хороший повод держать в голове простое правило: если вы используете scanning, старайтесь не плодить одинаковые простые имена для компонентов. А если такое всё же случилось, имя можно задать явно:
package com.example.contextflow.infrastructure.audit;
import org.springframework.stereotype.Component;
@Component("auditConsoleWriter") // Явное имя бина, чтобы не конфликтовать с другими ConsoleWriter
public class ConsoleWriter {
public void write(String msg) {
// Здесь мы явно показываем назначение: это аудит-вывод
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…».
Обратите внимание: мы сейчас не уходим в тему выбора между несколькими реализациями одного интерфейса — это отдельная история. Здесь речь только о том, что структура пакетов не спасает от конфликтов имён, потому что имя bean-а по умолчанию про пакеты не знает.
Мини‑рефакторинг пакетов и проверка сканирования
На практике хорошая структура появляется не потому, что вы сели и сразу сделали идеальную архитектуру. Обычно сначала пишется «как получилось», а потом приходит маленький рефакторинг: классы переезжают по пакетам, и проект становится читаемее. Важно делать это аккуратно, потому что у 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 // После переноса по пакетам аннотация остаётся, важно лишь чтобы пакет попал под scanning
public class ScenarioRunner {
public void run() {
// Тело метода не важно для scanning: важно, что Spring вообще нашёл этот класс
System.out.println("ContextFlow started"); // ContextFlow started
}
}
Во-вторых, меняются импорты там, где вы его используете. Например, в 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». Потому что структуру пакетов мы приводим в порядок не ради красоты, а ради предсказуемого scanning.
Если вдруг после переноса вы видите ошибку «bean not found», первая мысль должна быть не «сломался Spring», а «runner точно лежит внутри com.example.contextflow и его подпакетов?» и «точно ли @ComponentScan смотрит туда, куда надо?». В такой диагностике есть приятная простота: вы сначала проверяете карту, а не ищете баги в логике бизнес-сервисов.
8. Типичные ошибки в package structure под scanning
Ошибка №1: важные классы оказываются вне корневого пакета, который вы сканируете.
Это самая частая причина вопроса «почему Spring не видит мой @Service?». Обычно такое происходит после рефакторинга: вы перенесли класс в новый пакет, а границу scanning не перепроверили. В итоге контейнер честно сканирует одно дерево, а вы ждёте, что он найдёт класс в другом. Лечится это не аннотациями, а дисциплиной: один корень приложения и все компоненты внутри него.
Ошибка №2: структура «всё в одном пакете», после чего scanning вроде работает, но проект перестаёт читаться.
Технически контейнеру всё равно, лежит ли OrderPlacementService рядом с Order и ConsoleAuditWriter. Но вам — не всё равно. Через неделю вы будете тратить время на поиск классов, а через месяц начнёте бояться трогать код: «там же всё связано со всем». Привычка разделять domain, application и infrastructure спасает от этого намного раньше, чем появятся настоящие сложности.
Ошибка №3: пакеты‑зомби util/common/misc разрастаются и превращаются в чёрную дыру.
Поначалу туда падают 2–3 «полезных» класса, потом 10, потом 30, и внезапно util становится главным пакетом проекта, который никто не понимает. Для scanning это тоже неприятно: вы начинаете аннотировать «утилиты» как компоненты, а контейнер забирает в контекст случайные вещи. Лучше сразу называть пакеты по ответственности и оставлять util максимум как временный карантин — и то ненадолго.
Ошибка №4: одинаковые простые имена классов в разных пакетах приводят к конфликтам bean names.
Пакеты в Java решают конфликты имён классов, но Spring при scanning по умолчанию использует простое имя класса как имя bean-а. Поэтому два ConsoleWriter в разных пакетах могут конфликтовать при регистрации. В реальном проекте это обычно лечится нормальными именами классов — например, «AuditConsoleWriter» и «NotificationConsoleSender», — а если нужно, то и явным именованием в @Component("..." ).
Ошибка №5: конфигурация и бизнес‑классы перемешаны так, что непонятно, где находится «точка сборки».
Когда рядом лежат AppConfig, Order, OrderPlacementService и ещё десять классов, вы теряете ощущение, где вообще вход в приложение. Даже сейчас, когда конфигурация у нас минимальная, полезно держать её в отдельной зоне config, чтобы чтение проекта начиналось с понятного места: «вот точка входа, вот границы scanning, вот основной сценарий запуска».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ