Через год в ядре сервиса обнаруживается @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/. Остальные добавляются по мере роста.
Реальная раскладка модулей: слева три входа, справа пять выходов, в центре ядро, а десятый модуль, 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, 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/собирает всё приложение.