← назад к разделу

В гексагональном приложении код разбит на несколько модулей: core/ с бизнес-логикой, persistence/ с базой данных, адаптеры для HTTP, Kafka и других систем. Кто-то должен собрать всё это вместе и запустить — этим занимается bootstrap/.

Зачем нужен отдельный bootstrap-модуль

Без выделенного bootstrap-модуля точка входа живёт где придётся — в core/ или в одном из адаптеров. Это быстро ломает структуру: модуль с бизнес-логикой начинает тянуть Spring Boot, конфигурации размываются по нескольким местам, становится непонятно, что откуда зависит.

bootstrap/ — это composition root: место, в котором собирается приложение. Его роль строго ограничена:

  • объявить точку входа (main);
  • собрать Spring-контекст из всех модулей;
  • владеть конфигурационными файлами (application.yml);
  • содержать Dockerfile и инфраструктурные скрипты.

Никакой бизнес-логики, никаких контроллеров — только сборка.

bootstrap in-адаптеры входы в приложение out-адаптеры внешние системы core домен и порты адаптеры core

bootstrap подключает три группы модулей сразу, а под чертой видна их собственная зависимость: адаптеры знают core, обратной стрелки нет.

Что лежит в bootstrap/

Типичная структура:

bootstrap/
├── src/main/java/ru/example/order/    # ← корневой пакет, не ...order.bootstrap
│   ├── OrderServiceApplication.java   # @SpringBootApplication + main()
│   └── config/                        # @Configuration-классы для wiring
│       ├── ClockConfig.java
│       ├── ObjectMapperConfig.java
│       └── JwtDecoderConfig.java
├── src/main/resources/
│   ├── application.yml                # общий конфиг
│   ├── application-local.yml          # локальный профиль
│   ├── application-production.yml
│   └── logback-spring.xml
├── Dockerfile
└── docker-compose.yml

Обратите внимание, где лежит класс приложения: прямо в корневом пакете ru.example.order, а не в ru.example.order.bootstrap. Это не мелочь. @SpringBootApplication начинает сканирование со своего пакета и идёт вниз — из ...order.bootstrap он увидит только сам bootstrap и не найдёт ни ядро, ни адаптеры. На этом спотыкаются чаще всего: приложение стартует, а половины бинов в контексте нет.

В config/ лежит JwtDecoderConfig, а не SecurityConfig — и это тоже осознанно. Общую для всего сервиса обвязку (как разбирать токен, где брать ключи) собирают здесь, один раз. А вот сами правила доступа — SecurityFilterChain — живут в своих in-адаптерах: у пользовательского API одни, у административного другие, и разные модули не дают их перепутать. Стянуть их в один класс в bootstrap/ — значит вернуть ровно ту проблему, ради которой адаптеры и разделяли.

Минимальная точка входа:

@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

В @Configuration-классах живут только бины, которым нужна явная настройка:

  • Clock и UuidProvider — production-реализации интерфейсов из core/;
  • ObjectMapper с кастомными модулями;
  • RestClient-бины, если их сборка не делается внутри адаптеров.

Большинство бинов поднимается автоматически через component scan — @Component-классы в адаптерах Spring находит сам.

Это и есть основная содержательная работа точки сборки, поэтому покажем её целиком — и заодно объясним, зачем нужен первый пункт.

Зачем вообще Clock и генератор идентификаторов. Не ради красоты: ядро должно быть детерминированным. Метод агрегата, который внутри себя вызывает «текущее время» или «случайный идентификатор», даёт разный результат при одинаковом входе — и тест «заказ, оформленный до полуночи, попадает в отчёт за день» либо проходит случайно, либо падает в 23:59. Поэтому время и идентификаторы приходят снаружи: в проде это настоящие реализации, в тесте — фиксированные. Ядру для этого ничего не нужно, кроме интерфейса; настоящие значения подставляет точка сборки:

// bootstrap/config/TimeAndIdConfig.java
@Configuration
class TimeAndIdConfig {

    @Bean
    Clock clock() {
        return Clock.systemUTC();            // в тестах — Clock.fixed(...)
    }

    @Bean
    IdGenerator idGenerator() {              // интерфейс объявлен в core
        return () -> new OrderId(UuidCreator.getTimeOrderedEpoch());   // UUID v7
    }
}

Какие ещё бины требуют явной сборки — и почему именно они:

