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

Через год в ядре сервиса обнаруживается @Transactional из Spring и импорт jOOQ — и никто не заметил, когда они туда попали. Договорённость называть папки core/ и adapter/ от этого не защищает. Защищает граница, которую проверяет компилятор: он должен физически запрещать неправильные зависимости, иначе архитектура существует только в презентациях.

В Java это достигается многомодульным Gradle-проектом. Каждый модуль — отдельная зон компиляцииа. Класс из модуля A не видит класс из модуля B, если в build.gradle модуля A нет явной зависимости от B. Попытаться обойти это нельзя — будет ошибка компиляции.

Обязательно

Зачем несколько модулей, а не папки

Представьте, что весь сервис — один модуль с пакетами core/ и adapter/:

<service>/
└── src/main/java/
    ├── core/...
    └── adapter/...

Формально выглядит как Hexagonal. Но это одна зона компиляции. Разработчик добавляет 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", ":bootstrap",
    // ... и по строке на каждый адаптер из дерева выше
)

Минимальный стартовый набор: core/, persistence/, один *-in-adapter, bootstrap/. Остальные добавляются по мере роста.

core порт user-api-in-adapter клиенты порт admin-api-in-adapter админы порт kafka-in-adapter события порт persistence PostgreSQL порт sber-out-adapter Sber API порт sms-out-adapter SMS-шлюз порт kafka-out-adapter темы порт scheduler-out-adapter расписание снаружи адаптеры, внутри домен

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

А по какому признаку дробить дальше — и когда не надо. Модуль стоит денег (свой файл сборки, своя строка в списке модулей, свой путь, отдельная сборка), поэтому новый модуль заводят под названную причину:

Причины завести отдельный модуль:

  • Вторая внешняя система. У неё своя библиотека, свои настройки сроков и повторов, свои метрики. Это главная и самая частая причина.
  • Конфликт зависимостей. Двум адаптерам нужны разные версии одной библиотеки — модули единственный способ это развести.
  • Вторая модель безопасности. Публичный и административный вход с разными правилами проверки: отдельные модули не дают случайно позвать административный сценарий из публичного контроллера.
  • Другой контракт наружу. Своё описание API, своя схема сообщений, свой генерируемый клиент.
  • Другая команда-владелец. Границу владения удобно совмещать с границей модуля.

Когда дробить НЕ надо:

  • Два входа с одинаковой моделью безопасности и одним описанием API — это один модуль с двумя контроллерами. Разделение по принципу «пользовательские ручки и служебные» без разницы в правилах доступа даёт два модуля-близнеца.
  • Разные таблицы одной базы. Хранилище — один модуль, независимо от числа таблиц и контекстов. Соблазн сделать orders-persistence и customers-persistence появляется, а пользы нет: библиотека одна, транзакция одна, настройки одни.
  • Разделение по контекстам предметной области. Контексты живут пакетами внутри ядра (разбор правил между ними); модуль на контекст оправдан только тогда, когда вы всерьёз готовитесь разрезать сервис.
  • Один класс. Модуль ради одного адаптера на десять строк — чистая церемония; положите его в существующий модуль той же природы.

Ориентир по числу. Обычный сервис: 4–7 модулей. Десять и больше бывает у сервисов с многими интеграциями, и тогда обязательны общие настройки сборки (см. ниже) — иначе поддержка файлов сборки становится отдельной работой. Двадцать модулей в сервисе почти всегда означает, что дробили по контекстам или по слоям внутри адаптеров, и это стоит пересмотреть.

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

Настройки сборки: три вещи, которые ломают раскладку

Дерево модулей — это картинка, а работает раскладка за счёт настроек сборки. Три места, где она чаще всего ломается, и все три встречаются на первой неделе.

Плагин приложения — только в модуле сборки

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

Правильно так: плагин объявлен в корне без применения, применяется только в модуле сборки.

// корневой build.gradle.kts
plugins {
    id("org.springframework.boot") version "3.4.1" apply false   // ← apply false
    id("io.spring.dependency-management") version "1.1.7" apply false
    kotlin("jvm") version "2.1.0" apply false
}

