1 / 15

Исполняемая архитектура · ArchUnit 1.5.0

Ru BizGen

ArchUnit как исполняемая архитектура

Fitness-функции на bytecode: границы модулей, чистота ядра, слои core и соответствие Generator ↔ Benchmark — без надежды только на Gradle и code review.

История ArchUnit

Открытая библиотека архитектурных тестов для JVM: правила пишутся как обычные unit-тесты и проверяют bytecode.

Первый релиз апрель 2017 v0.4.0 — core, lang, library, JUnit
Авторы / компания TNG Technology Consulting Германия, open source
Тип проекта Java library Apache License 2.0 · test-scope
Сейчас в Ru BizGen 1.5.0 archunit-junit5 · апрель 2026
Сообщество GitHub ≈3.8k ★ 107 контрибьютеров · TNG/ArchUnit
Форки 345 копии репозитория · август 2026
  • Анализирует bytecode, а не «диаграмму на Confluence» — архитектура становится исполняемой.
  • Работает с JUnit 4/5 (и другими фреймворками): те же ./gradlew test/ CI.
  • Есть порт для .NET — ArchUnitNET; экосистема живая (релизы, examples, user guide).

Зачем внедрять ArchUnit

Архитектура «договорились на ревью» со временем размывается. ArchUnit фиксирует договорённости так же жёстко, как unit-тест на баг.

Ловит утечки раньше прода

Запрет core → IntelliJ/Ktor или циклов между модулями падает в CI на PR — до того, как зависимость «случайно» расползётся по команде.

Дешевле, чем процесс

Не нужен отдельный governance-ритуал. Правило — это код рядом с тестами, с текстом .because("…") на русском в failure report.

Работает на фактах bytecode

Gradle implementation не видит «случайный import». ArchUnit читает class-файлы: типы, вызовы, пакеты — то, что реально собралось.

Масштабируется с командой

Новый разработчик не обязан помнить все границы: CI напомнит. В Ru BizGen так держатся ядро, MCP, plugin и naming бенчмарков.

«Временное» не залипает

Костыль «только на этот PR» без правила остаётся на годы. ArchUnit превращает исключение в явный fail — либо чинят, либо осознанно ослабляют правило.

Документация, которая не врёт

Слайд и README устаревают. Падающий ArchRule с .because(…) — актуальный контракт границ прямо в отчёте CI.

Один check — и архитектура

Не нужен отдельный nightly-ритуал. В Ru BizGen :ru-bizgen-archunit:test входит в обычный ./gradlew check вместе с unit-тестами.

Спор на ревью → зелёный/красный

Вместо «мне кажется, core не должен…» — конкретный failing test. Обсуждение смещается с вкуса на правило: ослабить, исправить код или добавить исключение.

ArchUnit превращает архитектурные решения из слайда в зелёную галочку в пайплайне. Если правило нельзя выразить тестом — его, скорее всего, нельзя и соблюдать.

Модель работы ArchUnit

ArchUnit не смотрит на build.gradle.kts. Он читает скомпилированные .class и проверяет зависимости, пакеты и имена.

compile @AnalyzeClasses JavaClasses ArchRule.check() JUnit fail

Что импортируем

@AnalyzeClasses(
  packages = ["ru.eda.plgn.bizgen"],
  importOptions = [ImportOption.DoNotIncludeTests::class],
)
internal class ModuleBoundaryArchTest { ... }
  • packages — корень анализа; в classpath попадают классы из зависимостей модуля.
  • DoNotIncludeTests — тестовые классы не участвуют в правилах.

Два вида @ArchTest

1. Поле-правило — ArchUnit сам вызывает .check():

@ArchTest
val coreMustNotDependOnAdapters: ArchRule = noClasses()
  .that().resideInAnyPackage("ru.eda.plgn.bizgen.core..")
  .should().dependOnClassesThat().resideInAnyPackage(/* ... */)
  .because("ядро не должно зависеть от адаптеров")

2. Метод с инъекцией — получаете JavaClasses и проверяете вручную (нужно для Kotest-ассертов и кастомных условий):

