В гексагональной архитектуре есть несколько жёстких правил: ядро (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 — на архитектурные инварианты. Это разные плоскости.
Где размещать тесты
Типичное место — 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 inAdapterShouldNotDependOnOutAdapter =
noClasses().that().resideInAnyPackage("..userapi..", "..adminapi..", "..kafkain..")
.should().dependOnClassesThat().resideInAnyPackage(
"..persistence..", "..sberout..", "..smsout..", "..kafkaout..");
@ArchTest
static final ArchRule outAdaptersShouldImplementPorts =
classes().that().resideInAPackage("..sberout..")
.and().areAnnotatedWith(Component.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/ — только интерфейсы — порт — это контракт между ядром и внешним миром. Реализация контракта всегда снаружи ядра, в адаптере. Если порт — класс, граница размыта.
Входной адаптер не зависит от выходного — userapi, adminapi, kafkain не должны напрямую импортировать persistence, sberout и подобные. Они общаются через ядро и его порты.
Выходной адаптер реализует порт — каждый @Component в выходном адаптере должен реализовывать интерфейс из core/port/out/. Это гарантирует, что контракт и реализация связаны.
Контроллер реализует сгенерированный API — контроллер должен реализовывать интерфейс, сгенерированный из OpenAPI-спецификации. Это не даёт расходиться коду и контракту.
Как добавить в CI
Тест запускается как обычный JUnit, поэтому достаточно включить его в стандартный прогон:
# .github/workflows/ci.yml
jobs:
arch-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: 21
- run: ./gradlew :bootstrap:test --tests "*HexagonalArchitectureTest*"
Важный шаг — сделать этот job обязательным в настройках защиты ветки (branch protection rules). Без этого разработчик может открыть PR, не запустив тест локально, и если ревьюер тоже пропустит — нарушение попадёт в main.
Когда тест обязателен: нельзя смерджить PR без зелёного arch-test. Это делает ошибку дешёвой — поймали в CI, а не через месяц в продакшне.
Единая точка скана
@AnalyzeClasses стоит ставить один раз на уровне класса, а не повторять в каждом тесте:
@AnalyzeClasses(packages = "ru.example.order")
public class HexagonalArchitectureTest {
// все правила разделяют один обход classpath
}
Без этого каждое правило запускает сканирование classpath заново. На проекте с большим количеством классов это секунды на каждое правило. С общим сканом — миллисекунды.
Как это растёт со временем
Набор правил — живой. Когда какой-то антипаттерн один раз проскользнул через code review, его фиксируют новым тестом. Постепенно набор правил закрывает всё, что реально случалось в проекте.
Это делает ArchUnit-тесты чем-то вроде зафиксированных уроков: «мы один раз наступили на это — теперь тест это не пустит». Новый разработчик получает эти знания автоматически, без специального ввода в курс дела.
Коротко
- ArchUnit позволяет писать архитектурные правила как JUnit-тесты — нарушение правила сразу видно в CI.
- Тесты размещают в
bootstrap/src/test/java/— там есть доступ к классам всех модулей. - Обязательные правила:
coreне зависит от Spring/JOOQ/Jackson/HTTP/Kafka; порты — только интерфейсы; входные адаптеры не зависят от выходных; выходные адаптеры реализуют порты изcore/. @AnalyzeClassesставится один раз на класс, чтобы не сканировать classpath повторно.- Тест нужно сделать required в CI — иначе его легко обойти.
- ArchUnit не замена code review, а его дополнение: ревью — дизайн и логика, ArchUnit — архитектурные инварианты.
Что почитать дальше
- Core-слой Hexagonal — что именно должно (и не должно) быть в ядре.
- Порты в Hexagonal — почему порт — интерфейс, а не класс.
- Адаптеры in и out — как устроены адаптеры и почему они не зависят друг от друга.