subprojects {
    apply(plugin = "java-library")            // все модули — библиотеки
    apply(plugin = "io.spring.dependency-management")   // согласованные версии
}
// bootstrap/build.gradle.kts — единственный модуль, который собирается в приложение
plugins {
    id("org.springframework.boot")
}
// исполняемый архив собирается здесь, и только здесь

И проверка, что всё верно: в каталогах сборки всех модулей, кроме модуля сборки, лежит обычный архив с классами, а в модуле сборки — исполняемый. Если обычного архива нет — плагин применён не туда.

Общие настройки — в отдельном месте, а не копированием

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

Каталог версий — одно место, где записаны версии и наборы библиотек:

# gradle/libs.versions.toml
[versions]
spring-boot = "3.4.1"
jooq = "3.19.16"
assertj = "3.27.0"

[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
jooq = { module = "org.jooq:jooq", version.ref = "jooq" }
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }

[bundles]
testing = ["junit-jupiter", "assertj", "mockito-core"]
// любой модуль
dependencies {
    implementation(libs.jooq)            // версия из каталога
    testImplementation(libs.bundles.testing)
}

Плагины-соглашения — общие настройки, вынесенные в собираемый код сборки: версия языка, настройки тестов, правила компиляции, общий набор тестовых библиотек. Лежат они в отдельном каталоге кода сборки и применяются одной строкой:

// buildSrc/src/main/kotlin/java-conventions.gradle.kts
plugins { `java-library` }

java {
    toolchain { languageVersion = JavaLanguageVersion.of(21) }
}

tasks.test {
    useJUnitPlatform()
    testLogging { events("failed", "skipped") }
}
// core/build.gradle.kts — три строки вместо тридцати
plugins { id("java-conventions") }
dependencies { testImplementation(libs.bundles.testing) }

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

Где живёт генерируемый код

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

Что генерируетсяГдеПочему
Классы доступа к таблицам (по схеме базы)persistence/Это подробности хранения; ядро о них знать не должно
Интерфейсы контроллеров и структуры запросов (по своему описанию API)соответствующий *-in-adapter/Это контракт конкретного входа
Клиент и структуры чужого API (по описанию поставщика)соответствующий *-out-adapter/ или отдельный модуль-генератор, если клиент нужен двум адаптерамЭто чужой формат
Преобразователи (генерируемые библиотекой)там, где преобразуют, — обычно адаптерСледуют за структурами

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

И самая частая ошибка, связанная с этим: генерируемые классы доступа к таблицам, протёкшие в ядро. Происходит незаметно — кто-то возвращает такой класс из порта «чтобы не писать преобразователь», — и после этого ядро зависит от схемы базы. Ловится тестом архитектуры за минуту.

core/ — модуль без Spring

core/ — это сердце приложения. Здесь живут бизнес-правила, агрегаты, use cases, интерфейсы портов. И именно здесь Spring не нужен.

// core/build.gradle.kts
dependencies {
    compileOnly("org.projectlombok:lombok:1.18.38")
    annotationProcessor("org.projectlombok:lombok:1.18.38")
    implementation("ru.vikulinva:ddd-building-blocks:1.0.0")
    implementation("ru.vikulinva:usecase-pattern:1.1.0")
    implementation("ru.vikulinva:hexagonal-architecture:1.0.0")
    implementation("jakarta.validation:jakarta.validation-api:3.1.1")
    // Spring, jOOQ, Jackson, OkHttp, Kafka — этого здесь нет
}

Две детали этого листинга легко упустить.

Lombok стоит как compileOnly, а не implementation. Он нужен только компилятору: аннотации раскрываются в обычный код на этапе сборки, и в собранном приложении Lombok уже не участвует. С implementation его jar уехал бы в дистрибутив и таскался бы за сервисом без всякой пользы.

И версии написаны явно. В модулях с адаптерами их обычно не пишут — там подключён Spring Boot BOM, который сам знает, какую версию чего брать. В core/ Spring нет, а значит нет и BOM: запись без версии Gradle просто не разрешит.

