1. Вступ
Коли застосунок запускається, це приємно. Але у Spring-проєктів є підступний побічний ефект: вони часто «працюють самі», навіть тоді, коли ви не до кінця розумієте, чому саме все влаштовано так, а не інакше. У результаті в голові залишається «магія»: анотації, якісь конфіги, десь events, десь аспект… і все це ніби не про одну систему, а про різні демонстраційні фрагменти. Фінальна збірка потрібна, щоб у вас зʼявилася цілісна ментальна модель: один шлях, одна логіка, один ApplicationContext.
У цій лекції ми триматимемося простої осі: main() → створення контексту → реєстрація конфігурації → читання середовища, профілів і властивостей → створення бінів → запуск сценарію. Усе інше — ресурси, повідомлення, події, post-processors, AOP, XML-гілка, тести — ми не перелічуватимемо, а акуратно вмонтовуватимемо в цей шлях, як деталі в один механізм.
Щоб не загубитися, ось компактна схема зв’язків:
flowchart TD A["main()"] --> B["ApplicationContext"] B --> C["Модулі конфігурації через @Import"] B --> D["Середовище: профілі + властивості"] B --> E["Bean definitions → біни"] E --> F["ScenarioRunner.run()"] F --> G["Сервіси сценарію"] G --> H["publishEvent()"] H --> I["Побічні ефекти @EventListener"] E --> J["Підтримка: BFPP/BPP/FactoryBean/AOP"] C --> K["Міст до легасі XML"] B --> L["Тестовий контекст Spring"]
Сенс діаграми не в тому, щоб завчити стрілки. Важливо інше: це одна система, де кожен механізм має свою точку входу в спільний потік.
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 у звичайному Java-коді. Бізнес-шар не має перетворюватися на «професора getBean».
3. AppConfig: модульна конфігурація замість великої купи
Коли проєкт маленький, один конфігураційний клас здається нормальним. Але щойно ви додаєте профілі, ресурси, повідомлення, звіти, легасі 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 — це не ще один спосіб реєстрації. Це спосіб зробити зв’язування читабельним. Ваші модулі — це не шари архітектури заради діаграми, а практична навігація проєктом.
Наприклад, CoreConfig відповідає за базову збірку доменних портів і сервісів, ProfilesConfig — за варіативність режиму (dev/demo/test), ReportingConfig — за звіти та виведення, LegacyBridgeConfig — за підключення XML-фрагмента, а SupportConfig — за все, що стосується post-processors, factory beans, діагностики та AOP.
Якщо вам хочеться перевірити, що модульність працює, поставте собі запитання: «Якщо завтра зламається аудит у файли, у якому модулі я це шукатиму?» У хорошій модульній конфігурації відповідь буде майже автоматичною.
4. Середовище: profiles, properties і conversion
У Spring зручно те, що бізнес-логіка може залишатися чистою, а поведінка застосунку змінюється за рахунок конфігурації. Але для цього є умова: конфігурацію треба підключити раніше, ніж ви запустили сценарій. Тобто ваш ApplicationContext спочатку будує середовище й створює потрібні біни, а вже потім ви виконуєте сценарій використання. Це схоже на те, як ви спочатку готуєте кухню — дістаєте інгредієнти, вмикаєте плиту, — а вже потім смажите омлет, а не намагаєтеся ввімкнути плиту посеред готування.
У 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);
}
}
Тут важливий момент для ментальної моделі: @Value — це не магія рядків. Це частина механізму, який контейнер виконує на старті, до того як ви запускаєте сценарій. Якщо значення відсутнє, ви дізнаєтеся про це або на старті, або в момент створення конкретного біна — залежно від лінивої чи жадібної стратегії створення. Це і є підхід fail-fast: краще впасти одразу, ніж посеред виконання замовлення.
А де тут conversion? У фінальній версії ви не хочете писати if ("text".equals(format)) ... у бізнес-сервісі. Тому формат звіту у вас стає, наприклад, enum-ом, а рядок із properties перетворюється на тип через ConversionService і ваш конвертер. Логіка та сама: усе це готується контейнером до виконання сценарію.
5. Resource і MessageSource
Коли ви вперше чуєте «ресурси» й «повідомлення», є спокуса подумати: «ну це ж про UI та веб». Але в не-веб застосунку це навіть простіше й чесніше: шаблони повідомлень і заголовки звітів — це зовнішні артефакти, які мають жити поруч із кодом, версіонуватися і завантажуватися передбачувано. А локалізовані тексти — це спосіб не вшивати рядки в 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("Шаблони готові: " + orderCreatedTemplate.exists()); // Шаблони готові: true
}
}
Важливо не те, що ми друкуємо true. Важливо інше: ви бачите місце механізму в загальній картині. Ресурс — це не «просто файл», а залежність, яку контейнер уміє дати біну, а бін уміє перевірити й підготувати в init-фазі.
Історія з MessageSource схожа. Ви підключаєте bundles messages.properties, messages_ru.properties, messages_en.properties, а далі отримуєте повідомлення за ключем і локаллю. І це знову частина загальної збірки: контейнер надає MessageSource як інфраструктуру, а бізнес- та інфраструктурний шар використовує її для текстів.
6. Сценарій використання: сценарій → сервіс → event, а побічні ефекти — у слухачах
Коли проєкт росте, найнеприємніша деградація — це коли головний сервіс сценарію перетворюється на комбайн: він і зберігає замовлення, і рахує знижку, і пише аудит, і шле повідомлення, і оновлює статистику, і ще десь у кутку формує звіт «про всяк випадок». Такий сервіс зазвичай працює, але читати його важко: кожна зміна перетворюється на мініоперацію на відкритому серці.
Події в нашому проєкті розв’язують саме це: сценарій виконує основну дію і публікує факт, а побічні ефекти виконуються окремими слухачами.
Мініверсія 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 виконуються в поточному потоці).
}
}
Аудит як слухач — спрощено:
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// Побічний ефект живе окремо: сценарій не знає, що його хтось аудіює.
System.out.println("АУДИТ orderId=" + event.orderId()); // АУДИТ orderId=...
}
}
На цьому місці корисно згадати вашу ментальну модель із попередніх днів: події за замовчуванням синхронні. Тобто publishEvent() не ставить їх у чергу, а викликає слухачів просто в поточному потоці. Це означає, що сценарій завершився лише тоді, коли відпрацювали всі обробники. І це нормально для нашого навчального не-веб застосунку.
З погляду фінальної збірки важливо інше: сервіс сценарію не знає, хто слухає подію. Він знає лише, що публікує факт «замовлення створено». Це й є мʼяке розв’язання, яке робить систему менш зв’язаною.
7. Support-шар: BFPP, BPP, FactoryBean і AOP
Внутрішні механізми Spring легко перетворити на погану звичку: хочеться всюди додати Aware, десь зробити фабрику, десь підкрутити post-processor, і раптом бізнес-код починає нагадувати документацію Spring, а не бізнес-логіку. Тому в нашому проєкті існує окремий support.* шар: там живуть post-processors, FactoryBean, діагностичні Aware-біни та AOP. Це не ще один шар заради шарів, а санітарна зона: Spring-специфіка ізольована.
Почнімо з двох extension points, які впливають на запуск.
BeanFactoryPostProcessor живе у фазі, коли метадані ще не перетворилися на об’єкти. Це ідеально для перевірки коректності: перевірити, що ключові 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 обирає реалізацію форматера за конфігурацією. Важливо, що форматер — це продукт фабрики, а сама фабрика теж є біном. І це можна побачити, якщо ви пам’ятаєте трюк &beanName, який дає доступ до самої фабрики.
Потім — діагностичний Aware-бін. Він може отримати ім’я, Environment, контекст — але лише як ізольований компонент, щоб бізнес-сервіси не почали шукати біни вручну. Якщо бізнес-сервіс отримує ApplicationContext, це майже завжди ознака проблеми: ви переїхали з DI в service locator.
І нарешті AOP. Аспект ServiceTimingAspect додає технічну поведінку навколо методів сервісного шару, наприклад вимірювання часу. І тут фінальна збірка допомагає особливо: ви починаєте бачити, що аспект — не магія компілятора, а результат моделі на основі проксі, яку контейнер будує через інфраструктурні механізми.
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-конфігурації — це не про «покрити все тестами», а про те, щоб перестати боятися, що ви випадково зламали зв’язування, профілі, перевизначення властивостей, слухачів або 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.
}
}
Це виглядає майже як порожній тест, але він перевіряє дуже важливе: зв’язування, профілі, джерела властивостей, конвертацію, реєстрацію слухачів, підтримку legacy XML, підтримку AOP і наявність ваших support-бінів. Тобто він перевіряє саме те, що звичайними unit-тестами перевірити неможливо.
І знову зверніть увагу на загальну лінію: тест — це просто інша точка входу в той самий ApplicationContext. У фінальній ментальній моделі «запуск застосунку» і «запуск контексту в тесті» — це не різні світи, а один механізм у різних режимах.
10. Типові помилки фінальної збірки
Помилка №1: пояснювати проєкт через список анотацій.
Коли фінал зводиться до «у нас є @Configuration, @Profile, @EventListener і @Aspect», у студента знову виникає відчуття магії. Анотації починають жити самі по собі, без зв’язку із запуском застосунку. Важливо тримати фокус на потоці: main → ApplicationContext → створення бінів → запуск сценарію. Анотації — це лише спосіб описати цей шлях, а не його заміна.
Помилка №2: втрачати загальний контур через дрібні деталі.
Легко загрузнути в обговоренні «де лежить шаблон» або «як підвантажується ресурс», забувши, що це окремий випадок. Якщо не зрозумілий загальний механізм збірки, деталі не врятують — вони лише перевантажать. Спочатку має бути ясна картина цілком: як збирається контейнер, як зв’язуються біни, як запускається сценарій.
Помилка №3: змішувати координацію і побічні ефекти.
Коли сервіс сценарію починає і керувати сценарієм, і писати в лог, і надсилати події, і робити аудит, він розростається та втрачає читабельність. Події стають формальністю, а слухачі — дублюванням. Правильна структура така: сервіс описує сценарій, а побічні ефекти виносяться в окремі обробники.
Помилка №4: ставитися до XML як до «іншого Spring».
XML часто сприймається як щось чуже й застаріле, через що його починають уникати або боятися. Насправді це той самий контейнер, просто описаний іншим синтаксисом. Якщо перевести XML у звичну модель «bean → id → class → залежності», він стає зрозумілим і передбачуваним.
Помилка №5: забувати про точку входу та сценарій запуску.
У фінальній збірці важливо не лише «що є», а й «що відбувається під час запуску». Якщо не можна швидко пояснити, який бін ініціює сценарій і як він викликається з main(), значить картина все ще розмита. Чітка точка входу — це якір, який утримує всю архітектуру.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