1. Проєктування точок розширення
Коли ви вперше бачите BFPP/BPP, виникає цілком природне бажання: «О, круто! Зараз я тут усе перевірю, усе підправлю, усе підміню… і взагалі, навіщо мені архітектура, якщо є постпроцесори?» Це приблизно як побачити скотч і вирішити, що тепер цвяхи вже не потрібні. Так, можна. Але потім у якийсь момент двері разом зі скотчем лишаються у вас у руках, а ви стоїте й робите вигляд, що так і було задумано.
У ContextFlow наша мета інша: не перетворити проєкт на цирк контейнерних трюків, а отримати два дуже прагматичні ефекти. Перший — ранні перевірки, які дають зрозумілу помилку на старті, якщо збирання контексту некоректне, наприклад коли взагалі немає жодного NotificationSender. Другий — діагностика, яка допомагає побачити, що саме контейнер віддав назовні як бін: який beanName, який реальний Class, які об’єкти пройшли ініціалізацію.
Після розгляду refresh(), BFPP, BPP і анотаційної інфраструктури залишилося зібрати все це в одну стабільну wiring-схему проєкту. Інакше post-processors так і лишаться набором окремих демо-трюків, а не нормальною частиною застосунку.
І тут важливий тонкий момент: BFPP і BPP — це не «бізнес-логіка, тільки в іншому місці». Це інфраструктурні гачки. Вони мають працювати як турнікет у метро: він перевіряє, чи є квиток і чи він чинний, але не вирішує, куди вам їхати й навіщо ви взагалі вийшли з дому. Якщо BFPP починає «підкручувати» бізнес-рішення, ви отримуєте застосунок, який неможливо читати без знання внутрішніх фаз refresh().
2. Правило шарів: support і application
Щоб BFPP/BPP не перетворилися на «другий Spring усередині Spring», у проєкті потрібно заздалегідь прийняти просте архітектурне правило: бізнес-шар (domain і application) не зобов’язаний знати, що в контейнера взагалі є якісь постпроцесори. Це знання — суто інфраструктурне. Тому в нас і виділено пакети support.* та config.*: вони як «кімната системних адміністраторів», куди звичайні користувачі, тобто бізнес-сервіси, не ходять.
У ContextFlow це зручно фіксувати прямо структурою пакетів. Зверху — домен і порти, які описують «що ми хочемо» (повідомити, записати аудит, зберегти замовлення). Далі — application-сервіси, які оркеструють сценарії. І окремо — infrastructure та support, які займаються «як саме це працює» і «як це діагностувати». BFPP/BPP — типовий житель support.postprocessor, тому що вони використовують Spring SPI (BeanFactoryPostProcessor, BeanPostProcessor) і напряму спілкуються з контейнером.
Корисно тримати в голові таку таблицю, щоб не переплутати ролі й не затягнути контейнерну механіку в доменну модель — це майже завжди закінчується смутком:
| Шар проєкту | Що там живе | Чи може залежати від Spring? | Приклади |
|---|---|---|---|
| domain.* | сутності, події, порти (інтерфейси) | краще мінімально | Order, Customer, OrderCreatedEvent, NotificationSender |
| application.* | use-case сервіси та сценарії | допустимо, але без «контейнерного мозку» | OrderPlacementService, ScenarioRunner |
| infrastructure.* | реалізації портів | так, але без SPI-екзотики | ConsoleNotificationSender, FileAuditWriter |
| support.* | інфраструктурні розширення контейнера | так, це їхня робота | BFPP/BPP, конвертери, lifecycle helpers |
| config.* | wiring і реєстрація бінів | так | AppConfig, SupportConfig |
Якщо дуже коротко, то BFPP/BPP мають відчуватися як «проводка в стінах»: вона важлива, без неї нічого не працює, але ви не ухвалюєте бізнес-рішення «створити замовлення» на рівні проводки.
3. SupportConfig: реєстрація BFPP/BPP
Одна з найбільш неприємних проблем для новачка в Spring — «чому воно спрацювало?» або «чому воно раптом перестало працювати?». І майже завжди відповідь пов’язана з тим, що десь увімкнулася інфраструктура, але її не оформили як частину зрозумілої конфігурації. Тому в ContextFlow ми не реєструємо post-processors де заманеться, а робимо окремий модуль конфігурації: SupportConfig. Один модуль, один імпорт у AppConfig, один зрозумілий вхід для всієї контейнерної інфраструктури.
Уявіть, що людина відкриває репозиторій і намагається зрозуміти, що відбувається. Якщо BFPP/BPP лежать поруч із бізнес-сервісами або вмикаються через випадковий @Component у глибині пакета, то «магія» стає реальною магією — у поганому сенсі. А якщо є модуль SupportConfig, то розширення контейнера стають явною частиною збирання: як блок запобіжників у щитку. Хочете — дивіться, які стоять автомати, хочете — тимчасово вимикайте частину діагностики за профілем.
Приклад мінімального SupportConfig, який реєструє BFPP і BPP у нашому проєкті:
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SupportConfig {
@Bean
static BeanFactoryPostProcessor contextFlowSanityChecks() {
// BFPP має спрацювати якнайраніше, тому @Bean робимо static.
// Так Spring зможе викликати його до створення екземпляра конфігураційного класу.
return new ContextFlowSanityCheckBeanFactoryPostProcessor();
}
@Bean
BeanPostProcessor trackedComponents() {
// BPP — це вже діагностика на рівні реальних об’єктів, після створення бінів.
return new TrackedComponentBeanPostProcessor();
}
}
Зверніть увагу на static у @Bean для BFPP. Ми не перетворюємо це на містичний ритуал, але фіксуємо важливу ідею: BFPP — «ранній учасник старту». Чим менше шансів, що його буде створено пізніше, ніж треба, тим менше сюрпризів на рівному місці.
Тепер потрібно підключити SupportConfig до кореневої конфігурації застосунку. У реальному проєкті AppConfig уже, найімовірніше, збирає модулі через @Import, і ми додаємо ще один:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import({
// Бізнес-ядро (use-cases, доменні сервіси тощо)
CoreConfig.class,
// Реалізації портів (файли, консоль, БД тощо)
InfrastructureConfig.class,
// Інфраструктурні розширення контейнера: BFPP/BPP і діагностика
SupportConfig.class
})
public class AppConfig {
}
І нарешті, точка входу (умовно main()) лишається чесною і короткою: піднімаємо контекст і запускаємо сценарій через ScenarioRunner, як і раніше.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowMain {
public static void main(String[] args) {
// try-with-resources гарантує коректне закриття контексту та виконання shutdown hooks.
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Дістаємо верхньорівневий сценарій із контейнера та запускаємо його.
context.getBean(ScenarioRunner.class).run();
}
}
}
Так, це виглядає «нудно». І це комплімент: нудний bootstrap — ознака того, що у вас не ховається хаос там, де його потім неможливо відлагодити.
4. BFPP: ранні sanity checks
BFPP — чудовий інструмент, щоб зробити старт контексту максимально передбачуваним: якщо збирання зламане, краще впасти одразу й пояснити по-людськи, що саме не так. Але щоб BFPP не став джерелом болю, ми використовуємо його як санітарний контроль: перевіряємо структуру контексту, наявність обов’язкових ролей, мінімальні вимоги до збирання — і все. Без спроб «додумати за вас» бізнес-рішення.
Нижче приклад BFPP для ContextFlow, який перевіряє, що в контейнері взагалі існує хоча б один NotificationSender. Це не бізнес-правило рівня «кому надсилати», а структурне правило: проєкт заявлений як застосунок із повідомленнями, отже, хоча б один відправник має бути.
import com.example.contextflow.domain.ports.NotificationSender;
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) {
// Важливо: тут ми перевіряємо тільки "що зареєстровано", а не "що вже створено".
// Тому використовуємо getBeanNamesForType(...) замість getBean(...).
String[] senders = bf.getBeanNamesForType(NotificationSender.class, true, false);
// true -> враховуємо не лише singleton-екземпляри, а й інші scopes / фабрики (по суті: "шукаємо ширше").
// false -> НЕ форсуємо створення бінів заради перевірки (інакше можна запустити півзастосунку).
if (senders.length == 0) {
// Повідомлення спеціально "про проєкт", а не про Spring: так простіше читати помилку на старті.
throw new IllegalStateException("ContextFlow: потрібен щонайменше один NotificationSender");
}
}
}
Тут спеціально використовується getBeanNamesForType(...). Людською мовою це означає: «подивися, що зареєстровано за типом, але не намагайся заради цього створювати біни». Нам у sanity check потрібен саме рівень збирання, а не запуск половини застосунку посеред старту.
Зазвичай у ContextFlow хочеться перевірити не один тип, а кілька опорних портів. Тоді BFPP швидко перетворюється на копіпасту, тому можна зробити маленький helper усередині класу — так, усередині BFPP теж можна писати нормальний код, ми ж не в храмі мінімалізму:
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
// Фрагмент: допоміжний метод, який можна тримати всередині класу BFPP (наприклад, як private static).
private static void requireType(ConfigurableListableBeanFactory bf, Class
type) {
// Перевіряємо "наявність за типом" без створення екземплярів.
String[] names = bf.getBeanNamesForType(type, true, false);
if (names.length == 0) {
// Повідомлення робимо максимально зрозумілим: який саме обов’язковий тип відсутній.
throw new IllegalStateException("ContextFlow: відсутній бін типу " + type.getSimpleName());
}
}
І використовувати так:
// Це саме "структурні опори" застосунку: без них проєкт не вважається зібраним.
requireType(bf, NotificationSender.class);
requireType(bf, AuditWriter.class);
requireType(bf, OrderStore.class);
Зверніть увагу на стиль помилок. У sanity checks важливо, щоб повідомлення було «про проєкт», а не «про внутрішності Spring». Новачок має побачити не NoSuchBeanDefinitionException на глибині 70 рядків, а зрозумілу фразу рівня: «не знайдено AuditWriter — проєкт вимагає хоча б одну реалізацію аудиту».
І ще один практичний нюанс: BFPP — погане місце для мовчазних виправлень. Якщо ви автоматично змінюєте половину BeanDefinition (робите lazy, змінюєте scope, підставляєте дефолти), то через кілька тижнів ніхто не зрозуміє, чому застосунок поводиться не так, як написано в конфігурації. У навчальному проєкті це особливо небезпечно: студент починає думати, що Spring «сам якось здогадається».
5. BPP: діагностика бінів
Якщо BFPP — це перевірка «плану будівлі» до початку будівництва, то BPP — це момент, коли будинок уже збудували, і ви ходите з ліхтариком та дивитеся, де у вас розетки, вимикачі й чому у ванній раптом виявилася люстра. BPP бачить реальні екземпляри бінів, і цим він дуже корисний для діагностики: можна зрозуміти, які об’єкти реально живуть у контексті після фази ініціалізації, і які класи контейнер віддав назовні.
У ContextFlow нам не потрібно «обробляти все підряд» — інакше це швидко перетворюється на шум у логах і страждання від продуктивності. Ми обираємо вузький критерій: наприклад, відстежуємо тільки сервіси, за угодою — *Service. Тоді вивід лишається читабельним і допомагає зрозуміти, що саме контейнер зібрав.
import org.springframework.beans.factory.config.BeanPostProcessor;
public class TrackedComponentBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
// Вузький фільтр: відстежуємо тільки те, що справді цікаво бачити в консолі.
if (beanName.endsWith("Service")) {
// Діагностика: показуємо beanName і реальний клас (корисно при проксі та декораторах).
System.out.println("[track] " + beanName + " -> " + bean.getClass().getName());
// приклад виводу: [track] orderPlacementService -> com.example...OrderPlacementService
}
// Важливо: BPP зобов’язаний повернути об’єкт (той самий або обгортку), інакше бін "зникне" з контексту.
return bean;
}
}
Тут важлива одна деталь на мільйон доларів, яку новачки часто пропускають: BPP зобов’язаний повернути об’єкт, з яким контейнер житиме далі. Якщо ви повернули null, далі почнеться весела пригода під назвою «чому у мене раптом немає біна, хоча він був». Тому ми завжди завершуємо return bean; (або повертаємо обгортку — але це вже окрема історія, і тут ми використовуємо діагностику без зміни поведінки).
Іноді замість перевірки за іменем зручніше обмежитися пакетом, особливо якщо в проєкті є власні naming conventions. Тоді критерій можна записати так:
// Альтернатива фільтру за іменем: фільтр за пакетом (зручно, якщо імена не надто суворі).
String pkg = bean.getClass().getPackageName();
if (pkg.startsWith("com.example.contextflow.application")) {
// Виводимо коротку назву класу, щоб вивід був компактнішим.
System.out.println("[app] " + beanName + " -> " + bean.getClass().getSimpleName()); // [app] ...
}
Сенс той самий: вузько, передбачувано, без спроб «обробляти взагалі все у світі». У навчальному проєкті це ще й захищає від ситуації, коли ви ненароком почали друкувати внутрішні інфраструктурні біни Spring, і студент потім дивиться на консоль, як на Матрицю, та питає: «А де тут мій код?».
Ефект у рантаймі: порядок старту
Коли ви додали BFPP/BPP, дуже хочеться переконатися, що вони справді відпрацювали — і саме у правильній фазі. Найпростіший спосіб — зробити вивід таким, щоб його можна було прочитати очима, не перетворюючи запуск на розслідування рівня криміналістики. Ми не будуємо production-логування, але робимо повідомлення достатньо стабільними, щоб бачити порядок і сенс.
Корисно подумки тримати таку схему старту — вона ж допоможе пояснити собі, чому BFPP і BPP не взаємозамінні:
flowchart TD
A["Завантажено BeanDefinition"] --> B["BFPP: sanity checks / правки метаданих"]
B --> C["Створення singleton-бінів"]
C --> D["BPP до init (якщо є)"]
D --> E["Фаза ініціалізації: @PostConstruct / initMethod"]
E --> F["BPP після init (наш трекінг)"]
F --> G["Контекст готовий"]
Практична перевірка виходить майже самоочевидною: якщо BFPP падає, ви побачите помилку ще до того, як почнуть з’являтися повідомлення BPP про створені сервіси. А якщо BFPP проходить, то після створення бінів ви побачите трекінг.
Наприклад, такий вивід — сильно спрощено — матиме приблизно такий вигляд:
[track] orderPlacementService -> com.example.contextflow.application.service.OrderPlacementService
[track] orderCancellationService -> com.example.contextflow.application.service.OrderCancellationService
[track] reportingService -> com.example.contextflow.application.reporting.ReportingService
І так, це той випадок, коли «зайвий println» — корисний println: він перетворює абстрактну модель пайплайна на спостережувану поведінку. А потім, коли ви почнете вмикати інші інфраструктурні механіки, вам буде простіше відрізняти «звичайний об’єкт» від «контейнерної штуки».
6. Типові помилки під час роботи з BFPP/BPP
Помилка №1: складати BFPP/BPP поруч із бізнес-сервісами, тому що «так швидше знайти».
Це майже завжди призводить до того, що проєкт перестає читатися: поруч із OrderPlacementService раптом з’являється клас, який править BeanDefinition, і мозок читача починає робити сальто. Правильніше тримати processors у support.postprocessor, а реєстрацію — у модулі config, щоб було видно: це інфраструктура, а не частина домену.
Помилка №2: намагатися в BFPP отримувати звичайні біни через getBean() і використовувати їх як готові об’єкти.
На BFPP-фазі ви маєте думати «контейнером»: є описи, є набір definitions, є структура. Якщо ви почнете витягувати біни, то або спровокуєте ранню ініціалізацію, або отримаєте дивні напівробочі стани, або, що частіше, просто створите собі проблему, яку потім буде неможливо пояснити новачкові.
Помилка №3: перевіряти в BFPP runtime-дані бізнес-сценарію.
Іноді хочеться написати «перевірку»: а чи є хоча б одне замовлення в OrderStore? Але це вже не sanity check контейнера, а логіка сценарію. Контейнерний BFPP не має знати, які замовлення створюватимуться і коли. Він відповідає лише за те, що застосунок зібрано принаймні життєздатно: є порти, є реалізації, є ключові інфраструктурні біни.
Помилка №4: робити критерій BPP занадто широким і обробляти «взагалі всі біни».
Технічно BPP має владу над кожним об’єктом у контексті. І якщо ви використовуєте її без фільтрів, то отримуєте консольний шум, непередбачувані побічні ефекти й запитання «чому стало повільно». У ContextFlow відстежуйте тільки вибрані ролі: сервіси, конкретні порти, конкретні пакети. Вузький критерій — це не обмеження, а спосіб лишитися при здоровому глузді.
Помилка №5: забути повернути бін із методу BPP або повернути несумісну обгортку.
BPP — це місце, де одна випадкова помилка перетворює застосунок на сюрреалізм. Якщо повернути null, бін зникне. Якщо повернути об’єкт іншого типу, автозв’язування може зламатися в неочікуваному місці. Тому в діагностичному BPP майже завжди правильна відповідь — «повернути той самий об’єкт, просто вивести інформацію».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