@ArchTest
fun concreteBenchmarkCountMatchesGeneratorInfos(importedClasses: JavaClasses) {
  concreteBenchmarks(importedClasses).size shouldBe GeneratorInfoProvider.generatorInfos.size
}
Важно. Обычный @Test с параметром JavaClasses не получит инъекцию — только @ArchTest.

Fluent API: анатомия правила

Кликните по методу в коде или по chip — откроется объяснение в glossary.

noClasses()
  .that().resideInAnyPackage("ru.eda.plgn.bizgen.core..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "ru.eda.plgn.bizgen.plugin..",
    "ru.eda.plgn.bizgen.mcp..",
  )
  .because("ядро не должно зависеть от адаптеров")
Инверсия с noClasses(). Пишем should().dependOnClassesThat(...), а не notDependOn…. Смысл: «не должно существовать классов, которые зависят от запрещённых пакетов».
noClasses()
Стартовая точка «запрета»: правило падает, если найден хотя бы один класс, удовлетворяющий цепочке that…should….
that()
Переход к предикатам отбора субъектов правила (кто проверяется).
resideInAnyPackage("a..")
Ant-like matcher пакетов. Суффикс .. означает «этот пакет и все вложенные».
should()
Переход к условию («что нельзя / что должно быть»).
dependOnClassesThat()
Зависимость по bytecode: import, field type, method param/return, superclass и т.д.
because("…")
Текст попадает в failure report JUnit — пишем на русском, зачем правило существует.
layeredArchitecture()
Library API для слоёв: layer / definedBy / whereLayer / mayOnlyBeAccessedByLayers / mayNotBeAccessedByAnyLayer. В core — с consideringOnlyDependenciesInAnyPackage("…core.."), чтобы plugin/mcp не ломали правило «GeneratorInfo никого не зовёт внутри core».
slices().matching(…).beFreeOfCycles()
ru.eda.plgn.bizgen.(*).. режет код на slices по первому сегменту после bizgen (core, plugin, mcp, perf). Циклы между ними запрещены.
ArchRuleDefinition.classes()
Позитивные правила («классы, которые… должны…»). В бенчмарках: .that(predicate).should(condition).check(importedClasses).
DescribedPredicate
Именованный предикат отбора JavaClass (concrete Generator / Benchmark, без abstract и jmh_generated).
ArchCondition + SimpleConditionEvent
Кастомная проверка. При нарушении — SimpleConditionEvent.violated(item, message).
JavaClasses (injection)
Набор импортированных классов. Фильтрация: classes.that(predicate). Передаётся в @ArchTest fun(…).

Методики правил: Lang API

Базовый способ: «кто» + «что нельзя / должно» через ArchRuleDefinition → проверка на JavaClasses.

Точка входа

classes() / noClasses()

classes() — позитив: «классы, которые… должны…». noClasses() — инверсия: «не должно существовать классов, которые…».

  • Запреты зависимостей — почти всегда noClasses()
  • Naming / полнота — чаще classes().that(…).should(…)
noClasses().that().resideInAnyPackage("…core..")
  .should().dependOnClassesThat()
  .resideInAnyPackage("…plugin..")

Отбор субъектов

that() + пакеты / имена

Фильтр «кого проверяем»: пакет, имя, аннотация, модификаторы.

  • resideInAnyPackage — суффикс .. = пакет и вложенные
  • Несколько пакетов = OR
  • haveSimpleNameEndingWith, areAnnotatedWith
  • Кастом: .that(DescribedPredicate)

Условие

should() + зависимости

Факты из bytecode: import, field type, param/return, superclass…

  • dependOnClassesThat — с кем связан
  • onlyDependOnClassesThat / onlyBeAccessedBy…
  • Всегда .because("…") — текст падения в CI
.should().dependOnClassesThat()
  .resideInAnyPackage("io.ktor..")
  .because("mcp не тянет IntelliJ")

В Ru BizGen

Границы модулей

Почти все правила в ModuleBoundaryArchTest и PluginBoundaryArchTest — чистый Lang API.

  • core ↛ plugin/mcp/perf + forbidden third-party
  • mcp ↛ plugin/perf/IntelliJ
  • perf ↛ plugin/mcp/Ktor/MCP
  • plugin ↛ mcp/perf/Ktor (в plugin-модуле)

