Опирается на правила:
R-HEX-TEST-1…R-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 ↛ JOOQ | Persistence-detail в persistence/. |
core ↛ Jackson | JSON-сериализация — деталь in-adapter. |
core ↛ HTTP-клиенты | HTTP — в out-adapter'ах. |
core ↛ Kafka | Kafka — в kafka-in/out-adapter'ах. |
| Port = interface | Port-классы запрещены (R-HEX-PORT-X4). |
| in-adapter ↛ out-adapter | Симметрия Hexagonal. |
| Out-adapter implements port | Контракт ↔ реализация связаны. |
Controller implements <Tag>Api | OpenAPI-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 — симметричное правило «адаптеры не зависят друг от друга».