Что это даёт на практике:

  • Попытка написать 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-задачи

Планировщик в этом списке выглядит странно, и это честно. По потоку управления @Scheduled-задача — вход: она сама запускает сценарий, снаружи её никто не зовёт. Но модуль назван out и лежит среди исходящих, потому что ведёт он себя именно так: у него нет внешнего контракта, нет DTO, нет своей проверки прав — только расписание и вызов UseCaseDispatcher. Имя спорное; важно, что во всём проекте оно одно.

Зачем такая детализация:

Изоляция зависимостей. 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")
    // НЕТ зависимостей от других адаптеров
}

implementation, а не api — разница тут существенная. С implementation типы из core/ видит сам адаптер, но не те, кто подключит адаптер к себе. Для этой раскладки так и надо: единственный, кто подключает адаптеры, — bootstrap/, а он и так объявляет core отдельной строкой. Если же скопировать этот шаблон туда, где адаптер подключают из другого модуля, доменные типы в нём внезапно окажутся невидимыми — вот тогда нужен api(project(":core")).

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.

Где живут тесты

Обещание «тесты ядра за миллисекунды» требует раскладки, и она не очевидна: у каждого модуля свои тесты, и не все из них можно написать где угодно.

Что проверяемГде живётЧто нужно для запуска
Правила домена, сценарииcore/src/testничего: обычные объекты и поддельные реализации портов
Тесты архитектурыотдельный модуль или bootstrap/src/testдоступ ко всем модулям в пути сборки
Преобразование и запросы хранилищаpersistence/src/testконтейнер с настоящей базой
Адаптер к внешней системе<система>-out-adapter/src/testлокальный сервер-заглушка
Контроллер: разбор запроса, коды ответа, права*-in-adapter/src/testсрез веб-слоя с заглушкой сценария
Сквозной путь целикомbootstrap/src/testполный контекст приложения плюс контейнеры

Три правила, из которых эта таблица следует:

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

2. Полный контекст приложения поднимается только там, где он есть. Собрать контекст можно лишь в модуле, который видит все части и содержит настройки — то есть в модуле сборки. Попытка поднять его в тестах адаптера означает, что адаптер потянул зависимости всего приложения, и граница нарушена. В адаптерах используют срезы контекста: только веб-слой, только слой доступа к данным.

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

Почему тесты архитектуры оказываются в модуле сборки (и это выглядит странно для «модуля-клея»): только он видит все модули сразу, а правила вида «ядро не зависит от адаптеров» проверяются именно по полному набору классов. Альтернатива — отдельный модуль architecture-tests, который зависит от всех и больше ни от чего; он чище по смыслу и стоит одной строки в списке модулей. Разбор самих правил — в статье про тесты архитектуры.

И практическое следствие для скорости. Прогон тестов ядра занимает секунды и не требует ни контейнеров, ни контекста — значит, его запускают на каждое изменение. Тесты с контейнерами и полным контекстом идут дольше и живут на другой ступени конвейера. Если тесты ядра почему-то требуют контейнера, значит, в ядро протекла инфраструктура — и это не проблема тестов, а находка про раскладку.

Направление зависимостей

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

core bootstrap adapters

Стрелка всегда смотрит внутрь: адаптеры знают про core, core про адаптеры — нет. Держит это не договорённость, а Gradle: ссылка из core на адаптер просто не соберётся.

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

Если core/ оказывается нужна зависимость от persistence/ — это сигнал, что в core/ лежит что-то лишнее (например, сгенерированные JOOQ-классы, которым там не место).

Держит это Gradle, причём двумя разными способами — их полезно различать. Если кто-то просто напишет в классе из core/ импорт класса из persistence/, будет ошибка компиляции: модуль core этой зависимости не объявлял, и компилятор такого класса не видит. А если попробовать «исправить» это, дописав implementation(project(":persistence")) в core/build.gradle.kts, — сборка упадёт ещё раньше, до всякой компиляции, на циклической зависимости: persistence уже зависит от core. Это и есть гарантия на уровне сборки, которую не даст никакой свод правил.