// 1. Обработчики сценариев из ядра: в ядре нет аннотаций, поэтому собираем руками
@Configuration
class UseCaseConfig {
    @Bean
    PlaceOrderService placeOrderService(OrderRepository orders, PaymentGateway gateway,
                                        OutboxPort outbox, Clock clock) {
        return new PlaceOrderService(orders, gateway, outbox, clock);
    }
}

// 2. Клиент к внешней системе: настройки приходят из конфигурации, сборка — здесь
@Configuration
class SberClientConfig {
    @Bean
    RestClient sberRestClient(SberProperties props) {
        return RestClient.builder()
                .baseUrl(props.baseUrl())
                .requestFactory(timeouts(props.connectTimeout(), props.readTimeout()))
                .build();
    }
}

// 3. Бины из библиотек, у которых нет своих аннотаций
@Configuration
class SerializationConfig {
    @Bean
    ObjectMapper objectMapper() {
        return JsonMapper.builder()
                .addModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .build();
    }
}

// 4. Выбор реализации по настройке: заглушка на тестовом контуре
@Configuration
class PaymentConfig {
    @Bean
    @ConditionalOnProperty(name = "payment.mode", havingValue = "stub")
    PaymentGateway stubGateway() { return new AlwaysApprovedGateway(); }

    @Bean
    @ConditionalOnProperty(name = "payment.mode", havingValue = "sber", matchIfMissing = true)
    PaymentGateway sberGateway(RestClient client, SberMapper mapper) {
        return new SberClientAdapter(client, mapper);
    }
}

Четыре категории — и это исчерпывающий список того, что здесь появляется: классы ядра (у них нет аннотаций по определению), настроенные клиенты (настройки из конфигурации), объекты из библиотек (чужой код без аннотаций) и выбор реализации по окружению. Всё остальное находится сканированием само, и добавлять это в конфигурацию не надо.

Где живут настройки адаптера

sber.api-url в конфигурации есть, а класс, который его читает, — вопрос ровно того же разряда, что и весь материал статьи. Ответ: класс свойств живёт в модуле адаптера, а его включение — в точке сборки.

// sber-out-adapter: свойства рядом с тем, кто их использует
@ConfigurationProperties(prefix = "sber")
public record SberProperties(
        @NotBlank String baseUrl,
        @NotBlank String login,
        @NotBlank String password,
        @DefaultValue("2s") Duration connectTimeout,
        @DefaultValue("10s") Duration readTimeout) {}
// bootstrap: включение сканирования свойств по нужным пакетам
@SpringBootApplication
@ConfigurationPropertiesScan("ru.example.order")
public class OrderServiceApplication { ... }

Почему класс свойств в адаптере, а не в точке сборки. Три причины. Он описывает контракт настройки этого адаптера — то есть часть самого адаптера: перенеся его в точку сборки, вы заставите её знать про подробности чужой интеграции. Он используется только этим адаптером, и держать его отдельно от использования неудобно. И он переезжает вместе с адаптером: вынесли адаптер в другой сервис — свойства уехали с ним.

Что при этом остаётся точке сборки: включить сканирование свойств и дать значения (в файлах конфигурации). То есть адаптер говорит, что ему нужно, а точка сборки — чему это равно. Ровно та же логика, что у портов: интерфейс в ядре, реализация снаружи.

Проверка настроек при старте — обязательна. Аннотации проверки на записи свойств плюс @Validated означают, что приложение не поднимется с пустым обязательным значением, а не упадёт на первом запросе в три ночи. Это одна строка, которая экономит инцидент.

Одна тонкость про записи как свойства: они неизменяемы и заполняются через конструктор, поэтому значение по умолчанию задаётся аннотацией (как connectTimeout выше), а не полем. И Duration пишут как 2s, 10s, 500ms — фреймворк разбирает это сам, и это лучше, чем int connectTimeoutMillis, потому что в конфигурации видна единица измерения.

bootstrap/ зависит от всех модулей

В bootstrap/build.gradle.kts перечислены все остальные модули:

dependencies {
    implementation(project(":core"))

    implementation(project(":persistence"))
    implementation(project(":user-api-in-adapter"))
    implementation(project(":admin-api-in-adapter"))
    implementation(project(":kafka-in-adapter"))
    implementation(project(":sber-out-adapter"))
    implementation(project(":sms-out-adapter"))
    implementation(project(":kafka-out-adapter"))
    implementation(project(":scheduler-out-adapter"))

    // Spring Boot стартеры — только тут
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-jooq")
    implementation("org.springframework.boot:spring-boot-starter-security")

    runtimeOnly("org.postgresql:postgresql")
}

