В гексагональной архитектуре есть несколько жёстких правил: ядро (core/) не должно знать ни о Spring, ни о базе данных; порты — только интерфейсы; входные адаптеры не зависят от выходных. Эти правила легко нарушить случайно — достаточно одного импорта в не том месте. И даже внимательный ревьюер в большом PR может его пропустить.
Решение — сделать правила исполнимыми автоматически. ArchUnit — это библиотека, которая позволяет писать архитектурные правила как обычные JUnit-тесты. Нарушение правила = упавший тест = заблокированный PR.
Почему code review не достаточно
Кажется, что достаточно договориться в команде и внимательно смотреть на PR. На практике это не работает стабильно:
- В большом PR с 30 файлами один лишний
import org.springframework.*в ядре легко теряется. - Бывают цепочки зависимостей: класс A зависит от B, B — от C, C — от Spring. В конкретном коммите это выглядит как «добавили B в A», и ревью пропускает.
- Новый разработчик не знает всех правил — code review передаёт знания медленно. ArchUnit даёт мгновенную обратную связь прямо в CI: «твой PR нарушает правило».
- Code review допускает договорённости «сейчас можно, потом исправим». ArchUnit либо проходит, либо нет — без исключений по договорённости.
ArchUnit не заменяет code review, а дополняет его. Ревью смотрит на дизайн, читаемость, бизнес-логику. ArchUnit — на архитектурные инварианты. Это разные плоскости.
Что подключить
ArchUnit — обычная тестовая зависимость. Для JUnit 5 берут артефакт archunit-junit5, он тянет за собой и ядро библиотеки, и её движок для JUnit:
// bootstrap/build.gradle.kts
dependencies {
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}
Где размещать тесты
Типичное место — bootstrap/src/test/java/:
bootstrap/
└── src/test/java/<pkg>/architecture/
├── HexagonalArchitectureTest.java # главный файл с правилами
├── CoreLayerTest.java
├── PortTest.java
├── AdapterTest.java
└── ControllerTest.java
Почему именно в bootstrap/:
bootstrap/зависит от всех остальных модулей, значит в его test-classpath есть классы изcore/,persistence/, всех адаптеров — ArchUnit может их проверять.- В
bootstrap/уже есть JUnit и тестовая инфраструктура, добавление ArchUnit ничего не ломает.
Альтернатива — отдельный gradle-модуль architecture-tests/, который зависит от всех остальных. Это чище, но добавляет ещё один модуль. На практике в bootstrap/ приживается проще.
Что проверять
Вот полный набор правил для гексагонального сервиса:
@AnalyzeClasses(packages = "ru.example.order")
public class HexagonalArchitectureTest {
@ArchTest
static final ArchRule coreShouldNotDependOnSpring =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.springframework..");
@ArchTest
static final ArchRule coreShouldNotDependOnJooq =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.jooq..");
@ArchTest
static final ArchRule coreShouldNotDependOnJackson =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("com.fasterxml.jackson..");
@ArchTest
static final ArchRule coreShouldNotDependOnHttpClients =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAnyPackage(
"okhttp3..", "retrofit2..", "feign..", "org.springframework.web.client..");
@ArchTest
static final ArchRule coreShouldNotDependOnKafka =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.apache.kafka..");
@ArchTest
static final ArchRule portsInCoreShouldBeInterfaces =
classes().that().resideInAPackage("..core..port.out..")
.should().beInterfaces();
@ArchTest
static final ArchRule layersAreRespected =
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Core").definedBy("..core..")
.layer("InAdapters").definedBy("..adapter.in..")
.layer("OutAdapters").definedBy("..adapter.out..")
.layer("Bootstrap").definedBy("..bootstrap..")
.whereLayer("Core").mayOnlyBeAccessedByLayers("InAdapters", "OutAdapters", "Bootstrap")
.whereLayer("InAdapters").mayOnlyBeAccessedByLayers("Bootstrap")
.whereLayer("OutAdapters").mayOnlyBeAccessedByLayers("Bootstrap")
.whereLayer("Bootstrap").mayNotBeAccessedByAnyLayer();
@ArchTest
static final ArchRule outAdaptersShouldImplementPorts =
classes().that().resideInAPackage("..adapter.out..")
.and().areAnnotatedWith(Adapter.class)
.should().implement(JavaClass.Predicates.resideInAPackage("..core..port.out.."));
@ArchTest
static final ArchRule controllersShouldImplementGeneratedApi =
classes().that().areAnnotatedWith(RestController.class)
.should().beAssignableTo(JavaClass.Predicates.resideInAPackage("..api.generated.."));
}
Что здесь проверяется и зачем:
core не зависит от Spring, JOOQ, Jackson, HTTP-клиентов, Kafka — ядро содержит только бизнес-логику на чистой Java. Spring-аннотации, SQL-запросы, HTTP-вызовы — это детали инфраструктуры, которые живут в адаптерах.
Порты в core/ — только интерфейсы — порт — это контракт между ядром и внешним миром. Реализация контракта всегда снаружи ядра, в адаптере. Если порт — класс, граница размыта. Правило строгое: в пакете port/out/ не должно быть вообще ничего, кроме интерфейсов. Исключения портов — PaymentPortException и его подклассы — поэтому живут не здесь, а в core/<bc>/exception/, про это подробнее в статье Ports.
Слои не лезут друг к другу — вместо того чтобы перечислять пакеты адаптеров руками, лучше описать слои целиком. Список руками выглядит проще, но у него скверное свойство: добавили новый адаптер — правило про него ничего не знает и продолжает зеленеть. Гейт вроде есть, а на деле его нет. layeredArchitecture() закрывает всё, что попадает в слой, включая то, чего ещё не написали. Цена — договорённость о пакетах: входные адаптеры под adapter.in, выходные под adapter.out.
Выходной адаптер реализует порт — здесь важно не перестараться с отбором классов. Если написать «каждый @Component в выходном адаптере реализует порт», правило тут же упадёт на SberMapper, на @Configuration-классах и на обёртках HTTP-клиента: они тоже бины, но портов не реализуют и не должны. Поэтому отбираем по @Adapter из библиотеки hexagonal-architecture — этой аннотацией помечают именно адаптеры, а не всё подряд.
Контроллер реализует сгенерированный API — контроллер должен реализовывать интерфейс, сгенерированный из OpenAPI-спецификации. Это не даёт расходиться коду и контракту.
Ещё пара слов про сам листинг. RestController и Adapter в правилах — это настоящие классы аннотаций, их надо импортировать: первый из org.springframework.web.bind.annotation, второй из библиотеки hexagonal-architecture-annotations. То есть тестовый classpath модуля bootstrap/ обязан видеть Spring. Противоречия с правилом «core не знает Spring» тут нет: запрет касается src/main ядра, а тесты живут в другом модуле и в другом наборе исходников.
И одна ловушка, на которую натыкаются сразу: если that() не нашёл ни одного класса, ArchUnit по умолчанию считает это ошибкой и роняет правило. Опечатались в имени пакета — тест покраснеет и скажет «no classes». Когда пустой результат законен (модуля ещё нет, адаптер не написан), правилу дописывают .allowEmptyShould(true).
Как добавить в CI
Тест запускается как обычный JUnit, поэтому достаточно включить его в стандартный прогон:
# .github/workflows/ci.yml
jobs:
arch-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin # обязательный параметр, без него шаг падает
java-version: 21
- run: ./gradlew :bootstrap:test --tests "*HexagonalArchitectureTest*"
Важный шаг — сделать этот job обязательным в настройках защиты ветки (branch protection rules). Без этого разработчик может открыть PR, не запустив тест локально, и если ревьюер тоже пропустит — нарушение попадёт в main.
Когда тест обязателен: нельзя смерджить PR без зелёного arch-test. Это делает ошибку дешёвой — поймали в CI, а не через месяц в продакшне.
Главное правило: ядро не зависит от адаптеров
В наборе выше перечислены запреты на библиотеки — и пропущен самый важный инвариант: ядро не зависит ни на один модуль проекта. Разворот стрелки и есть суть архитектуры; запреты на библиотеки — только следствие.
@ArchTest
static final ArchRule coreDependsOnNothingOfOurs =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAnyPackage(
"..persistence..", "..adapter..", "..bootstrap..");
@ArchTest
static final ArchRule adaptersDoNotSeeEachOther =
noClasses().that().resideInAPackage("..adapter.persistence..")
.should().dependOnClassesThat().resideInAnyPackage("..adapter.rest..", "..adapter.sber..");
@ArchTest
static final ArchRule onlyBootstrapKnowsEverything =
classes().that().resideInAPackage("..bootstrap..")
.should().onlyDependOnClassesThat().resideInAnyPackage(
"..core..", "..adapter..", "..persistence..", "java..", "org.springframework..");
Почему это правило важнее запретов на библиотеки: библиотека в ядре видна глазами на ревью («откуда тут импорт фреймворка?»), а зависимость на свой же модуль выглядит невинно — «я просто переиспользовал запись». Именно так ядро и склеивается с хранилищем.
Оговорка про физические модули. Если раскладка на модулях сборки, это правило уже обеспечено сборкой: у ядра просто нет этих зависимостей. Тогда правило в тесте — страховка на случай, если кто-то добавит зависимость в файл сборки, и оно дешёвое. Если раскладка пакетами в одном модуле — это единственная защита, и без неё пакеты держатся на честном слове.
Готовые конструкции: слои и кольца
Половину листинга выше можно заменить одним объявлением: в ArchUnit есть готовые описания слоистой и луковой архитектуры.
@ArchTest
static final ArchRule layers = layeredArchitecture().consideringAllDependencies()
.layer("Core").definedBy("..core..")
.layer("Persistence").definedBy("..persistence..")
.layer("Web").definedBy("..adapter.rest..")
.layer("Bootstrap").definedBy("..bootstrap..")
.whereLayer("Core").mayOnlyBeAccessedByLayers("Persistence", "Web", "Bootstrap")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Bootstrap")
.whereLayer("Web").mayOnlyBeAccessedByLayers("Bootstrap")
.whereLayer("Bootstrap").mayNotBeAccessed();
@ArchTest
static final ArchRule onion = onionArchitecture()
.domainModels("..core.domain..")
.domainServices("..core.application..")
.applicationServices("..core.usecase..")
.adapter("persistence", "..persistence..")
.adapter("rest", "..adapter.rest..")
.adapter("sber", "..adapter.sber..");
Чем это лучше набора отдельных запретов. Во-первых, правило читается как описание архитектуры, а не как список исключений. Во-вторых — и это главное — оно не рассыпается при добавлении модуля: описание луковой архитектуры само знает, что адаптеры не видят друг друга, поэтому новый адаптер добавляется одной строкой, а не тремя новыми запретами. При наборе из ручных правил про новый адаптер легко забыть — и он останется без проверок.
Чем хуже: сообщение об ошибке менее конкретное («нарушение слоёв» вместо «ядро зависит от хранилища»), и настраивается оно грубее. Поэтому на практике их совмещают: готовая конструкция описывает основную структуру, а отдельными правилами добавляют то, чего в ней нет (запрет конкретных библиотек в ядре, правила именования, требования к аннотациям).
И деталь, которая ловит начинающих: consideringAllDependencies() нужен почти всегда. Без него проверяются не все виды зависимостей, и часть нарушений (например, через сигнатуры методов) проходит незамеченной.
Единая точка скана
@AnalyzeClasses объявлена как @Target(TYPE) — поставить её можно только над классом, над отдельным правилом не получится при всём желании:
@AnalyzeClasses(packages = "ru.example.order")
public class HexagonalArchitectureTest {
// все правила внутри разделяют один обход classpath
}
Чтения классов с диска бояться не стоит: движок ArchUnit для JUnit 5 кеширует разобранный classpath сам. Параметр cacheMode по умолчанию равен FOREVER — это значит, что результат обхода переиспользуется не только внутри одного класса, но и между всеми тестовыми классами с такой же конфигурацией @AnalyzeClasses. Платим за сканирование один раз за прогон, а не на каждый тест.
Настраивать тут почти нечего, но знать полезно: именно поэтому строку @AnalyzeClasses(packages = ...) держат одинаковой во всех тестовых классах. Стоит написать в одном месте "ru.example.order", а в соседнем "ru.example" — и получите два независимых обхода вместо одного.
Как выглядит падение
Инструмент, обещающий мгновенную обратную связь, стоит показать в момент, когда он срабатывает, — иначе непонятно, что делать с красным тестом.
Кто-то добавил в доменный класс работу с базой. Сборка падает, и в отчёте примерно это:
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] -
Rule 'no classes that reside in a package '..core..' should depend on classes that
reside in a package 'org.jooq..'' was violated (3 times):
Field <ru.example.order.core.domain.orders.Order.dsl> has type <org.jooq.DSLContext>
in (Order.java:0)
Method <ru.example.order.core.domain.orders.Order.reload()> calls method
<org.jooq.DSLContext.selectFrom(org.jooq.Table)> in (Order.java:84)
Constructor <ru.example.order.core.domain.orders.Order.<init>(org.jooq.DSLContext)>
has parameter of type <org.jooq.DSLContext> in (Order.java:31)
Как это читать, по строкам:
- Первая строка — какое правило нарушено, дословно тем текстом, который вы написали в объявлении. Отсюда практический вывод: имя переменной правила и его текст — это сообщение об ошибке, и писать их стоит так, чтобы человек понял без чтения кода теста.
- «(3 times)» — число нарушений, а не число классов. Одно неудачное решение обычно даёт несколько записей: поле, вызов метода, параметр конструктора.
- Каждая запись называет что именно зависит (поле, метод, конструктор, параметр), от чего и где — с файлом и строкой. По этой строке переход в редакторе делается щелчком.
Что делать с таким падением. Три варианта, и выбирают осознанно:
- Починить код — обычный случай: убрать зависимость, вернуть работу в адаптер, передать данные параметром.
- Уточнить правило, если нарушение законное. Пример: ядру действительно нужна одна аннотация управления транзакциями — тогда правило получает явное исключение с комментарием почему:
@ArchTest
static final ArchRule coreShouldNotDependOnSpring =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.springframework..")
.because("ядро не знает о фреймворке; исключение — @Transactional, см. ADR-021")
.ignoreDependency(alwaysTrue(),
DescribedPredicate.describe("Transactional",
clazz -> clazz.getName().equals("org.springframework.transaction.annotation.Transactional")));
- Зафиксировать как известное нарушение — если правило вводится на существующем коде (см. следующий раздел).
Полезная привычка: в каждом правиле писать because("..."). Этот текст попадает в сообщение об ошибке, и человек, увидевший красный тест впервые, понимает не только «нельзя», но и «почему нельзя» — а это разница между «уберу правило» и «поправлю код».
Внедрение на существующем проекте
Правило, введённое на проекте, где нарушений сотни, краснеет сразу — и его выключают через день. Это главная причина, по которой тесты архитектуры «не приживаются». Штатный ответ есть, и он в самом ArchUnit.
Фиксация текущих нарушений. Правило оборачивается так, что существующие нарушения записываются в файл и считаются известными, а любое новое роняет сборку:
@ArchTest
static final ArchRule coreShouldNotDependOnSpring =
FreezingArchRule.freeze(
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.springframework.."));
При первом запуске создаётся хранилище нарушений (по умолчанию каталог с файлами, который коммитят в репозиторий). Дальше:
- Новое нарушение — тест красный, потому что его нет в списке известных.
- Исправленное нарушение — автоматически удаляется из списка, и вернуть его обратно уже нельзя. Это самое приятное свойство: долг только уменьшается.
- Список нарушений в репозитории — видимая мера долга: файл на двести строк говорит о состоянии кода больше, чем любой отчёт.
Порядок внедрения на живом проекте:
- Написать правила как надо — не под текущее состояние, а под целевое.
- Заморозить все, сборка зелёная с первого дня.
- Посмотреть на размер списка — это оценка работы, и её можно показать команде.
- Разгребать по ходу дела: тронули класс — заодно убрали его нарушение. Специальные задачи «почистить архитектуру» обычно не нужны.
- Проверять, что список сокращается — например, раз в квартал сравнивать число строк. Список, который не уменьшается год, означает, что правило не разделяют, и это повод обсудить его, а не молча терпеть.
Чего не делать: не замораживать правила, которые и так зелёные (лишняя сложность), и не удалять записи из списка вручную «чтобы починить тест» — это ровно то, от чего механизм защищает.
Чего гейт не ловит
Ограничения инструмента надо назвать прямо, иначе он воспринимается как полная гарантия — и тогда его зелёный статус становится опаснее его отсутствия.
Он смотрит на скомпилированный код, а значит, не видит зависимостей, которых в нём нет:
- Обращение по имени класса строкой.
Class.forName("ru.example.order.persistence.JooqOrderRepository")в ядре — зависимость, которой нет в байт-коде. Тест зелёный, граница нарушена. - Через контейнер зависимостей. Ядро, получающее реализацию через поиск в контексте по типу, формально не зависит от неё — и это, кстати, нормально, но тем же способом можно протащить и то, что не следует.
- Через настройки и условную сборку. Зависимость, появляющаяся только при определённом значении свойства, в байт-коде выглядит так же, как любая другая, а вот правило, написанное с учётом «этого класса в проде не бывает», может ошибаться.
- Через шаблоны кода и генерацию. Сгенерированный код проверяется, если он попал в путь сканирования, — и часто его как раз исключают, чтобы правила не краснели. Всё, что в исключениях, не проверяется вовсе.
- Через работу с ресурсами и сериализацию. Ядро, читающее файл настроек или разбирающее внешний формат «руками», формально не зависит от инфраструктуры, а фактически знает о ней.
И чего он не видит принципиально: качества границ. Правило «ядро не зависит от адаптеров» ничего не говорит о том, что порт толстый, преобразователь ничего не переводит, а модель анемична. Тест архитектуры проверяет направление зависимостей, а не смысл — и это его честная область.
Отсюда практический вывод: зелёный тест архитектуры означает «стрелки направлены верно», а не «архитектура хорошая». Остальное остаётся за ревью, и об этом стоит сказать команде вслух, чтобы гейт не стал заменой мышлению.
Альтернативы и что с чем сочетать
Тест архитектуры — не единственный способ поставить границу, и полезно понимать, чем он отличается от остальных.
| Способ | Когда срабатывает | Что проверяет | Цена |
|---|---|---|---|
| Модули сборки | при компиляции | зависимости между модулями | раскладка проекта, файлы сборки |
| Тест архитектуры | при прогоне тестов | зависимости, имена, аннотации, слои внутри модуля | тестовый модуль, правила |
| Модули платформы | при компиляции и запуске | что модуль отдаёт наружу | высокая: перестройка проекта |
| Проверка совместимости артефакта | при сборке библиотеки | не сломан ли публичный контракт | только для публикуемых библиотек |
| Ревью | до слияния | всё, включая смысл | внимание человека |
Главное сравнение — с модулями сборки. Они дают границу раньше (код не компилируется) и надёжнее (обойти нельзя). Тест архитектуры даёт больше: правила внутри модуля (контексты в ядре не видят друг друга), правила именования, требования к аннотациям, запреты конкретных библиотек и классов. То есть они не конкуренты:
- Модули есть → тест архитектуры проверяет то, что модули не умеют: границы контекстов внутри ядра, имена, запреты вида «никаких
java.util.Date», расположение аннотаций. - Модулей нет (раскладка пакетами) → тест архитектуры заменяет их как основной механизм, и тогда правила направления зависимостей обязательны.
Разбор того, что дают модули и когда они нужны, — в статье про раскладку модулей.
Как это растёт со временем
Набор правил — живой. Когда какой-то антипаттерн один раз проскользнул через code review, его фиксируют новым тестом. Постепенно набор правил закрывает всё, что реально случалось в проекте.
Это делает ArchUnit-тесты чем-то вроде зафиксированных уроков: «мы один раз наступили на это — теперь тест это не пустит». Новый разработчик получает эти знания автоматически, без специального ввода в курс дела.
Вот правила, которые команды заводят после конкретных инцидентов — как примеры того, что вообще можно проверить:
// После того, как дата без зоны в проде разошлась на два часа
@ArchTest
static final ArchRule noLegacyDates =
noClasses().should().dependOnClassesThat()
.haveFullyQualifiedName("java.util.Date")
.orShould().dependOnClassesThat().haveFullyQualifiedName("java.sql.Timestamp")
.because("время — только java.time, и только с зоной (см. разбор аварии 2026-03-14)");
// После того, как агрегат создали в обход правил через публичный конструктор
@ArchTest
static final ArchRule aggregatesHaveNoPublicConstructors =
constructors().that().areDeclaredInClassesThat().resideInAPackage("..domain..aggregate..")
.should().notBePublic()
.because("агрегат создаётся только фабричным методом с проверкой инвариантов");
// После того, как транзакция открылась в контроллере и висела на время сериализации
@ArchTest
static final ArchRule transactionalOnlyOnHandlers =
methods().that().areAnnotatedWith(Transactional.class)
.should().beDeclaredInClassesThat().haveSimpleNameEndingWith("Handler")
.because("граница транзакции совпадает с операцией ядра");
// После того, как время внутри агрегата сделало тест нестабильным
@ArchTest
static final ArchRule noNowInsideDomain =
noClasses().that().resideInAPackage("..domain..")
.should().callMethod(Instant.class, "now")
.because("время приходит параметром, иначе ядро недетерминировано");
// После того, как ответ фреймворка уехал в доменное исключение
@ArchTest
static final ArchRule domainExceptionsAreClean =
noClasses().that().resideInAPackage("..domain..exception..")
.should().beAssignableTo("org.springframework.web.server.ResponseStatusException")
.because("код ответа живёт в адаптере входа, а не в исключении");
// После того, как запись базы протекла в порт
@ArchTest
static final ArchRule portsUseDomainTypes =
methods().that().areDeclaredInClassesThat().resideInAPackage("..port.out..")
.should().notHaveRawReturnType(resideInAPackage("..persistence.generated.."))
.because("порт говорит доменными типами");
Что делает такой набор ценным: каждое правило соответствует случившемуся, и в тексте причины стоит ссылка на разбор. Тогда через год никто не спрашивает «зачем этот странный запрет» — ответ написан рядом. И обратное: правило, введённое «на всякий случай», без случая за спиной, обычно первым и снимается при неудобстве.
Практический ритм: новое правило — часть плана действий по разбору аварии, наравне с исправлением кода. Разбор, после которого не появилось ни теста, ни правила, обычно заканчивается повторением той же аварии.
Глубже: тесты адаптеров: out через WireMock, in через срез, persistence через контейнеррасширенное
Раздел учит проверять границы. Три статьи повторяют, что модули нужны ради изоляции тестов, и ни одна не показывает сами тесты адаптеров; вот они, по одному на вид.
Out-адаптер к HTTP. Тест поднимает WireMock вместо партнёра и проверяет две вещи, за которые адаптер отвечает: перевод из доменного запроса в чужой формат и перевод чужих ответов в исключения из иерархии port'а. stubFor(post("/charge")).willReturn(serverError()) обязан дать PaymentUnavailableException, а не HttpServerErrorException; таймаут обязан дать то же самое, а не зависание; 422 с телом партнёра обязан дать PaymentRejectedException с причиной. Тест живёт в модуле адаптера, не поднимает Spring целиком (клиент собирают руками или через срез @RestClientTest) и идёт секунды.
In-адаптер. Срез @WebMvcTest на контроллер с заглушкой диспетчера: проверяется, что JSON запроса превращается в команду с правильными полями, что ответ обработчика превращается в JSON по контракту OpenAPI, что невалидное тело даёт 400 в формате ошибок, а доменное исключение из обработчика даёт нужный статус. Обработчик здесь заглушка, потому что его логику проверяют тесты ядра без Spring; тест адаптера отвечает только за перевод.
Persistence-адаптер. Единственный из трёх, которому нужна настоящая база: @JooqTest с Testcontainers и @ServiceConnection. Проверяют круг: сохранить агрегат, загрузить, получить равный; изменить коллекцию внутри, сохранить, загрузить, лишних строк нет; сохранить с устаревшей версией, получить исключение оптимистичной блокировки. Это тест на то, что репозиторий отдаёт агрегат целиком и сохраняет целиком, о чём статья про агрегат в нескольких таблицах.
Общее у трёх: тест адаптера не знает доменных правил, а тест ядра не знает адаптеров; если для теста контроллера понадобилась база, граница нарушена, и это находка того же рода, что и у ArchUnit. Сквозной тест через все слои остаётся один-два на сервис, по главному сценарию, а не на каждую ручку.
Коротко
- ArchUnit позволяет писать архитектурные правила как JUnit-тесты — нарушение правила сразу видно в CI. Тесты размещают в
bootstrap/src/test/java/— там есть доступ к классам всех модулей. - Обязательные правила:
coreне зависит от Spring/JOOQ/Jackson/HTTP/Kafka; порты — только интерфейсы; входные адаптеры не зависят от выходных; выходные адаптеры реализуют порты изcore/. Слои описывают черезlayeredArchitecture(), а не списком пакетов: список не охватывает адаптеры, которых ещё нет, и правило молча зеленеет. @AnalyzeClassesставится один раз на класс, чтобы не сканировать classpath повторно. Тест нужно сделать required в CI — иначе его легко обойти.- ArchUnit не замена code review, а его дополнение: ревью — дизайн и логика, ArchUnit — архитектурные инварианты. Адаптеры тестируют по виду: out через WireMock на перевод форматов и исключений, in через
@WebMvcTestс заглушкой диспетчера на перевод JSON и статусов, persistence через@JooqTestс контейнером на круг «сохранить, загрузить, версия». - Главное правило — ядро не зависит ни на один модуль проекта, а адаптеры не видят друг друга: зависимость на свой же модуль выглядит невинно и потому опаснее импорта библиотеки.
- Готовые описания слоёв и кольцевой архитектуры заменяют половину ручных правил и не рассыпаются при добавлении модуля; их совмещают с отдельными запретами, а
consideringAllDependenciesнужен почти всегда. - В отчёте о падении видно текст правила, число нарушений и каждое из них с файлом и строкой; поэтому текст правила и
because— это и есть сообщение об ошибке. - На существующем проекте правила пишут под целевое состояние и замораживают: новые нарушения роняют сборку, исправленные исчезают навсегда, а список в репозитории — видимая мера долга.
- Гейт не видит обращений по имени класса строкой, через контейнер, через исключённый из проверки сгенерированный код — и ничего не говорит о качестве границ: зелёный означает «стрелки верны», а не «архитектура хорошая».
- Модули сборки дают границу раньше и надёжнее, тест архитектуры — шире (контексты внутри ядра, имена, аннотации, запреты классов); при раскладке пакетами он единственный механизм.
Что почитать дальше
- Core-слой Hexagonal — что именно должно (и не должно) быть в ядре.
- Порты в Hexagonal — почему порт — интерфейс, а не класс.
- Адаптеры in и out — как устроены адаптеры и почему они не зависят друг от друга.
- Библиотека hexagonal-architecture — аннотации
@Portи@Adapterи готовые правила ArchUnit, по которым в тестах выше отбираются классы.