Опирается на правила: R-HEX-TEST-1R-HEX-TEST-3 и R-HEX-TEST-X1 из Hexagonal Style Guide → раздел 8. Архитектурные тесты.

Важно знать

  • ArchUnit-тесты — обязательный механизм enforcement правил Hexagonal на test-time. Только code-review недостаточно.
  • Тесты живут в bootstrap/src/test/java/ (либо в отдельном модуле architecture-tests/).
  • Проверяют: core/ без Spring/JOOQ/Jackson/OkHttp/Kafka; core/<bc>/port/out/* — только interfaces; out-adapter'ы implements port'ы; in-adapter ↛ out-adapter; controller implements generated <Tag>Api.
  • Required CI check. PR не мерджится, если archtest падает.
  • @AnalyzeClasses(packages = "<root.package>") — единая точка скана для всех тестов.
  • Только code-review без ArchUnit — антипаттерн: ревьюер пропустит хотя бы один impossible-import, и через полгода core/ уже не чистый.

Multi-module gradle даёт compile-time гарантии: если core/build.gradle.kts не зависит от Spring, импорт org.springframework.* не скомпилируется. Но gradle не ловит все правила Hexagonal — например, «port — это interface, не class» или «out-adapter не должен иметь бизнес-логику». Для таких правил нужен runtime-проверяющий механизм. ArchUnit — это библиотека, которая позволяет писать архитектурные тесты как обычные JUnit-тесты, и они становятся required CI check. Раскрытие правил R-HEX-TEST-* ниже.

Где живут тесты

R-HEX-TEST-1: типичное расположение — bootstrap/src/test/java/.

bootstrap/
└── src/test/java/<pkg>/architecture/
    ├── ArchitectureTest.java          # @AnalyzeClasses + общие правила
    ├── CoreLayerTest.java             # правила для core/
    ├── PortTest.java                  # правила для port-интерфейсов
    ├── AdapterTest.java               # правила для адаптеров
    └── ControllerTest.java            # правила для контроллеров

Почему в bootstrap/:

  • Видит все модули. bootstrap/ зависит от всех остальных, поэтому в его test-classpath есть классы из core/, persistence/, всех адаптеров. ArchUnit может их сканить.
  • Существующая инфраструктура. В bootstrap уже есть test-зависимости, JUnit, ассерты — добавление ArchUnit ничего не ломает.
  • Логически связано с composition. Composition root знает «как собирается сервис», логично, чтобы он же проверял правила сборки.

Альтернатива — отдельный gradle-модуль architecture-tests/, который зависит от всех остальных. Чище, но добавляет ещё один модуль; на практике в bootstrap/ приживается лучше.

Что проверять

R-HEX-TEST-1 детально: список обязательных правил.

@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Чистый Java/Lombok в core.
core ↛ JOOQPersistence-detail в persistence/.
core ↛ JacksonJSON-сериализация — деталь in-adapter.
core ↛ HTTP-клиентыHTTP — в out-adapter'ах.
core ↛ KafkaKafka — в kafka-in/out-adapter'ах.
Port = interfacePort-классы запрещены (R-HEX-PORT-X4).
in-adapter ↛ out-adapterСимметрия Hexagonal.
Out-adapter implements portКонтракт ↔ реализация связаны.
Controller implements <Tag>ApiOpenAPI-spec как источник правды.

Полный список правил расширяется со временем — каждый раз, когда в код проползает антипаттерн через ревью, добавляем тест, чтобы такого больше не было.

Required CI check

R-HEX-TEST-2: тест должен быть обязательным для мерджа PR.

В GitHub Actions / GitLab CI:

# .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 "*ArchitectureTest*"

И в branch protection rules: arch-test job — required. PR не мерджится, если падает.

Зачем именно required:

  • Без CI-гарантии правила не держатся. Кто-то откроет PR, ArchUnit-тест локально не запустит, ревью пропустит — нарушение влезет в main.
  • Сообщает интент. «У нас архитектурные тесты required» — это сигнал коллегам, что границы серьёзные.
  • Делает ошибку дешёвой. Поймали на этапе CI ≪ дешевле, чем поймали после релиза в проде.

Единая точка скана

R-HEX-TEST-3: @AnalyzeClasses(packages = "<root.package>") на classes-уровне, не на каждом тесте.

@AnalyzeClasses(packages = "ru.example.order")
public class HexagonalArchitectureTest {
    // все тесты разделяют один scan
}

Без этого каждый @ArchTest стартует scan заново — на проекте из 200K классов это секунды. С общим scan'ом — миллисекунды.

Что запрещено

R-HEX-TEST-X1: только code-review для enforcement Hexagonal-правил.

«Ревьюер заметит, если кто-то добавит Spring в core» — это иллюзия:

  • Усталость ревьюера. В большом PR с 30 файлами один лишний import org.springframework.* теряется.
  • Невидимые цепочки. A зависит от B, B от C, C — от Spring. В коммите выглядит как «добавили B в A», ревью пропустит.
  • Новый коллега не знает правил. Code-review культурно передаёт знания, но медленно. ArchUnit-тест — мгновенная обратная связь: «твой PR ломает правило X».
  • Решения через override. Code-review допускает «ок, сегодня можно, потом починим» — и это «потом» наступает редко. ArchUnit либо да, либо нет — нет «договорились по особому случаю».

ArchUnit-тест дополняет code-review, а не заменяет. Ревью смотрит «дизайн, читаемость, бизнес-логика»; ArchUnit — «архитектурные инварианты». Это разные плоскости.

Куда дальше

  • Hexagonal Style Guide → раздел 8. Архитектурные тесты — нормативные формулировки.
  • ArchUnit Documentation — официальная документация библиотеки.
  • Core слой — что именно проверяют тесты «core без Spring/JOOQ».
  • Ports — почему port — interface, не class.
  • Adapters in и Adapters out — симметричное правило «адаптеры не зависят друг от друга».