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

В гексагональной архитектуре есть несколько жёстких правил: ядро (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)» — число нарушений, а не число классов. Одно неудачное решение обычно даёт несколько записей: поле, вызов метода, параметр конструктора.
  • Каждая запись называет что именно зависит (поле, метод, конструктор, параметр), от чего и где — с файлом и строкой. По этой строке переход в редакторе делается щелчком.

Что делать с таким падением. Три варианта, и выбирают осознанно:

  1. Починить код — обычный случай: убрать зависимость, вернуть работу в адаптер, передать данные параметром.
  2. Уточнить правило, если нарушение законное. Пример: ядру действительно нужна одна аннотация управления транзакциями — тогда правило получает явное исключение с комментарием почему:
@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")));
  1. Зафиксировать как известное нарушение — если правило вводится на существующем коде (см. следующий раздел).

Полезная привычка: в каждом правиле писать because("..."). Этот текст попадает в сообщение об ошибке, и человек, увидевший красный тест впервые, понимает не только «нельзя», но и «почему нельзя» — а это разница между «уберу правило» и «поправлю код».

Внедрение на существующем проекте

Правило, введённое на проекте, где нарушений сотни, краснеет сразу — и его выключают через день. Это главная причина, по которой тесты архитектуры «не приживаются». Штатный ответ есть, и он в самом ArchUnit.

Фиксация текущих нарушений. Правило оборачивается так, что существующие нарушения записываются в файл и считаются известными, а любое новое роняет сборку:

@ArchTest
static final ArchRule coreShouldNotDependOnSpring =
    FreezingArchRule.freeze(
        noClasses().that().resideInAPackage("..core..")
            .should().dependOnClassesThat().resideInAPackage("org.springframework.."));

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

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

Порядок внедрения на живом проекте:

  1. Написать правила как надо — не под текущее состояние, а под целевое.
  2. Заморозить все, сборка зелёная с первого дня.
  3. Посмотреть на размер списка — это оценка работы, и её можно показать команде.
  4. Разгребать по ходу дела: тронули класс — заодно убрали его нарушение. Специальные задачи «почистить архитектуру» обычно не нужны.
  5. Проверять, что список сокращается — например, раз в квартал сравнивать число строк. Список, который не уменьшается год, означает, что правило не разделяют, и это повод обсудить его, а не молча терпеть.

Чего не делать: не замораживать правила, которые и так зелёные (лишняя сложность), и не удалять записи из списка вручную «чтобы починить тест» — это ровно то, от чего механизм защищает.

Чего гейт не ловит

Ограничения инструмента надо назвать прямо, иначе он воспринимается как полная гарантия — и тогда его зелёный статус становится опаснее его отсутствия.

Он смотрит на скомпилированный код, а значит, не видит зависимостей, которых в нём нет:

  • Обращение по имени класса строкой. 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 — это и есть сообщение об ошибке.
  • На существующем проекте правила пишут под целевое состояние и замораживают: новые нарушения роняют сборку, исправленные исчезают навсегда, а список в репозитории — видимая мера долга.
  • Гейт не видит обращений по имени класса строкой, через контейнер, через исключённый из проверки сгенерированный код — и ничего не говорит о качестве границ: зелёный означает «стрелки верны», а не «архитектура хорошая».
  • Модули сборки дают границу раньше и надёжнее, тест архитектуры — шире (контексты внутри ядра, имена, аннотации, запреты классов); при раскладке пакетами он единственный механизм.

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