Есть ещё одна строка, которой в этом файле не видно, но которая решает, соберётся проект или нет: plugins { id("org.springframework.boot") }. Плагин Spring Boot применяют только в bootstrap/. Стоит повесить его на core/ или на адаптер — у того модуля появится задача bootJar, она попадёт в assemble, и сборка упадёт с «Main class name has not been configured»: в библиотечном модуле никакого main нет и быть не должно. Заодно обычный jar модуля переименуется в core-1.0.0-plain.jar. В остальных модулях подключают только управление версиями — io.spring.dependency-management или implementation(platform(...)) со Spring Boot BOM.

И при этом никто не зависит от bootstrap/ — в core/, persistence/ и адаптерах нет project(":bootstrap"). Стрелки зависимостей сходятся сюда и дальше не идут: bootstrap/ — закрывающий узел, в котором заканчиваются все связи.

Если core/ или адаптер начнёт зависеть от bootstrap/, получится циклическая зависимость — Gradle откажется собирать проект.

Как Spring находит бины из всех модулей

Запустили — контроллер отвечает 404, потому что Spring его не нашёл: @SpringBootApplication сканирует пакеты начиная с пакета своего класса, а контроллер лежит в соседнем модуле. Собрать бины из всех модулей можно тремя способами.

main() класс в корневом пакете run(...) контекст Spring @SpringBootApplication идёт вниз по пакетам сканирование пакетов core, входы, выходы найдены @Component бины адаптеров встают на порты ядра

Порядок старта: точка входа поднимает контекст, сканирование находит классы адаптеров, и только потом их бины встают на порты ядра.

Вариант 1 — общий корневой пакет. Если все модули лежат под одним корнем, Spring сам найдёт все @Component-классы:

package ru.example.order;   // корневой пакет

@SpringBootApplication
public class OrderServiceApplication { /* ... */ }

При условии, что все модули используют пакеты ru.example.order.*.

Вариант 2 — явный список пакетов. Когда структура пакетов не позволяет использовать общий корень:

@SpringBootApplication(scanBasePackages = {
    "ru.example.order.core",
    "ru.example.order.persistence",
    "ru.example.order.userapi",
    "ru.example.order.sberout",
})
public class OrderServiceApplication { /* ... */ }

Вариант 3 — явный импорт конфигурации. Каждый адаптер экспортирует свой @Configuration-класс, bootstrap его импортирует:

@SpringBootApplication
@Import({PersistenceConfig.class, SberOutAdapterConfig.class, UserApiInAdapterConfig.class})
public class OrderServiceApplication { /* ... */ }

Здесь есть важная оговорка. @Import ничего не отключает: @SpringBootApplication внутри себя содержит @ComponentScan, и сканирование своего пакета продолжится в любом случае. То есть вариант 3 — это добавка к первым двум, а не замена им. Чтобы он действительно стал заменой, @SpringBootApplication придётся разобрать на части и оставить @SpringBootConfiguration + @EnableAutoConfiguration без @ComponentScan. Ради «явных контрактов между модулями» так делают редко — на практике чаще берут вариант 1 или 2.

как Spring найдёт бины общий корень все модули под одним пакетом scanBasePackages перечислить пакеты @Import добавка к сканированию

Три способа собрать бины из всех модулей: первые два задают, где искать, а третий только добавляет конфигурации к сканированию.

Профили и application.yml

Все профили конфигурации живут в bootstrap/src/main/resources/. core/ и адаптеры их не видят и не контролируют.

Это три разных файла, а не один с тремя корнями spring: — такой файл просто не загрузится, ключ в YAML не может повторяться.

application.yml — общий для всех профилей:

spring:
  application:
    name: order-service

application-local.yml — для запуска на своей машине:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/orders
    username: orders
    password: orders
sber:
  api-url: https://sber-sandbox.example.com

application-production.yml — всё чувствительное берётся из переменных окружения:

spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USER}
    password: ${DB_PASSWORD}
sber:
  api-url: ${SBER_API_URL}

Разделение на профили позволяет запускать приложение локально без правки основного конфига — достаточно сказать, какой профиль включить. Как именно сказать — зависит от способа запуска:

java -jar bootstrap.jar --spring.profiles.active=local   # собранный jar
./gradlew bootRun --args='--spring.profiles.active=local' # из исходников