Library API: слои и slices

com.tngtech.archunit.library — готовые стили, когда одной цепочки dependOn мало. В BizGen используем два: слои внутри core и acyclic slices модулей.

Architectures

layeredArchitecture()

CoreLayerArchTest

  • layer / definedBy — имя слоя ↔ пакет
  • mayOnlyBeAccessedByLayers — кто сверху
  • mayNotBeAccessedByAnyLayer — верхний слой
  • consideringOnlyDependenciesInAnyPackage("…core..") — plugin/mcp могут вызывать Info; внутри core направление всё равно строгое
layeredArchitecture()
  .consideringOnlyDependenciesInAnyPackage("…core..")
  .layer("Utils").definedBy("…utils..")
  .layer("Generator").definedBy("…generator..")
  .layer("GeneratorInfo").definedBy("…generator_info..")
  .whereLayer("Utils").mayOnlyBeAccessedByLayers("Generator", "GeneratorInfo")
  .whereLayer("Generator").mayOnlyBeAccessedByLayers("GeneratorInfo")
  .whereLayer("GeneratorInfo").mayNotBeAccessedByAnyLayer()

Slices

slices().matching(…)

modulesMustBeFreeOfCycles

  • Паттерн ru.eda.plgn.bizgen.(*).. режет код на slices
  • beFreeOfCycles() — запрет A→B→A между slices
  • Не путать со слоями: slices — горизонтальные модули, layers — вертикаль внутри core
slices()
  .matching("ru.eda.plgn.bizgen.(*)..")
  .should().beFreeOfCycles()
  .because("модули не должны образовывать циклы")

Ещё в Library (не используем): onionArchitecture(), adhereToPlantUmlDiagram, GeneralCodingRules.

Предикаты и conditions

Когда Fluent API не выражает правило (naming 1:1, полнота реестра) — пишем свои DescribedPredicate и ArchCondition. В BizGen — только в BenchmarkNamingArchTest.

кто .that(predicate)
что должно .should(condition)
прогон .check(classes)

Отбор субъектов

DescribedPredicate<JavaClass>

Именованный фильтр. Описание попадает в failure report рядом с нарушителем.

  • isAssignableTo(Generator)
  • не interface / не ABSTRACT
  • без пакета .jmh_generated.
  • для бенчмарков: ещё исключить Base* / Str*Benchmark
DescribedPredicate.describe(
  "являются конкретными реализациями Generator"
) { javaClass ->
  javaClass.isAssignableTo(Generator::class.java)
    && !javaClass.isInterface
    && !javaClass.modifiers.contains(ABSTRACT)
    && !javaClass.name.contains(".jmh_generated.")
}

Условие на субъект

ArchCondition<JavaClass>

В check(item, events) решаете, нарушено ли правило. Нарушение — через SimpleConditionEvent.violated.

  • Generator → имя <SimpleName>Benchmark в set
  • Benchmark → имя без суффикса есть в set генераторов
  • Сообщение на русском — видно в CI
object : ArchCondition("иметь Benchmark") {
  override fun check(generator: JavaClass, events: ConditionEvents) {
    val expected = "${generator.simpleName}Benchmark"
    if (expected !in benchmarkSimpleNames) {
      events.add(SimpleConditionEvent.violated(
        generator, "для ${generator.name} ожидается $expected"))
    }
  }
}

Три правила naming — единственное место с custom API. Остальное закрыто Lang + Library.

ArchRuleDefinition.classes()
  .that(isConcreteGenerator())        // DescribedPredicate
  .should(haveBenchmarkNamed(names))  // ArchCondition
  .because("для каждого генератора — JMH-бенчмарк Benchmark")
  .check(importedClasses)

Карта проекта

ArchUnit читает bytecode на test-classpath, сам в runtime не входит

plugin mcp perf core archunit
runtime dep test classpath

Границы модулей

Файл ModuleBoundaryArchTest.kt — семь правил в :ru-bizgen-archunit.

coreMustNotDependOnAdapters

«ядро не должно зависеть от адаптеров (plugin, mcp, perf)»

