1. Введение
Когда приложение запускается — это приятно. Но у Spring-проектов есть хитрый побочный эффект: они часто запускаются даже тогда, когда вы не до конца понимаете, почему именно всё работает. В результате в голове остаётся “магия”: аннотации, какие-то конфиги, где-то events, где-то аспект… и всё это как будто не про одну систему, а про разные демо-куски. Финальная сборка нужна, чтобы у вас появилась цельная mental model: один путь, одна логика, один ApplicationContext.
В этой лекции мы будем держаться простой оси: main() → создание контекста → регистрация конфигурации → чтение окружения/профилей/свойств → создание бинов → запуск сценария. Всё остальное (ресурсы, сообщения, события, post-processors, AOP, XML-ветка, тесты) будем не перечислять, а аккуратно “вкладывать” в этот путь, как детали в общий механизм.
Чтобы не потеряться, вот компактная схема “что с чем связано”:
flowchart TD A["main()"] --> B["ApplicationContext"] B --> C["Config modules @Import"] B --> D["Environment: profiles + properties"] B --> E["Bean definitions -> beans"] E --> F["ScenarioRunner.run()"] F --> G["Use-case services"] G --> H["publishEvent()"] H --> I["@EventListener side effects"] E --> J["Support: BFPP/BPP/FactoryBean/AOP"] C --> K["Legacy XML bridge"] B --> L["Spring Test context"]
Смысл диаграммы не в том, чтобы выучить стрелки. Смысл в том, что это одна система, где каждый механизм имеет “точку входа” в общий поток.
2. Точка входа: main() и ApplicationContext
Нормальный старт приложения — это тот момент, когда вы должны видеть, кто здесь главный. И в нашем курсе главный не “аннотация дня”, а ApplicationContext. В Spring Boot часто кажется, что приложение стартует “само” (потому что SpringApplication.run() делает много работы за вас). Но мы сегодня сознательно остаёмся в чистом Spring Core: вы руками создаёте контекст, выбираете профили, регистрируете конфигурацию и вызываете refresh(). Это не “лишняя рутина”, это учебная рентген-съёмка.
Минимальный, честный entry-point финальной версии ContextFlow может выглядеть так:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowMain {
public static void main(String[] args) {
// Контекст лучше закрывать явно: иначе @PreDestroy/destroyMethod могут не сработать.
try (var context = new AnnotationConfigApplicationContext()) {
// Профиль важен ДО refresh(): он влияет на то, какие бины вообще будут зарегистрированы.
context.getEnvironment().setActiveProfiles("demo");
// Регистрируем корневую конфигурацию приложения (composition root).
context.register(AppConfig.class);
// Запускаем фазу сборки: чтение конфигов, создание bean definitions, инстанцирование бинов и т.д.
context.refresh();
// Запуск сценария — это граница старта, тут допустим ручной getBean().
context.getBean(ScenarioRunner.class).run();
}
}
}
Обратите внимание на две вещи.
Первая — профиль выставляется до refresh(). Это важно, потому что профили влияют на то, какие бины вообще будут зарегистрированы и созданы. Если выставить профиль после refresh(), вы получите классический эффект “я переключил режим, но ничего не поменялось” (и начнёте подозревать заговор, хотя виноваты будете вы).
Вторая — try-with-resources. Закрытие контекста — это не “церемония”. Это причина, почему @PreDestroy и destroyMethod действительно срабатывают, а ресурсы (файлы, потоки, менеджеры вывода) закрываются корректно. Spring не может закрыть контекст “силой мысли”, если вы его не закрываете.
И да: вы тут видите “ручной getBean()”, но это только на границе старта, как вы и делали раньше с composition root в plain Java. Бизнес-слой не должен превращаться в “профессора getBean”.
3. AppConfig: модульная конфигурация вместо giant-кучи
Когда проект маленький, один конфигурационный класс кажется нормальным. Но как только вы добавляете profiles, resources, сообщения, reporting, legacy XML-ветку и support-слой, “один AppConfig на всё” превращается в текстовую версию гаража, где вы храните и велосипед, и холодильник, и старые дипломы. Модульная конфигурация нужна не ради красоты, а ради читабельности: вы открываете AppConfig и видите, из каких частей состоит приложение.
В финальной сборке удобно, когда верхний конфиг вообще почти пустой — он просто “складывает модули”:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import({
// База приложения: доменные сервисы/порты/репозитории и т.п.
CoreConfig.class,
// Профили (dev/demo/test) и вариативность поведения.
ProfilesConfig.class,
// Отчёты, форматирование и вывод.
ReportingConfig.class,
// Мост к легаси XML-фрагменту.
LegacyBridgeConfig.class,
// Инфраструктурные расширения Spring: BFPP/BPP/FactoryBean/AOP и диагностика.
SupportConfig.class
})
public class AppConfig {
}
Теперь важная мысль: @Import — это не “ещё один способ регистрации”. Это способ сделать wiring читаемым. Ваши модули — это не “слои архитектуры ради диаграммы”, а практическая навигация по проекту.
Например, CoreConfig отвечает за базовую сборку доменных портов и сервисов, ProfilesConfig — за вариативность режима (dev/demo/test), ReportingConfig — за отчёты и output, LegacyBridgeConfig — за подключение XML-фрагмента, а SupportConfig — за всё, что относится к post-processors, factory beans, диагностике и AOP.
Если вам хочется проверить, что модульность работает, задайте себе вопрос: “Если завтра упадёт аудит в файлы, в каком модуле я это ищу?” В хорошей модульной конфигурации ответ будет почти автоматическим.
4. Окружение: profiles, properties и conversion
В Spring удобно то, что бизнес-логика может оставаться “чистой”, а поведение приложения меняется за счёт конфигурации. Но у этого есть условие: конфигурация должна быть подключена раньше, чем вы запустили сценарий. То есть ваш ApplicationContext сначала строит окружение и создаёт нужные бины, а потом вы выполняете use case. Это похоже на то, как вы сначала готовите кухню (достаёте ингредиенты, включаете плиту), а потом уже жарите омлет, а не пытаетесь включить плиту в середине жарки.
В ContextFlow мы держимся простой модели: есть базовый contextflow.properties, а также profile-specific варианты, например contextflow-demo.properties и contextflow-test.properties. Важно не перепутать это с Boot-конвенциями: в чистом Spring такие profile-specific источники подключаются явно через @PropertySource или программную регистрацию, одного совпадения имени с профилем контейнеру недостаточно. А затем вы читаете их через @Value или Environment.
Пусть, например, мы хотим управлять форматом отчёта и директорией вывода:
# contextflow.properties
contextflow.report.format=text
contextflow.report.output-dir=build/contextflow/reports
А затем в конфигурации “приземлить” это в инфраструктуру (упрощённо):
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ReportingConfig {
@Bean
ReportOutputManager reportOutputManager(
// Значение будет подставлено контейнером при создании бина.
@Value("${contextflow.report.output-dir}") String outputDir) {
// Здесь мы сознательно держим только инфраструктуру (куда писать), не бизнес-логику.
return new ReportOutputManager(outputDir);
}
}
Здесь важный момент для mental model: @Value — это не “магия строк”. Это часть механизма, который контейнер делает на старте, до того как вы запускаете сценарий. Если value отсутствует, вы узнаете об этом либо на старте, либо в момент создания конкретного бина (в зависимости от ленивости/жадности создания). Это и есть fail-fast подход: лучше упасть сразу, чем “в середине выполнения заказа”.
А где тут conversion? В финальной версии вы не хотите писать if ("text".equals(format)) ... в бизнес-сервисе. Поэтому формат отчёта у вас становится, например, enum-ом, а строка из properties превращается в тип через ConversionService и ваш конвертер. Логика та же: всё это готовится контейнером до выполнения сценария.
5. Resource и MessageSource
Когда вы впервые слышите “ресурсы” и “сообщения”, есть соблазн подумать: “ну это же про UI и веб”. Но в non-web приложении это даже проще и честнее: шаблоны уведомлений и заголовки отчётов — это внешние артефакты, которые должны жить рядом с кодом, версионироваться и загружаться предсказуемо. А локализованные тексты — это способ не зашивать строки в Java-код, особенно когда они используются и в уведомлениях, и в отчётах, и в консольном выводе.
Представьте, что у вас есть шаблон уведомления о создании заказа:
src/main/resources/templates/notifications/order-created.txt
И каталог шаблонов, который загружается при старте (инициализация “живет” в lifecycle, а не в первом вызове бизнес-метода). Очень упрощённый пример:
import jakarta.annotation.PostConstruct;
import org.springframework.core.io.Resource;
public class NotificationTemplateCatalog {
// Resource — это не "java.io.File": это абстракция Spring, которая умеет работать и с classpath, и с URL, и т.д.
private final Resource orderCreatedTemplate;
public NotificationTemplateCatalog(Resource orderCreatedTemplate) {
// Зависимость приходит из контейнера так же, как и любая другая.
this.orderCreatedTemplate = orderCreatedTemplate;
}
@PostConstruct
void init() {
// Эта фаза вызывается контейнером после создания бина: удобно делать быстрые проверки и подготовку.
System.out.println("Templates ready: " + orderCreatedTemplate.exists()); // Templates ready: true
}
}
Важно не то, что мы печатаем true. Важно, что вы видите место механизма в общей картине: ресурс — это не “просто файл”, это зависимость, которую контейнер умеет дать бину, а бин умеет проверить и подготовить в init-фазе.
С MessageSource история похожая. Вы подключаете bundles messages.properties, messages_ru.properties, messages_en.properties, а дальше получаете сообщение по ключу и локали. И это снова часть общей сборки: контейнер предоставляет MessageSource как инфраструктуру, а бизнес/инфраструктурный слой использует её для текста.
6. Use case: сценарий → сервис → event, а side effects — в listeners
Когда проект растёт, самая неприятная деградация — когда “главный” use case сервис превращается в комбайн: он и сохраняет заказ, и считает скидку, и пишет аудит, и шлёт уведомления, и обновляет статистику, и ещё где-то в уголке формирует отчёт “на всякий случай”. Такой сервис обычно работает, но читать его тяжело: каждое изменение превращается в мини-операцию на открытом сердце.
События в нашем проекте решают ровно это: use case делает основное действие и публикует факт, а побочные эффекты выполняются отдельными слушателями.
Мини-версия OrderPlacementService:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void place(String orderId) {
// Сервис делает "главное": публикует бизнес-факт, а не тащит в себе аудит/уведомления/статистику.
publisher.publishEvent(new OrderCreatedEvent(orderId));
// Важно помнить: по умолчанию это синхронно (listeners выполняются в текущем потоке).
}
}
Аудит как listener (упрощённо):
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// Side effect живёт отдельно: use case не знает, что его кто-то аудитит.
System.out.println("AUDIT orderId=" + event.orderId()); // AUDIT orderId=...
}
}
На этом месте полезно вспомнить вашу mental model из предыдущих дней: события по умолчанию синхронные. То есть publishEvent() не “ставит в очередь”, а вызывает listeners прямо в текущем потоке. Это означает, что use case “завершился” только тогда, когда закончили все обработчики. И это нормально для нашего учебного non-web приложения.
С точки зрения финальной сборки важно другое: use case сервис не знает, кто слушает событие. Он знает только, что он публикует факт “заказ создан”. Это и есть мягкая развязка, которая делает систему менее связной.
7. Support-слой: BFPP, BPP, FactoryBean и AOP
Внутренние механизмы Spring легко превратить в плохую привычку: хочется везде добавить Aware, где-то сделать factory, где-то “подкрутить” post-processor, и внезапно бизнес-код начинает напоминать Spring-документацию, а не бизнес-логику. Поэтому в нашем проекте существует отдельный support.* слой: там живут post-processors, FactoryBean, диагностические Aware-бины и AOP. Это не “ещё один слой ради слоёв”, это санитарная зона: Spring-специфика изолирована.
Начнём с двух extension points, которые влияют на старт.
BeanFactoryPostProcessor живёт в фазе “метаданные ещё не превратились в объекты”. Это идеально для sanity check: проверить, что ключевые properties существуют, что нет критических противоречий, что обязательные bean definitions присутствуют. Условный пример (сильно упрощённый, чтобы не утонуть в API):
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
public class ContextFlowSanityCheckBeanFactoryPostProcessor
implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
// Эта точка выполняется ДО создания бинов: мы работаем с bean definitions, а не с объектами.
System.out.println("Sanity check: beanCount=" + bf.getBeanDefinitionCount());
}
}
BeanPostProcessor работает уже с объектами (до/после init). Там удобно делать диагностику, “трекинг” компонентов, лёгкую обвязку. Например, логировать факт инициализации определённых бинов, не залезая в каждый класс. Это и есть “инфраструктурная магия”, которая не портит бизнес-слой.
Дальше FactoryBean. Мы используем его ровно один раз и только там, где это действительно объясняет контейнер: ReportFormatterFactoryBean выбирает реализацию форматтера по конфигурации. Важно, что “форматтер” — это продукт фабрики, а сама фабрика тоже bean. И это можно увидеть, если вы помните трюк &beanName, который даёт доступ к самой фабрике.
Потом диагностический Aware-бин. Он может получить имя, Environment, контекст — но только как изолированный компонент, чтобы бизнес-сервисы не начали искать бины вручную. Если бизнес-сервис получает ApplicationContext, это почти всегда smell: вы переехали из DI в service locator.
И наконец AOP. Аспект ServiceTimingAspect добавляет техническое поведение вокруг методов сервисного слоя (например, измерение времени). И тут финальная сборка помогает особенно: вы начинаете видеть, что аспект — не “магия компилятора”, а результат proxy-based модели, построенной контейнером через инфраструктурные механизмы.
8. Legacy XML: изолированный мост
XML в современных проектах — это не норма, но это реальность, которая встречается в легаси. И самое важное, что вы должны унести из этой части курса: XML — это тот же Spring, просто другой синтаксис описания bean model. Это не “отдельная технология”, и тем более не “тёмная магия”. Если вы понимаете bean definitions, scopes, wiring и lifecycle, то XML читается почти как “аннотации, только текстом”.
В финальной версии ContextFlow XML-фрагмент подключается как bridge, например через @ImportResource в отдельном модуле:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
// Мост к легаси: отдельный модуль, чтобы XML не "размазывался" по всему проекту.
@ImportResource("classpath:legacy/legacy-notification-context.xml")
public class LegacyBridgeConfig {
}
А внутри XML вы видите знакомые вещи — bean id, class и, если нужно, зависимости:
<!-- Обычное определение бина: тот же bean model, просто в другом синтаксисе. -->
<bean id="consoleAuditWriter"
class="com.example.contextflow.infrastructure.audit.ConsoleAuditWriter"/>
Фишка финальной сборки в том, что XML-ветка не размывает проект: она изолирована. Вы не превращаете приложение в XML-first, вы просто умеете жить с кусочком легаси, пока его не мигрировали.
9. Тесты: проверяем сборку, а не “верим в неё”
Тестирование Spring-конфигурации — это не про “покрыть всё тестами”, а про то, чтобы перестать бояться, что вы случайно сломали wiring, профили, property overrides, listeners или legacy-bridge. В идеале тесты должны использовать тот же AppConfig, что и реальный запуск, иначе вы тестируете “другую вселенную” и удивляетесь, почему в проде не так.
Минимальный smoke test может быть очень маленьким — иногда достаточно того, что контекст поднимается:
import org.junit.jupiter.api.Test;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(AppConfig.class)
@ActiveProfiles("test")
class ContextSmokeTest {
@Test
void contextStarts() {
// если контекст не стартует — тест упадёт сам (на поднятии TestContext).
}
}
Это выглядит почти как “пустой тест”, но он проверяет очень важное: wiring, профили, property sources, конвертацию, регистрацию listeners, поддержку legacy XML, поддержку AOP и наличие ваших support beans. То есть он проверяет именно то, что plain unit-тестами проверить невозможно.
И снова обратите внимание на общую линию: тест — это просто другой entry-point в тот же ApplicationContext. В финальной mental model “запуск приложения” и “запуск контекста в тесте” — это не разные миры, это один механизм в разных режимах.
10. Типичные ошибки финальной сборки
Ошибка №1: объяснять проект через список аннотаций.
Когда финал сводится к “у нас есть @Configuration, @Profile, @EventListener и @Aspect”, у студента снова возникает ощущение магии. Аннотации начинают жить сами по себе, без связи с выполнением программы. Важно держать фокус на потоке: main → ApplicationContext → создание бинов → запуск сценария. Аннотации — это лишь способ описать этот путь, а не его замена.
Ошибка №2: терять общий контур из-за мелких деталей.
Легко увязнуть в обсуждении “где лежит шаблон” или “как подгружается ресурс”, забыв, что это частный случай. Если не понятен общий механизм сборки, детали не спасают — они только перегружают. Сначала должна быть ясна картина целиком: как собирается контейнер, как связываются бины, как запускается сценарий.
Ошибка №3: смешивать orchestration и side effects.
Когда use case сервис начинает и управлять сценарием, и писать в лог, и слать события, и делать аудит — он разрастается и теряет читаемость. События становятся формальностью, а listeners — дублированием. Правильная структура: сервис описывает сценарий, а побочные эффекты выносятся в отдельные обработчики.
Ошибка №4: относиться к XML как к “другому Spring”.
XML часто воспринимается как что-то чужое и устаревшее, из-за чего его начинают избегать или бояться. На самом деле это тот же самый контейнер, просто описанный другим синтаксисом. Если перевести XML в привычную модель “bean → id → class → зависимости”, он становится понятным и предсказуемым.
Ошибка №5: забывать про точку входа и сценарий запуска.
В финальной сборке важно не только “что есть”, но и “что происходит при запуске”. Если нельзя быстро объяснить, какой бин инициирует сценарий и как он вызывается из main(), значит картина всё ещё размыта. Чёткая точка входа — это якорь, который удерживает всю архитектуру.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