Опирается на правила: R-HEX-MOD-1R-HEX-MOD-5 и R-HEX-MOD-X1R-HEX-MOD-X3 из Hexagonal Style Guide → раздел 2. Структура модулей.

Важно знать

  • Hexagonal — это multi-module gradle, не «один модуль с папками».
  • core/ — единственный модуль без Spring. Чистый Java + Lombok + DDD-библиотеки.
  • Per-purpose: user-api-in-adapter, admin-api-in-adapter, kafka-in-adapter — каждый со своим security/конфигом.
  • Per-system out: persistence/, sber-out-adapter/, kafka-out-adapter/ — отдельный модуль на каждую внешнюю систему.
  • bootstrap/ — composition root: @SpringBootApplication + application.yml + сборка всех адаптеров. Бизнес-логики не содержит.
  • Стрелка зависимостей строго: bootstrap → core ← adapters. core не зависит от адаптеров. Адаптеры не зависят друг от друга.

Архитектура — это не «как назвали пакеты», а что компилятор реально запрещает. Gradle-модуль — единственный механизм в Java, который даёт compile-time изоляцию: класс из модуля A не увидит класс из модуля B, если в build.gradle модуля A нет на B зависимости. Hexagonal без multi-module gradle — это конвенция, которую первый невнимательный коммит сломает. С multi-module — это compile-error. Раскрытие правил R-HEX-MOD-* ниже.

Многомодульный gradle-проект

R-HEX-MOD-1: типичная раскладка сервиса.

<service>/
├── core/                          # Pure Java, без Spring
├── persistence/                   # out-adapter для PG (jOOQ)
├── user-api-in-adapter/           # in-adapter REST (user-facing)
├── admin-api-in-adapter/          # in-adapter REST (admin)
├── kafka-in-adapter/              # in-adapter Kafka consumers (если есть)
├── kafka-out-adapter/             # out-adapter Kafka producers (если есть)
├── sber-out-adapter/              # out-adapter для Sber (или другая внешняя система)
├── sms-out-adapter/               # out-adapter для SMS-провайдера
├── scheduler-out-adapter/         # out-adapter для @Scheduled tasks
└── bootstrap/                     # composition root: Spring Boot Application + конфиги

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"
)

Минимальный набор для Уровня 3 — core/, persistence/, один *-in-adapter, один *-out-adapter (если выходим за PG), bootstrap/. Дальше — добавляем модули по мере роста.

core/ — единственный без Spring

R-HEX-MOD-2: core/ зависит только от JDK, Lombok, DDD-библиотек, jakarta.validation API.

// 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-boot, jooq, jackson, okhttp, kafka-clients...
}

Что это даёт:

  • Compile-time guarantee. Невозможно случайно положить import org.springframework.* в core/ — gradle на компиляции скажет «класс не найден». Это и есть та граница, которую руками не удержать.
  • Быстрые unit-тесты. Тесты на агрегатах работают за миллисекунды, без @SpringBootTest, без Testcontainers, без warmup. Можно запускать тысячами на каждый коммит.
  • Переиспользуемость. Тот же core/ можно завернуть в Lambda-handler, CLI-утилиту, batch-task — без переписывания. На практике мало кто переиспользует, но гарантия чистоты домена ценна сама по себе.

См. также Core слой — внутреннее устройство core/.

Per-system out-адаптеры

R-HEX-MOD-3: на каждую внешнюю систему — отдельный gradle-модуль.

persistence/                # PG через jOOQ
sber-out-adapter/           # Sber-API через RestClient
ok-out-adapter/             # OdnaKassa-API
sms-out-adapter/            # SMS-провайдер
kafka-out-adapter/          # Kafka producers (если есть direct send)
s3-out-adapter/             # S3 / object storage
scheduler-out-adapter/      # @Scheduled — это тоже out, дёргает port-методы

Зачем разделять:

  • Изоляция dependency. core/ не зависит от Sber-SDK. persistence/ не знает Kafka. Если завтра меняем провайдера SMS — правка в одном sms-out-adapter/, остальные модули не пересобираются.
  • Версионирование. Sber-SDK обновился до v3 — обновили один модуль, тестим, релизим. Не «обновили SDK во всём сервисе и теперь надо проверить весь функционал».
  • Resilience-конфиг per-system. OkHttpClient, Resilience4j (Circuit Breaker, Bulkhead, Retry) настраиваются отдельно для каждой системы. Это правило R-RES-ISO-1 из Resilience Style Guide — per-system isolation. Один общий HttpClient для всего исходящего трафика — антипаттерн.

