Hexagonal Architecture — это не просто договорённость называть папки core/ и adapter/. Это про compile-time границы: компилятор должен физически запрещать неправильные зависимости. Иначе архитектура существует только в презентациях, а в коде через год от неё не остаётся ничего.
В Java это достигается многомодульным Gradle-проектом. Каждый модуль — отдельная compile-зона. Класс из модуля A не видит класс из модуля B, если в build.gradle модуля A нет явной зависимости от B. Попытаться обойти это нельзя — будет ошибка компиляции.
Зачем несколько модулей, а не папки
Представьте, что весь сервис — один модуль с пакетами core/ и adapter/:
<service>/
└── src/main/java/
├── core/...
└── adapter/...
Формально выглядит как Hexagonal. Но это одна compile-зона. Разработчик добавляет import org.springframework.* в файл из пакета core/ — и IDE не возражает, тест проходит, на ревью это можно не заметить. Через полгода core/ уже не чистый: там Spring, там JPA-аннотации, там всё что угодно.
Многомодульный Gradle — единственный способ сделать границу настоящей:
<service>/
├── core/ # чистый Java, без Spring
├── persistence/ # работа с базой данных
├── user-api-in-adapter/ # REST для пользователей
├── admin-api-in-adapter/ # REST для администраторов
├── kafka-in-adapter/ # Kafka consumers
├── kafka-out-adapter/ # Kafka producers
├── sber-out-adapter/ # интеграция со Сбером
├── sms-out-adapter/ # SMS-провайдер
├── scheduler-out-adapter/ # планировщик задач
└── bootstrap/ # точка сборки приложения
Все модули перечисляются в settings.gradle.kts:
include(
":core",
":persistence",
":user-api-in-adapter",
":admin-api-in-adapter",
":kafka-in-adapter",
":kafka-out-adapter",
":sber-out-adapter",
":sms-out-adapter",
":scheduler-out-adapter",
":bootstrap"
)
Минимальный стартовый набор: core/, persistence/, один *-in-adapter, bootstrap/. Остальные добавляются по мере роста.
core/ — модуль без Spring
core/ — это сердце приложения. Здесь живут бизнес-правила, агрегаты, use cases, интерфейсы портов. И именно здесь Spring не нужен.
// core/build.gradle.kts
dependencies {
implementation("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
implementation("ru.vikulinva:ddd-building-blocks:1.x")
implementation("ru.vikulinva:usecase-pattern:1.x")
implementation("ru.vikulinva:hexagonal-architecture:1.x")
implementation("jakarta.validation:jakarta.validation-api")
// Spring, jOOQ, Jackson, OkHttp, Kafka — этого здесь нет
}
Что это даёт на практике:
- Попытка написать
import org.springframework.*вcore/— ошибка компиляции. Граница не «договорная», она физическая. - Тесты на агрегатах и use cases запускаются за миллисекунды: не нужен
@SpringBootTest, не нужен Testcontainers, не нужен прогрев. Можно запускать тысячами. - Этот же
core/можно подключить к Lambda, CLI-утилите или пакетной задаче — ничего не переписывая.
Подробнее о том, что именно лежит внутри core/, — в статье Core слой.
Out-адаптеры: один модуль на одну систему
Каждая внешняя система — отдельный Gradle-модуль:
persistence/ # PostgreSQL через jOOQ
sber-out-adapter/ # Sber API через RestClient
sms-out-adapter/ # SMS-провайдер
kafka-out-adapter/ # отправка в Kafka
s3-out-adapter/ # объектное хранилище
scheduler-out-adapter/ # @Scheduled-задачи
Зачем такая детализация:
Изоляция зависимостей. core/ не знает о Sber SDK. persistence/ не знает о Kafka. Когда меняется SMS-провайдер — правка в одном sms-out-adapter/, остальные модули не пересобираются.
Независимые обновления. Sber SDK обновился до новой версии — обновляете один модуль, тестируете его, выкатываете. Не «обновили библиотеку во всём сервисе и теперь надо проверить всё».
Настройка под каждую систему. HTTP-клиент, таймауты, повторные попытки, автоматические выключатели — каждый out-адаптер настраивает их под свою систему. Один общий HTTP-клиент для всего исходящего трафика — частая ошибка.
Каждый out-адаптер зависит только от core/, где лежат интерфейсы портов, которые он реализует:
// persistence/build.gradle.kts
dependencies {
implementation(project(":core")) // только core
implementation("org.jooq:jooq")
// НЕТ зависимостей от других адаптеров
}
In-адаптеры: один модуль на один тип входа
Входы в приложение тоже разделяются:
user-api-in-adapter/ # REST для конечных пользователей
admin-api-in-adapter/ # REST для администраторов
kafka-in-adapter/ # Kafka consumers как точка входа
cli-in-adapter/ # командная строка или batch (если есть)
Главная причина — разная модель безопасности. user-api-in-adapter принимает JWT от Keycloak с одним набором ролей. admin-api-in-adapter — JWT с другим audience и, возможно, mTLS. Два отдельных модуля — два отдельных SecurityFilterChain, которые физически не могут перепутаться.
Дополнительный эффект: у каждого in-адаптера свой OpenAPI-файл. User API публикуется для клиентских команд, admin API — внутренний. Не одна большая спецификация со всем подряд.
Если пользовательские и административные endpoint'ы живут в одном *-in-adapter — один SecurityFilterChain обслуживает оба контракта. Любая ошибка в аннотации доступа затрагивает сразу оба. Ошибки такого рода обычно обнаруживаются не сразу.
bootstrap/ — точка сборки
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(":scheduler-out-adapter"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
}
Что здесь живёт:
<App>Application.javaс@SpringBootApplication.application.ymlи профильные конфиги (application-local.yml,application-prod.yml).@Configuration-классы для соединения бинов, которые не подхватываются component scan'ом.Dockerfile, Helm chart.
Что здесь не живёт: бизнес-логика, контроллеры, репозитории. bootstrap/ — это клей, не функциональность.
Подробнее — в статье Bootstrap / composition root.
Направление зависимостей
Главное правило, из которого следует всё остальное:
bootstrap → core ← adapters
bootstrapзависит от всех модулей.- Каждый адаптер зависит только от
core— через port-интерфейсы. coreне зависит ни от одного адаптера.- Адаптеры не зависят друг от друга. Координация между ними — это use case в
core/.
Если core/ оказывается нужна зависимость от persistence/ — это сигнал, что в core/ лежит что-то лишнее (например, сгенерированные JOOQ-классы, которым там не место).
Gradle проверяет это при сборке. Нарушить направление зависимостей — получить ошибку компиляции. Это и есть compile-time гарантия, которую не даст никакой style guide.
Коротко
- Многомодульный Gradle — не дополнительная сложность, а механизм, который делает архитектурные границы физическими.
core/без Spring: любая попытка добавитьimport org.springframework.*— ошибка компиляции. Тесты быстрые, домен чистый.- Out-адаптеры разделяются по внешним системам: один модуль — одна система, независимые зависимости и настройки.
- In-адаптеры разделяются по типу входа: разные модели безопасности, разные OpenAPI-спецификации, compile-time изоляция.
bootstrap/зависит от всех, никто не зависит от него. Только конфигурация, никакой логики.- Стрелка зависимостей всегда:
bootstrap → core ← adapters.
Что почитать дальше
- Core слой — что именно лежит внутри
core/и почему. - Ports — интерфейсы на границе между
coreи адаптерами. - Bootstrap / composition root — как
bootstrap/собирает всё приложение.