1. Признаки раздутого AppConfig
Пока проект маленький, один конфигурационный класс кажется отличной идеей: открыл файл — увидел «как всё собрано», закрыл — пошёл жить дальше. Но у AppConfig есть неприятная суперсила: он растёт быстрее, чем вы успеваете сказать «я добавлю всего один маленький бин». После properties, profiles, resources, locale-правил и MessageSource в нём начинают жить решения с совершенно разными причинами изменения, и именно это делает конфиг неудобным.
Представьте типичный путь учебного проекта ContextFlow. Сначала мы добавили сканирование компонентов, потом пару инфраструктурных @Bean, затем настройки из properties, затем Resource-шаблоны, теперь сообщения. В итоге конфигурация становится похожа на кухонный ящик, куда в какой-то момент начинают класть и вилки, и батарейки, и отвёртку, и странный ключ «непонятно от чего, но выбрасывать жалко».
Проблема giant config class обычно проявляется не в том, что «много строк» (хотя и это тоже), а в том, что рядом оказываются вещи из разных миров: например, бин для MessageSource, бин для ReportOutputManager, бин для выбора Locale, бин для чего-то ещё — и всё это перемешано с @ComponentScan. Вы открываете файл, чтобы найти одну маленькую настройку, и внезапно читаете половину романа.
Вот как выглядит “симптоматика” (без драматизма, чисто по ощущениями разработчика):
| Симптом | Что происходит в голове | Чем это плохо в жизни |
|---|---|---|
| В одном классе и scanning, и локаль, и отчёты | «Я пришёл за солью, а оказался на собрании жильцов» | Трудно читать, трудно объяснять, трудно поддерживать |
| Конфиг часто правится разными людьми | «Почему у меня конфликт в Git на @Bean методах?» | Мёрдж-конфликты и постоянное “ой, я случайно сломал твою настройку” |
| Много @Bean методов с похожими именами | «Этот formatter() — тот или другой?» | Лёгкая путаница превращается в тяжёлые баги wiring’а |
| В конфиге появляется логика «если то, иначе это» | «Почему бизнес-решение живёт в конфиге?» | Конфигурация превращается в второй слой приложения |
И вот тут нам нужен следующий инструмент: научиться дробить конфигурацию на куски так, чтобы проект становился более читаемым, а не более “сложным ради сложности”.
2. Конфигурационный модуль
Когда я говорю «конфигурационный модуль», я не имею в виду multi-module Gradle (это другая история и точно не сегодня). Здесь модуль — это просто маленький @Configuration класс, который отвечает за одну понятную часть wiring’а. И ключевая идея очень взрослая: делим конфигурацию не по количеству бинов, а по причинам изменения.
То есть хороший конфигурационный модуль — это такой класс, про который вы можете сказать человеческую фразу: «Он отвечает за локализацию», или «Он отвечает за сборку core-слоя и компонент-сканирование», или «Он отвечает за инфраструктуру отчётов». Если вы пытаетесь описать модуль как «ну… тут разные общие штуки», значит вы только что придумали будущий MiscConfig, который через неделю станет свалкой.
Для ContextFlow логика разбиения обычно естественная:
- один модуль отвечает за @ComponentScan и “поднятие” приложения как набора компонентов;
- один модуль отвечает за MessageSource и message bundles;
- один модуль отвечает за правило выбора Locale (например, preferred locale клиента или default);
- один модуль отвечает за отчёты и их output-настройки (если нужно).
Обратите внимание: модуль — это по сути «кусочек composition root», только не весь сразу, а аккуратно нарезанный. Spring-контейнер любит такую дисциплину: ему всё равно, один у вас конфиг или десять, но вам не всё равно.
Мини-идея, которую полезно держать в голове: @Configuration — это не «ещё один компонент», а карта сборки. Когда карта становится слишком крупной и на ней слишком много слоёв, её логично разделить. Не потому что «так принято», а потому что вы хотите снова видеть структуру, а не хаос.
Чтобы это получилось, модулю нужна одна простая добродетель: он должен делать wiring, а не «жить жизнью приложения». Если внутри @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
%% Схема помогает "прочитать" wiring сверху вниз: root -> модули -> бины/компоненты.
AppConfig["AppConfig (root)"] --> CoreConfig["CoreConfig: component scan"]
AppConfig --> MessageConfig["MessageConfig: MessageSource"]
AppConfig --> LocalizationConfig["LocalizationConfig: Locale rule"]
CoreConfig --> Components["@Component/@Service/@Repository beans"]
MessageConfig --> MsgBean["bean: messageSource"]
LocalizationConfig --> LocaleBeans["beans: defaultLocale, localeSelector"]
У @Import есть два практических плюса, которые вы почувствуете почти сразу.
Первый — явность. Вы открываете AppConfig и видите структуру приложения, а не пытаетесь мысленно восстановить её по 30 методам.
Второй — контроль. Импорт не зависит от того, где лежит класс и попадает ли он в @ComponentScan. Вы сами решаете, что входит в сборку.
И ещё один нюанс, который полезно снять заранее: порядок классов в @Import({...}) не является «порядком создания бинов» в стиле «сначала это, потом то». Контейнер создаёт бины, ориентируясь на зависимости, lifecycle и собственные правила. Поэтому @Import — это про состав конфигурации, а не про «скрипт выполнения».
4. Рефакторинг AppConfig на модули
Сейчас сделаем самый практичный шаг: покажем, как именно AppConfig можно разрезать на модули, чтобы после этого было проще жить. Идея не в том, чтобы срочно сделать «идеальную архитектуру», а в том, чтобы получить прозрачную сборку: один класс показывает карту, модули содержат конкретные решения.
Начнём с CoreConfig. Его роль — сказать Spring, где искать ваши @Service/@Component классы. И тут есть важный момент: если вы начнёте сканировать вообще весь com.example.contextflow, вы рискуете случайно подсосать и сами конфиги через scanning, а @Import тогда станет либо лишним, либо начнутся дубли и переопределения. Поэтому разумный teaching-default здесь — сканировать только те пакеты, где реально живут компоненты.
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 спокойно попадает сюда через scan. А config пакет в scan специально не входит: его мы собираем явно через @Import.
Теперь модуль сообщений. Он отвечает за то, чтобы в контексте появился бин с именем messageSource. И да, имя важно — мы это обсуждали: ApplicationContext ищет именно его как стандартную точку message lookup.
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_ru.properties.
source.setBasename("messages");
return source;
}
}
Теперь модуль локализации. Его задача — не хранить тексты, а определить правило выбора локали и дать приложению один-два маленьких бина, которые с этим помогают. Например, LocaleSelector, который выбирает preferred locale клиента, а если её нет — берёт default.
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 (например: "ru-RU", "en-US") и переводим в Locale.
return Locale.forLanguageTag(tag);
}
@Bean
LocaleSelector localeSelector(Locale defaultLocale) {
// Склеиваем правило выбора локали: если у клиента нет preferred locale, используем default.
return new LocaleSelector(defaultLocale);
}
}
И наконец корневой AppConfig, который собирает всё это в одну точку входа:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false) // Root config: как правило, без @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();
} // На выходе корректно закрываем контекст (ресурсы, lifecycle).
}
}
Даже если вы не помните наизусть, что лежит в MessageConfig, вы уже понимаете, где искать: структура читается сверху вниз. Это тот редкий случай, когда «разбили на файлы» действительно сделало систему проще, а не раздробило её.
5. @Import и @ComponentScan вместе
Когда вы впервые пробуете модульную конфигурацию, часто возникает ощущение, что @Import и @ComponentScan — конкуренты: мол, либо “всё импортировать”, либо “всё сканировать”. На практике они чаще работают как два инструмента для разных типов задач, и ваша цель — чтобы они не мешали друг другу.
@ComponentScan удобен для тех классов, которые являются «обычными компонентами приложения»: сервисы, репозитории (в нашем смысле — in-memory store), отправители уведомлений, аудит и так далее. Эти классы “живут” внутри нашего кода, их много, и мы действительно хотим, чтобы контейнер находил их автоматически.
@Import удобен для конфигурации как для “карты сборки”. Конфиги мы почти всегда хотим подключать явно, потому что их присутствие меняет структуру контекста. Если конфиг «подключился случайно, потому что попал в scanning», вы получаете эффект “а откуда это взялось?”. И именно поэтому мы в CoreConfig выше сузили scanning до application / infrastructure, оставив конфиги вне зоны сканирования.
Ещё один важный момент — дисциплина имён бинов. В модульной конфигурации очень легко случайно сделать два @Bean метода с одинаковым именем в разных модулях, например два разных messageSource() или два defaultLocale(). Что будет дальше, зависит от настроек контейнера и конкретного контекста: иногда одно определение перекроет другое, иногда контекст упадёт на старте. И то, и другое — плохие сюрпризы. Поэтому модульность не отменяет аккуратность: если модуль отвечает за сообщения, то именно там должен быть единственный messageSource(), и больше нигде.
Ещё тонкий нюанс: конфигурационные классы — это тоже бины. Если вы случайно сделаете так, что один и тот же конфиг попал в контекст дважды (через scanning и через @Import), вы рискуете получить либо “дублирование определения”, либо “а почему мои бины вдруг появились два раза?”. Поэтому простое правило для новичка звучит так: либо конфиги импортируем явно, либо включаем их в scanning осознанно, но не делаем оба пути одновременно.
Если держать это в голове, то получается очень спокойная модель:
- компоненты приложения приходят через scanning;
- инфраструктурные и “точно-настроенные” вещи приходят через @Bean в модулях;
- верхний уровень сборки описан коротко через @Import.
6. Типичные ошибки при модульной конфигурации
Когда вы впервые начинаете дробить конфиг, очень хочется сделать это «максимально красиво». И иногда это приводит к тому, что конфигурация становится сложнее, чем само приложение. Ниже несколько ошибок, которые чаще всего происходят именно на старте, и из-за которых у новичка появляется ощущение, что @Import — это “лишняя бюрократия”. На самом деле проблема обычно в том, как именно мы нарезали модули и как мы их собрали.
Ошибка №1: делить конфигурацию по количеству @Bean методов, а не по смыслу.
Если вы делаете файл “на два бина” просто потому что “так меньше”, вы быстро получаете 15 микроконфигов, которые невозможно запомнить. Модуль должен отвечать на вопрос «зачем он существует» и иметь одну чёткую причину изменения. Тогда файлов может быть больше, но читать станет легче.
Ошибка №2: оставлять @ComponentScan("com.example.contextflow") и одновременно импортировать конфиги из этого же пакета.
Такой вариант выглядит безобидно, пока не случится дублирование или переопределение бинов. Если вы хотите, чтобы конфигурация была явной, то scanning должен покрывать компоненты приложения, но не подтягивать конфиги “автоматически”. В учебном проекте это особенно важно: мы учимся видеть wiring, а не угадывать, откуда он возник.
Ошибка №3: превращать AppConfig в “гиганта”, просто раскидав методы по классам.
Иногда AppConfig очищают от @Bean методов, но вместо этого начинают писать в нём огромные @Import({...}) списки без структуры, а сами модули называют невнятно. В результате “карта” становится такой же нечитаемой, как и старый монолитный конфиг. AppConfig должен быть коротким и говорящим, а названия модулей — отражать ответственность.
Ошибка №4: прятать бизнес-логику внутрь конфигов.
Конфигурация — это место, где мы создаём и связываем объекты, а не место, где мы решаем бизнес-вопросы. Если внутри @Bean метода вы начинаете вычислять сложные правила (особенно завязанные на доменную модель), то через пару дней вы уже не сможете понять, где “настоящая” логика приложения, а где логика “сборки”.
Ошибка №5: конфликт имён бинов между модулями.
В модульной конфигурации имя @Bean по умолчанию — это имя метода. Два метода defaultLocale() в разных модулях выглядят как «ну и что, они же в разных классах», но для контейнера это два кандидата на один и тот же bean name. Лучше заранее дисциплинировать модули так, чтобы ключевые инфраструктурные бины имели единственную точку определения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