Дополнительно: при первом чтении можно пропустить

Глубже: общий код между модулями: почему common превращается в свалкурасширенное

Раскладка из десяти модулей выше вызывает первый же вопрос у того, кто её повторит: куда класть общие DTO, утилиты и константы. Обычный ответ, модуль common, через полгода содержит всё подряд, зависит от Spring и Jackson и тянется во все модули, включая core; граница, ради которой делили, растворяется в нём.

Правило одно: общего модуля «для всего» нет, есть маленькие модули с именем по содержимому и без зависимостей на фреймворки. money с типом суммы и валюты, ids с типизированными идентификаторами, errors с базовой иерархией исключений port'ов: у каждого одна причина существовать, и core может зависеть от них, потому что они такие же чистые, как он.

Что общим не является. DTO контракта принадлежат адаптеру, который этот контракт держит: сгенерированные классы OpenAPI живут в adapter.rest, классы событий Kafka в adapter.kafka, и другому адаптеру они не нужны; если нужны, это один и тот же контракт, и его выносят в модуль контракта с именем этого контракта, а не в common. Константы принадлежат своему контексту: срок жизни резерва это правило домена и живёт в core, имя топика это деталь транспорта и живёт в адаптере. Утилиты для строк и дат это либо стандартная библиотека, либо признак, что не хватает типа: DateUtils.startOfDay это метод у объекта-значения BusinessDay.

Два приёма против роста. Дублирование дешевле связанности: маппер на десять строк, повторённый в двух адаптерах, лучше общего модуля, от которого зависят оба, потому что у них разные причины меняться. И правило ArchUnit: ни один модуль с именем common, shared, util не существует, а зависимости от маленьких общих модулей направлены только к ним, не от них. Модуль, который импортируют все, и который импортирует хоть что-то, это и есть свалка, и тест ловит её на первом коммите.

Коротко

  • Многомодульный Gradle — не дополнительная сложность, а механизм, который делает архитектурные границы физическими. core/ без Spring: любая попытка добавить import org.springframework.* — ошибка компиляции. Тесты быстрые, домен чистый.
  • Out-адаптеры разделяются по внешним системам: один модуль — одна система, независимые зависимости и настройки. In-адаптеры разделяются по типу входа: разные модели безопасности, разные OpenAPI-спецификации, изоляция на уровне компиляции.
  • bootstrap/ зависит от всех, никто не зависит от него. Только конфигурация, никакой логики. Стрелка зависимостей всегда смотрит внутрь: adapters → core, и core не знает ни одного адаптера. bootstrap/ стоит в стороне от этого правила — он зависит от всех и от core, и от адаптеров, потому что его работа — собрать их вместе.
  • Модуля common нет: общее это маленькие чистые модули по содержимому (money, ids, errors); DTO принадлежат своему адаптеру, константы своему контексту, дублирование дешевле связанности, ArchUnit запрещает свалку.
  • Плагин сборки приложения объявляют в корне с apply false и применяют только в модуле сборки; иначе у каждого модуля включается исполняемый архив и его нельзя подключить как библиотеку.
  • Каталог версий вводят с трёх модулей, плагины-соглашения — с пяти; без них обновление библиотеки превращается в десять правок, одна из которых забывается.
  • Генерируемый код живёт в своём модуле (классы таблиц — в хранилище, контракты входа — в адаптере входа, чужой клиент — в адаптере наружу), результат не коммитят, источник фиксируют по версии.
  • Тесты живут в модуле проверяемого кода; полный контекст поднимается только в модуле сборки, в адаптерах — срезы; поддельные реализации портов лежат в тестовых исходниках ядра.
  • Модуль заводят под названную причину (вторая внешняя система, конфликт зависимостей, вторая модель безопасности, другой контракт, другая команда); обычный сервис — 4–7 модулей, и модули можно сливать обратно.

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

  • Core слой — что именно лежит внутри core/ и почему.
  • Ports — интерфейсы на границе между core и адаптерами.
  • Bootstrap / composition root — как bootstrap/ собирает всё приложение.