Для java -jar работает и -Dspring.profiles.active=local. А вот с bootRun этот способ подводит: задача запускает приложение в отдельной JVM, и системные свойства самого Gradle туда не попадают. Либо --args, либо один раз прописать в сборке:

tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") {
    systemProperty("spring.profiles.active", "local")
}

Откуда берутся значения в проде

${DB_URL} в файле конфигурации — это ссылка, и вопрос «откуда приходит значение» решается не в приложении, а вокруг него. Три способа, и они не равноценны.

1. Переменные окружения — основной способ. Приложение читает DB_URL из окружения процесса; кто его туда положил — забота среды запуска. Плюсы: работает везде одинаково, ничего не надо кодировать, значение не лежит в образе. Минусы: значение видно в описании процесса и в переменных контейнера (то есть это не секрет от того, у кого есть доступ к машине), и при большом числе настроек список становится длинным.

Практическая деталь про имена: spring.datasource.url соответствует переменной SPRING_DATASOURCE_URL — точки и дефисы превращаются в подчёркивания, регистр в верхний. Это соглашение, и оно означает, что любую настройку можно переопределить переменной окружения, не трогая файлы.

2. Настройки из хранилища конфигурации кластера. Значения лежат объектом конфигурации рядом с описанием развёртывания и попадают в контейнер либо переменными, либо смонтированным файлом. Плюс: конфигурация живёт как код в репозитории инфраструктуры, с историей и ревью. Минус: обновление значения обычно означает перезапуск — разбор, почему так и что с этим делать.

3. Хранилище секретов. Для паролей и ключей: значение не лежит ни в образе, ни в описании развёртывания. Варианты подключения — оператор, который подтягивает секрет в объект кластера; специальный драйвер, монтирующий его файлом; или приложение само обращается в хранилище при старте. Первые два проще: приложение по-прежнему читает переменную или файл и ничего не знает про хранилище.

Что важно решить про секреты, и это не техника. Пароль базы, ключ платёжного провайдера, токен подписи — не лежат в репозитории ни в каком виде, включая закодированный. Файл конфигурации в репозитории содержит только ссылки (${DB_PASSWORD}), а значения приходят извне. Локальная разработка — исключение: там значения фиктивные и годятся только для локальной базы. Отдельно про ротацию: смена пароля не должна требовать выката, а это значит, что приложение либо перечитывает значение, либо (чаще) переносит перезапуск — и это решение принимают заранее.

Порядок, в котором значения перекрывают друг друга, полезно помнить: аргументы командной строки → переменные окружения → файл конфигурации профиля → общий файл конфигурации. То есть переменная окружения всегда сильнее файла в образе — на этом и держится способ 1.

Тесты в точке сборки

Модуль, который «только клей», оказывается главным тестовым модулем — и это стоит проговорить, потому что удивляет.

Что здесь живёт:

  • Тест подъёма контекста. Один тест, который поднимает приложение и проверяет, что контекст собрался. Ловит ровно те ошибки, которые иначе находят при выкате: забытая фабрика бина, две реализации одного порта, неверный пакет сканирования, отсутствующее обязательное свойство. Это самый дешёвый полезный тест в проекте.
  • Сквозные тесты: запрос по настоящему HTTP, база в контейнере, заглушки внешних систем — проверяют, что собранное приложение действительно работает.
  • Тесты архитектуры — либо здесь, либо в отдельном модуле, который видит все остальные (см. раскладку модулей).
  • Тест на настройки профиля: поднять контекст с профилем, похожим на прод, и убедиться, что все обязательные значения заданы.

Почему подъём полного контекста дорогой — и что с этим делать. Полный контекст поднимается секунды (на большом приложении — десятки), поэтому таких тестов немного, и они переиспользуют один контекст: набор настроек должен быть одинаковым, иначе фреймворк поднимет ещё один контекст на каждый уникальный набор. Отсюда практика: подмены бинов в сквозных тестах держат в одном общем базовом классе, а не расставляют по тестам; разбор — в статье про интеграционные тесты.

Что здесь НЕ тестируют: правила домена (их место в ядре, там они идут за миллисекунды), преобразование адаптеров (в модуле адаптера с заглушкой), запросы к базе (в модуле хранилища с контейнером). Сквозной тест проверяет, что части соединены, а не поведение частей — иначе прогон превращается в минуты, а причина падения перестаёт быть понятной.

Когда отдельный модуль сборки не нужен

