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

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/ собирает всё приложение.