noClasses().that().resideInAnyPackage("…core..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "…plugin..", "…mcp..", "…perf..")

core остаётся переиспользуемым ядром без знания об адаптерах.

plugin → core core → plugin
Что сломает правило?
Импорт ru.eda.plgn.bizgen.mcp.ToolDispatcher из класса в core.

coreMustNotDependOnForbiddenThirdParty

«ядро не должно зависеть от фреймворков адаптеров и инструментов бенчмаркинга»

noClasses().that().resideInAnyPackage("…core..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "com.intellij..", "com.jetbrains..", "io.ktor..",
    "io.modelcontextprotocol..", "org.openjdk.jmh..", "org.kodein..")

Bytecode-страховка к Gradle-контракту «core = stdlib only».

Что сломает правило?
Любой import com.intellij.openapi… или io.ktor… в core.

perfMustNotDependOnPluginOrMcp

«perf не должен зависеть от plugin и mcp»

noClasses().that().resideInAnyPackage("…perf..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "…plugin..", "…mcp..")

Бенчмарки меряют генераторы ядра, не UI и не HTTP-транспорт.

Что сломает правило?
Зависимость JMH-класса от Ktor/MCP или от plugin actions.

mcpMustNotDependOnPluginOrPerf

«mcp не должен зависеть от plugin и perf»

noClasses().that().resideInAnyPackage("…mcp..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "…plugin..", "…perf..")

MCP-сервер изолирован от IntelliJ-плагина и JMH.

mcp → core mcp → plugin
Что сломает правило?
Обращение к BizGenMainAction или к пакету …perf.bench из MCP.

mcpMustNotDependOnIntelliJ

«mcp не должен зависеть от IntelliJ Platform»

noClasses().that().resideInAnyPackage("…mcp..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "com.intellij..", "com.jetbrains..")

Сервер не тащит IDE API (com.intellij.. / com.jetbrains..).

Что сломает правило?
Использование ApplicationManager или AnAction в MCP-модуле.

perfMustNotDependOnAdapters

«perf не должен зависеть от IntelliJ Platform, MCP SDK и Ktor»

noClasses().that().resideInAnyPackage("…perf..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "com.intellij..", "com.jetbrains..",
    "io.modelcontextprotocol..", "io.ktor..")

Стек бенчмарков — JMH + core, без чужих адаптеров.

Что сломает правило?
Импорт Ktor или MCP SDK из …perf...

modulesMustBeFreeOfCycles

«модули ru.eda.plgn.bizgen не должны образовывать циклических зависимостей»

slices()
  .matching("ru.eda.plgn.bizgen.(*)..")
  .should().beFreeOfCycles()

Top-level slice = core / plugin / mcp / perf. Цикл A→B→A падает тестом.

Что сломает правило?
Взаимные зависимости пакетов разных модульных префиксов (например mcp ↔ plugin).

Границы plugin

PluginBoundaryArchTest.kt в ru-bizgen-plugin/…/archunit/ — анализ только пакета ru.eda.plgn.bizgen.plugin.

pluginMustNotDependOnMcpOrPerf

«plugin не должен зависеть от mcp и perf»

noClasses().that().resideInAnyPackage("…plugin..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "…mcp..", "…perf..")

IDE-плагин говорит только с core.

plugin → core plugin → mcp plugin → perf
Что сломает правило?
Импорт классов MCP или JMH из action/service плагина.

pluginMustNotDependOnMcpStack

«plugin не должен зависеть от MCP SDK и Ktor»

noClasses().that().resideInAnyPackage("…plugin..")
  .should().dependOnClassesThat().resideInAnyPackage(
    "io.modelcontextprotocol..", "io.ktor..")

Даже без прямого project-dependency на mcp нельзя «подтянуть» чужой стек библиотекой.

Что сломает правило?
Добавление ktor-server-netty в plugin и использование его типов.

Слои внутри core

CoreLayerArchTest — направление зависимостей только внутри ru.eda.plgn.bizgen.core...

GeneratorInfo Generator Utils доступ снизу вверх по стрелкам

coreLayersMustRespectDependencyDirection

«внутри core: Utils ← Generator ← GeneratorInfo»

layeredArchitecture()
  .consideringOnlyDependenciesInAnyPackage("…core..")
  .layer("Utils").definedBy("…core.utils..")
  .layer("Generator").definedBy("…core.generator..")
  .layer("GeneratorInfo").definedBy("…core.generator_info..")
  .whereLayer("Utils").mayOnlyBeAccessedByLayers("Generator", "GeneratorInfo")
  .whereLayer("Generator").mayOnlyBeAccessedByLayers("GeneratorInfo")
  .whereLayer("GeneratorInfo").mayNotBeAccessedByAnyLayer()
  • Utils — хелперы; вызывают Generator и Info
  • Generator — алгоритмы; без метаданных
  • GeneratorInfo — реестр; верхний слой
  • consideringOnly… — plugin/mcp могут звать Info
Что сломает правило?
Импорт GeneratorInfoProvider из …generator.impl, или generator из utils.

Naming Generator ↔ Benchmark

01

Полнота реестра

count == Info.size

@ArchTest
fun concreteBenchmarkCountMatchesGeneratorInfos(importedClasses: JavaClasses) {
  concreteBenchmarks(importedClasses).size shouldBe GeneratorInfoProvider.generatorInfos.size
}

Сломает: новый FooGeneratorInfo без JMH-класса.

02

Generator → Benchmark

есть <Name>Benchmark

@ArchTest
fun everyConcreteGeneratorHasMatchingBenchmark(importedClasses: JavaClasses) {
  val benchmarkSimpleNames = concreteBenchmarks(importedClasses).map { it.simpleName }.toSet()

  ArchRuleDefinition.classes()
    .that(isConcreteGenerator())
    .should(haveBenchmarkNamed(benchmarkSimpleNames))
    .because("для каждого генератора должен существовать JMH-бенчмарк с именем <GeneratorSimpleName>Benchmark")
    .check(importedClasses)
}
03

Benchmark → Generator

зеркало, без сирот

removeSuffix("Benchmark") ∈ concrete Generators. Вместе с 02 — полное 1:1.

@ArchTest
fun everyConcreteBenchmarkHasMatchingGenerator(importedClasses: JavaClasses) {
  val generatorSimpleNames = concreteGenerators(importedClasses).map { it.simpleName }.toSet()

  ArchRuleDefinition.classes()
    .that(isConcreteBenchmark())
    .should(haveGeneratorNamed(generatorSimpleNames))
    .because("имя каждого бенчмарка должно совпадать с именем генератора плюс суффикс Benchmark")
    .check(importedClasses)
}

Сломает: «осиротевший» LegacyInnBenchmark без генератора.

Как добавить новое правило

Короткий чеклист, чтобы правило попало в CI и осталось читаемым.

  1. Нужен bytecode plugin? → PluginBoundaryArchTest. Иначе → ru-bizgen-archunit.
  2. Импорт пакетов: @AnalyzeClasses(packages = ["ru.eda.plgn.bizgen"], …) или уже существующий scope.
  3. Предпочитайте готовый Fluent API; custom predicate/condition — только если naming/полнота.
  4. Всегда .because("…") на русском — это текст падения в CI.
  5. При noClasses() помните инверсию: dependOnClassesThat.
  6. Прогон: :ru-bizgen-archunit:test (и plugin-тест при правилах plugin).
  7. При желании — добавьте блок в эту документацию (тот же шаблон rule).

Запуск

ArchUnit входит в обычный жизненный цикл тестов Gradle.

# все правила arch-модуля (без IntelliJ download)
./gradlew :ru-bizgen-archunit:test

# границы plugin (нужна IDE на classpath plugin-тестов)
./gradlew :ru-bizgen-plugin:test --tests "*PluginBoundaryArchTest*"

# вместе с остальными быстрыми проверками
./gradlew check
  • Модуль: ru-bizgen-archunit (только src/test)
  • Зависимости: core, mcp, perf + jmhApiByConf, archunit-junit5
  • Исходники правил: …/archunit/ModuleBoundaryArchTest.kt, CoreLayerArchTest.kt, BenchmarkNamingArchTest.kt, plugin/…/PluginBoundaryArchTest.kt

Открыть эту страницу снова: docs/presentations/archunit/index.html