Честная граница: в одномодульном сервисе отдельного модуля сборки нет и быть не может — точка входа, настройки и сборка бинов живут в том же модуле, что всё остальное, и это нормально. Разговор о точке сборки как об отдельном модуле возникает только в многомодульной раскладке.

Но и там есть случай, когда модуля не заводят: два модуля — ядро и всё остальное. Тогда «всё остальное» и есть точка сборки: там адаптеры, настройки, точка входа. Заводить третий модуль ради десяти строк конфигурации незачем.

Модуль сборки становится нужен, когда: модулей больше двух (иначе настройки пришлось бы положить в один из адаптеров, и он стал бы «главным» — а адаптеры должны быть равноправны); адаптеров входа несколько, и ни один не имеет права быть точкой входа; или нужна сборка нескольких приложений из одних модулей (два исполняемых файла: веб-приложение и пакетная задача) — тогда точек сборки может быть и две.

И то, что остаётся верным при любой раскладке, даже в одном модуле: точка сборки — единственное место, где знают про всё. Класс приложения в корневом пакете, настройки в одном месте, выбор реализаций по окружению там же. Даже если это не отдельный модуль, это отдельная ответственность, и смешивать её с адаптерами не стоит: настройки, разбросанные по модулям, — та же беда, что бизнес-логика в контроллере.

Частые ошибки

Контроллеры и логика в bootstrap

Соблазн быстро добавить обработчик прямо в bootstrap понятен, но это разрушает архитектуру:

// Неправильно — контроллер в bootstrap
package ru.example.order.bootstrap;

@RestController
public class OrderController {
    @PostMapping("/orders")
    public OrderJson createOrder(@RequestBody CreateOrderRequest req) { ... }
}

Проблема в том, что bootstrap/ должен быть тонким — только сборка. Как только там появляется логика:

  • её сложно перенести в нужный модуль без рефакторинга;
  • тест на такой контроллер вынужден поднимать весь контекст, хотя достаточно было бы легковесного @WebMvcTest.

Правило простое: контроллеры — в *-in-adapter/, бизнес-логика — в core/, bootstrap/ — только сборка.

@SpringBootApplication не на своём месте

Если @SpringBootApplication оказывается в core/ или в адаптере, возникают конкретные проблемы:

// Неправильно — @SpringBootApplication в core
package ru.example.order.core;

@SpringBootApplication
public class CoreApplication { /* ... */ }

Во-первых, core/ начинает тянуть всю Spring Boot инфраструктуру — теряется возможность использовать ядро без фреймворка. Во-вторых, когда в проекте два @SpringBootApplication (в core/ и в bootstrap/), непонятно, что запускать. В-третьих, оба класса запускают сканирование пакетов — они могут конфликтовать.

@SpringBootApplication должен быть ровно один, строго в bootstrap/.

Коротко

  • bootstrap/ — composition root: точка входа, Spring-контекст, application.yml, Dockerfile. bootstrap/ зависит от всех остальных модулей; никто не зависит от bootstrap/.
  • Spring-бины из адаптеров подхватываются через component scan или явный @Import. Все профили (local, production) хранятся в bootstrap/src/main/resources/.
  • Контроллеры и бизнес-логика в bootstrap/ — ошибка: модуль становится нетонким и его сложно тестировать.
  • @SpringBootApplication — ровно один, только в bootstrap/.
  • Явной сборки требуют четыре категории: классы ядра (в них нет аннотаций), настроенные клиенты, объекты из библиотек и выбор реализации по окружению; всё остальное находится сканированием.
  • Время и генератор идентификаторов приходят снаружи, чтобы ядро оставалось детерминированным: в проде настоящие реализации, в тестах фиксированные.
  • Класс свойств адаптера живёт в модуле адаптера, точка сборки только включает сканирование и даёт значения; проверка обязательных настроек при старте — одна строка, экономящая инцидент.
  • Значения в проде приходят переменными окружения (они сильнее файлов в образе), конфигурацией кластера или хранилищем секретов; в репозитории лежат только ссылки, а ротацию продумывают заранее.
  • Точка сборки — главный тестовый модуль: тест подъёма контекста, сквозные тесты, тесты архитектуры и проверка настроек профиля; правила домена и преобразования тестируют не здесь.
  • Отдельный модуль сборки нужен от трёх модулей и при нескольких входах; при двух модулях им служит «всё остальное», но ответственность точки сборки остаётся отдельной при любой раскладке.

Что почитать дальше