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

В гексагональной архитектуре есть несколько жёстких правил: ядро (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 — как устроены адаптеры и почему они не зависят друг от друга.