Per-purpose in-адаптеры

R-HEX-MOD-4: каждый тип входа — свой *-in-adapter.

user-api-in-adapter/        # REST для конечного пользователя
admin-api-in-adapter/       # REST для админ-панели
kafka-in-adapter/           # Kafka consumers как entry-point (если consumer != просто sync-read)
cli-in-adapter/             # CLI / batch — если есть

Зачем разделять:

  • Разный security-профиль. user-api-in-adapter принимает JWT от Keycloak; admin-api-in-adapter — JWT с другим audience и mTLS. SecurityFilterChain в одном модуле = один профиль, без рисков перепутать.
  • Разный OpenAPI. User-API публикуется наружу для клиентских команд; admin-API — внутренний. Два отдельных swagger-файла, две отдельные *Api-интерфейсные генерации.
  • Compile-time изоляция. Контроллер user-API не сможет случайно вызвать admin-внутренний-handler — он его не видит. Это защита от расширения user-API «через чёрный ход».

bootstrap/ — composition root

R-HEX-MOD-5: 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")
    implementation("org.springframework.boot:spring-boot-starter-jooq")
    // ...
}

Содержит:

  • <App>Application.java с @SpringBootApplication.
  • application.yml + профили (application-local.yml, application-prod.yml).
  • @Configuration-классы для wiring beans, которые не покрываются component scan'ом.
  • Dockerfile, Helm chart.

Зависит от всех модулей. Никто не зависит от bootstrap/ — это закрывающий узел.

Никакой бизнес-логики, никаких контроллеров здесь нет. См. Bootstrap / composition root.

Стрелка зависимостей

Это самое важное правило, из которого следует всё остальное:

bootstrap → core ← adapters
  • bootstrap зависит от всех.
  • Каждый adapter зависит только от core (через port-интерфейсы).
  • core не зависит ни от чего инфраструктурного.
  • Адаптеры не зависят друг от друга. Координация — это use-case в core/.

В gradle это выражается явно:

// persistence/build.gradle.kts
dependencies {
    implementation(project(":core"))                    // ← только core
    // НЕТ implementation(project(":sber-out-adapter")) ← запрещено
}

Если что-то нарушает стрелку — gradle упадёт на сборке. Это и есть compile-time гарантия Hexagonal'a, которую не даст никакая «папка с правильным именем».

Что запрещено

R-HEX-MOD-X1: один gradle-модуль для всего сервиса с папками core/, adapter/.

<service>/
└── src/main/java/
    ├── core/...
    └── adapter/...        # ← одна compile-зона

Это «архитектура на бумаге»: гарантий компилятора нет. Кто-то добавит import org.springframework.* в файл из пакета core/, и никто не заметит — IDE подсветит как валидный импорт, тест пройдёт, ревью пропустит. Через полгода core/ уже не чистый.

Multi-module gradle — единственный способ это обеспечить.

R-HEX-MOD-X2: core/ зависит от persistence/ или любого другого адаптера.

// core/build.gradle.kts
dependencies {
    implementation(project(":persistence"))    // ← НЕТ, стрелка вывернута
}

Это разрушает всю модель. core/ теперь тянет JOOQ, через JOOQ — HikariCP, через HikariCP — JDBC-драйвер. Чистого domain больше нет.

Стрелка всегда: bootstrap → core ← adapters. Если кажется, что core/ нужен persistence/ — это сигнал, что в core лежит то, чему там не место (например, generated POJO).

R-HEX-MOD-X3: все REST в одном *-in-adapter — user + admin endpoints вместе.

Compile-time изоляция security-моделей теряется. Один и тот же SecurityFilterChain обслуживает оба контракта, и любая ошибка в @PreAuthorize распространяется через оба. На admin-API случайно может попасть user-токен с правами админа — и проверки не покажут это до прода.

Два отдельных *-in-adapter, два отдельных @EnableWebSecurity-конфига. Дороже на 200 строк boilerplate, но дешевле, чем security-incident.

Куда дальше