1. Ознаки роздутого AppConfig
Поки проєкт маленький, один конфігураційний клас здається чудовою ідеєю: відкрили файл — побачили, як усе зібрано, закрили — і пішли далі. Але в AppConfig є неприємна суперсила: він росте швидше, ніж ви встигаєте сказати: «Я додам лише один маленький бін». Після properties, profiles, resources, правил локалі та MessageSource у ньому починають жити рішення із зовсім різними причинами змін, і саме це робить конфіг незручним.
Уявіть типовий шлях навчального проєкту ContextFlow. Спочатку ми додали сканування компонентів, потім кілька інфраструктурних @Bean, далі налаштування з properties, потім Resource-шаблони, а тепер — повідомлення. У підсумку конфігурація стає схожою на кухонну шухляду, куди в якийсь момент починають класти і виделки, і батарейки, і викрутку, і дивний ключ «незрозуміло від чого, але викидати шкода».
Проблема великого конфігураційного класу зазвичай проявляється не в тому, що «багато рядків» (хоча й це також), а в тому, що поруч опиняються речі з різних світів: наприклад, бін для MessageSource, бін для ReportOutputManager, бін для вибору Locale, бін для чогось ще — і все це перемішано з @ComponentScan. Ви відкриваєте файл, щоб знайти одне маленьке налаштування, і раптом читаєте вже половину роману.
Ось як виглядає «симптоматика» (без драматизму, чисто за відчуттями розробника):
| Симптом | Що відбувається в голові | Чим це погано в житті |
|---|---|---|
| В одному класі і сканування, і локаль, і звіти | «Я прийшов по сіль, а опинився на зборах мешканців» | Важко читати, важко пояснювати, важко підтримувати |
| Конфіг часто редагують різні люди | «Чому у мене конфлікт у Git на @Bean методах?» | Мердж-конфлікти та постійне «ой, я випадково зламав ваше налаштування» |
| Багато @Bean методів із подібними іменами | «Цей formatter() — той чи інший?» | Легка плутанина перетворюється на складні баги звʼязування |
| У конфігу з’являється логіка «якщо так, то одне, якщо ні — інше» | «Чому бізнес-рішення живе в конфігу?» | Конфігурація перетворюється на ще один шар застосунку |
І ось тут нам потрібен наступний інструмент: навчитися дробити конфігурацію на шматки так, щоб проєкт ставав читабельнішим, а не «складнішим заради складності».
2. Конфігураційний модуль
Коли я кажу «конфігураційний модуль», я не маю на увазі multi-module Gradle (це інша історія й точно не сьогодні). Тут модуль — це просто маленький @Configuration клас, який відповідає за одну зрозумілу частину звʼязування. Ідея тут дуже проста й зріла: ділимо конфігурацію не за кількістю бінів, а за причинами змін.
Тобто хороший конфігураційний модуль — це такий клас, про який ви можете сказати людську фразу: «Він відповідає за локалізацію», або «Він відповідає за збірку core-шару та component scan», або «Він відповідає за інфраструктуру звітів». Якщо ви намагаєтеся описати модуль як «ну… тут різні загальні штуки», значить ви щойно вигадали майбутній MiscConfig, який за тиждень стане смітником.
Для ContextFlow логіка розбиття зазвичай природна:
- один модуль відповідає за @ComponentScan і «підняття» застосунку як набору компонентів;
- один модуль відповідає за MessageSource і message bundles;
- один модуль відповідає за правило вибору Locale (наприклад, бажану локаль клієнта або локаль за замовчуванням);
- один модуль відповідає за звіти та їхні налаштування виведення (якщо потрібно).
Зверніть увагу: модуль — це, по суті, «шматочок composition root», тільки не весь одразу, а акуратно нарізаний. Spring-контейнер любить таку дисципліну: йому байдуже, один у вас конфіг чи десять, але вам не байдуже.
Мініідея, яку корисно тримати в голові: @Configuration — це не «ще один компонент», а карта збірки. Коли карта стає надто великою й на ній надто багато шарів, її логічно розділити. Не тому, що «так заведено», а тому, що ви хочете знову бачити структуру, а не хаос.
Щоб це спрацювало, модулю потрібна одна проста чеснота: він має робити звʼязування, а не «жити життям застосунку». Якщо всередині @Bean методу у вас починає з’являтися складна логіка, ви випадково будуєте другий бізнес-шар у конфігу. А конфіги так не домовлялися.
3. @Import для збірки модулів
Коли конфігурацію розділено на модулі, виникає нове запитання: «Гаразд, а як усе це зібрати назад в одну точку входу?» І ось тут на сцену виходить @Import. Це анотація, яка говорить Spring: «Візьми ось ці класи й включи їх у конфігурацію контексту». Якщо зовсім просто, @Import — це «склеювання» конфігураційних модулів в один набір.
Важливо розуміти @Import не як «магічну штуку», а як дуже прямолінійну дію. Усередині ідея така: ви могли б вручну зареєструвати класи в контексті, приблизно так:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ManualRegisterDemo {
public static void main(String[] args) {
// Приклад "вручну": створюємо контекст без стартової конфігурації.
try (var ctx = new AnnotationConfigApplicationContext()) {
// Реєструємо конфігураційні класи явно — ніби робили @Import.
ctx.register(CoreConfig.class, MessageConfig.class);
// Після реєстрації потрібно "запустити" контейнер: обробити конфіги і створити біни.
ctx.refresh();
} // try-with-resources гарантує коректне закриття контексту.
}
}
@Import робить те саме, тільки декларативно, на рівні конфігурації. Ми не будемо перетворювати лекцію на розбір внутрішностей refresh(), але важливо вловити момент: імпорт працює на фазі реєстрації конфігурації, тобто ще до того, як Spring почне створювати реальні об’єкти.
Ось найупізнаваніший вигляд кореневої конфігурації:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false) // Конфіг-«карта»: не потрібен CGLIB-проксі для @Bean-методів.
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // Явно збираємо модулі в один контекст.
public class AppConfig {
// В ідеалі тут немає @Bean: лише "зміст" і композиція модулів.
}
Тепер AppConfig стає не складом @Bean методів, а короткою картою: видно, з яких частин зібрано контекст.
Щоб «прочитати» таку конфігурацію правильно, корисно тримати просту схему в голові:
flowchart TD
%% Схема допомагає "прочитати" звʼязування згори донизу: корінь -> модулі -> біни/компоненти.
AppConfig["AppConfig (корінь)"] --> CoreConfig["CoreConfig: сканування компонентів"]
AppConfig --> MessageConfig["MessageConfig: MessageSource"]
AppConfig --> LocalizationConfig["LocalizationConfig: правило локалі"]
CoreConfig --> Components["@Component/@Service/@Repository біни"]
MessageConfig --> MsgBean["бін: messageSource"]
LocalizationConfig --> LocaleBeans["біни: defaultLocale, localeSelector"]
У @Import є два практичні плюси, які ви відчуєте майже одразу.
Перший — явність. Ви відкриваєте AppConfig і бачите структуру застосунку, а не намагаєтеся подумки відновити її за 30 методами.
Другий — контроль. Імпорт не залежить від того, де лежить клас і чи потрапляє він у @ComponentScan. Ви самі вирішуєте, що входить до збірки.
І ще один нюанс, який корисно зняти заздалегідь: порядок класів у @Import({...}) не є «порядком створення бінів» у стилі «спочатку це, потім те». Контейнер створює біни, орієнтуючись на залежності, життєвий цикл і власні правила. Тому @Import — це про склад конфігурації, а не про «скрипт виконання».
4. Рефакторинг AppConfig на модулі
Зараз зробимо найпрактичніший крок: покажемо, як саме AppConfig можна розрізати на модулі, щоб після цього було простіше жити. Ідея не в тому, щоб терміново зробити «ідеальну архітектуру», а в тому, щоб отримати прозору збірку: один клас показує карту, модулі містять конкретні рішення.
Почнемо з CoreConfig. Його роль — сказати Spring, де шукати ваші @Service/@Component класи. І тут є важливий момент: якщо ви почнете сканувати взагалі весь com.example.contextflow, ви ризикуєте випадково підтягнути і самі конфіги через сканування, а @Import тоді стане або зайвим, або почнуться дублікати й перевизначення. Тому розумний навчальний підхід тут — сканувати лише ті пакети, де справді живуть компоненти.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false) // Конфіг без @Bean: він задає правила сканування компонентів.
@ComponentScan(basePackages = {
// Скануємо лише прикладні пакети, щоб конфіги не підтягувалися випадково.
"com.example.contextflow.application",
"com.example.contextflow.infrastructure"
})
public class CoreConfig {
// Порожній клас — це нормально: тут важливі анотації та їхні параметри.
}
application.i18n — це той самий application-шар, тому OrderTextService спокійно потрапляє сюди через сканування. А пакет config у сканування спеціально не входить: його ми збираємо явно через @Import.
Тепер модуль повідомлень. Він відповідає за те, щоб у контексті з’явився бін з іменем messageSource. І так, ім’я важливе — ми це вже обговорювали: ApplicationContext шукає саме його як стандартну точку пошуку повідомлень.
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
@Configuration(proxyBeanMethods = false)
public class MessageConfig {
@Bean
MessageSource messageSource() {
// За замовчуванням Spring шукає бін саме з іменем "messageSource" для локалізації повідомлень.
var source = new ResourceBundleMessageSource();
// "messages" означає, що шукатимуться файли на кшталт messages.properties / messages_uk.properties.
source.setBasename("messages");
return source;
}
}
Тепер модуль локалізації. Його завдання — не зберігати тексти, а визначити правило вибору локалі й дати застосунку один-два маленькі біни, які з цим допомагають. Наприклад, LocaleSelector, який обирає бажану локаль клієнта, а якщо її немає — бере локаль за замовчуванням.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Locale;
@Configuration(proxyBeanMethods = false)
public class LocalizationConfig {
@Bean
Locale defaultLocale(@Value("${contextflow.locale.default}") String tag) {
// Значення беремо з properties (наприклад: "uk-UA", "en-US") і перетворюємо на Locale.
return Locale.forLanguageTag(tag);
}
@Bean
LocaleSelector localeSelector(Locale defaultLocale) {
// Зшиваємо правило вибору локалі: якщо у клієнта немає бажаної локалі, використовуємо локаль за замовчуванням.
return new LocaleSelector(defaultLocale);
}
}
І нарешті кореневий AppConfig, який збирає все це в одну точку входу:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false) // Коренева конфігурація: як правило, без @Bean і без логіки.
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // Явна "карта збірки" контексту.
public class AppConfig {
}
Відчуйте різницю: тепер AppConfig — це «зміст», а не «том другий із додатками».
А запуск застосунку при цьому лишається таким самим простим, як і раніше, тільки тепер у вас чистіша структура конфігурації:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Запускаємо контекст із кореневим AppConfig, який уже імпортує всі модулі.
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Дістаємо вхідний бін сценарію і запускаємо застосунок.
ctx.getBean(ScenarioRunner.class).run();
} // На виході коректно закриваємо контекст (ресурси, життєвий цикл).
}
}
Навіть якщо ви не пам’ятаєте напам’ять, що лежить у MessageConfig, ви вже розумієте, де шукати: структура читається згори донизу. Це той рідкісний випадок, коли «розбили на файли» справді зробило систему простішою, а не роздрібнило її.
5. @Import і @ComponentScan разом
Коли ви вперше пробуєте модульну конфігурацію, часто виникає відчуття, що @Import і @ComponentScan — конкуренти: мовляв, або «все імпортувати», або «все сканувати». На практиці вони частіше працюють як два інструменти для різних типів завдань, і ваша мета — щоб вони не заважали одне одному.
@ComponentScan зручний для тих класів, які є «звичайними компонентами застосунку»: сервіси, репозиторії (у нашому сенсі — in-memory store), сервіси сповіщень, аудит тощо. Ці класи «живуть» усередині нашого коду, їх багато, і ми справді хочемо, щоб контейнер знаходив їх автоматично.
@Import зручний для конфігурації як для «карти збірки». Конфіги ми майже завжди хочемо підключати явно, тому що їхня присутність змінює структуру контексту. Якщо конфіг «підключився випадково, бо потрапив у сканування», ви отримуєте ефект «а звідки це взялося?». І саме тому ми в CoreConfig вище звузили сканування до application / infrastructure, залишивши конфіги поза зоною сканування.
Ще один важливий момент — дисципліна імен бінів. У модульній конфігурації дуже легко випадково зробити два @Bean методи з однаковим іменем у різних модулях, наприклад два різні messageSource() або два defaultLocale(). Що буде далі, залежить від налаштувань контейнера та конкретного контексту: інколи одне визначення перекриє інше, інколи контекст упаде на старті. І те, й те — погані сюрпризи. Тому модульність не скасовує акуратність: якщо модуль відповідає за повідомлення, то саме там має бути єдиний messageSource(), і більше ніде.
Ще один тонкий нюанс: конфігураційні класи — це теж біни. Якщо ви випадково зробите так, що один і той самий конфіг потрапить у контекст двічі (через сканування і через @Import), ви ризикуєте отримати або «дублювання визначення», або «а чому мої біни раптом зʼявилися двічі?». Тому просте правило для новачка звучить так: або конфіги імпортуємо явно, або включаємо їх у сканування усвідомлено, але не робимо обидва шляхи одночасно.
Якщо тримати це в голові, то виходить дуже спокійна модель:
- компоненти застосунку приходять через сканування;
- інфраструктурні та «точно налаштовані» речі приходять через @Bean у модулях;
- верхній рівень збірки описано коротко через @Import.
6. Типові помилки під час модульної конфігурації
Коли ви вперше починаєте дробити конфіг, дуже хочеться зробити це «максимально красиво». І інколи це призводить до того, що конфігурація стає складнішою, ніж сам застосунок. Нижче кілька помилок, які найчастіше трапляються саме на старті, і через які в новачка з’являється відчуття, що @Import — це «зайва бюрократія». Насправді проблема зазвичай у тому, як саме ми нарізали модулі та як їх зібрали.
Помилка №1: ділити конфігурацію за кількістю @Bean методів, а не за змістом.
Якщо ви робите файл «на два біни» просто тому, що «так менше», ви швидко отримуєте 15 мікроконфігів, які неможливо запам’ятати. Модуль має відповідати на запитання «навіщо він існує» і мати одну чітку причину змін. Тоді файлів може бути більше, але читати стане легше.
Помилка №2: залишати @ComponentScan("com.example.contextflow") і водночас імпортувати конфіги з цього самого пакета.
Такий варіант виглядає нешкідливо, доки не станеться дублювання або перевизначення бінів. Якщо ви хочете, щоб конфігурація була явною, то сканування має покривати компоненти застосунку, але не підтягувати конфіги «автоматично». У навчальному проєкті це особливо важливо: ми вчимося бачити звʼязування, а не вгадувати, звідки воно виникло.
Помилка №3: перетворювати AppConfig на «гіганта», просто розкидавши методи по класах.
Іноді AppConfig очищають від @Bean методів, але замість цього починають писати в ньому величезні @Import({...}) списки без структури, а самі модулі називають невиразно. У результаті «карта» стає такою ж нечитабельною, як і старий монолітний конфіг. AppConfig має бути коротким і промовистим, а назви модулів — відображати відповідальність.
Помилка №4: ховати бізнес-логіку всередину конфігів.
Конфігурація — це місце, де ми створюємо та пов’язуємо об’єкти, а не місце, де ми вирішуємо бізнес-питання. Якщо всередині @Bean методу ви починаєте обчислювати складні правила (особливо пов’язані з доменною моделлю), то за кілька днів ви вже не зможете зрозуміти, де «справжня» логіка застосунку, а де логіка «збірки».
Помилка №5: конфлікт імен бінів між модулями.
У модульній конфігурації ім’я @Bean за замовчуванням — це ім’я методу. Два методи defaultLocale() у різних модулях виглядають як «ну й що, вони ж у різних класах», але для контейнера це два кандидати на одне й те саме ім’я біну. Краще заздалегідь дисциплінувати модулі так, щоб ключові інфраструктурні біни мали єдину точку визначення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