Ловит утечки раньше прода
Запрет core → IntelliJ/Ktor или циклов между модулями падает в CI
на PR — до того, как зависимость «случайно» расползётся по команде.
Исполняемая архитектура · ArchUnit 1.5.0
ArchUnit как исполняемая архитектура
Fitness-функции на bytecode: границы модулей, чистота ядра, слои
core и соответствие Generator ↔ Benchmark — без надежды
только на Gradle и code review.
Открытая библиотека архитектурных тестов для JVM: правила пишутся как обычные unit-тесты и проверяют bytecode.
Сайт: archunit.org · Исходники: github.com/TNG/ArchUnit · Примеры: ArchUnit-Examples
Архитектура «договорились на ревью» со временем размывается. ArchUnit фиксирует договорённости так же жёстко, как unit-тест на баг.
Запрет core → IntelliJ/Ktor или циклов между модулями падает в CI
на PR — до того, как зависимость «случайно» расползётся по команде.
Не нужен отдельный governance-ритуал. Правило — это код рядом с тестами,
с текстом .because("…") на русском в failure report.
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 не смотрит на build.gradle.kts. Он читает
скомпилированные .class и проверяет зависимости, пакеты и имена.
@AnalyzeClasses(
packages = ["ru.eda.plgn.bizgen"],
importOptions = [ImportOption.DoNotIncludeTests::class],
)
internal class ModuleBoundaryArchTest { ... }
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.
Кликните по методу в коде или по chip — откроется объяснение в glossary.
noClasses() .that().resideInAnyPackage("ru.eda.plgn.bizgen.core..") .should().dependOnClassesThat().resideInAnyPackage( "ru.eda.plgn.bizgen.plugin..", "ru.eda.plgn.bizgen.mcp..", ) .because("ядро не должно зависеть от адаптеров")
should().dependOnClassesThat(...), а не
notDependOn…. Смысл: «не должно существовать классов,
которые зависят от запрещённых пакетов».
that…should…... означает «этот пакет и все вложенные».layer / definedBy / whereLayer / mayOnlyBeAccessedByLayers / mayNotBeAccessedByAnyLayer. В core — с consideringOnlyDependenciesInAnyPackage("…core.."), чтобы plugin/mcp не ломали правило «GeneratorInfo никого не зовёт внутри core».ru.eda.plgn.bizgen.(*).. режет код на slices по первому сегменту после bizgen (core, plugin, mcp, perf). Циклы между ними запрещены..that(predicate).should(condition).check(importedClasses).JavaClass (concrete Generator / Benchmark, без abstract и jmh_generated).SimpleConditionEvent.violated(item, message).classes.that(predicate). Передаётся в @ArchTest fun(…).
Базовый способ: «кто» + «что нельзя / должно» через
ArchRuleDefinition → проверка на JavaClasses.
Точка входа
classes() / noClasses()
classes() — позитив: «классы, которые… должны…».
noClasses() — инверсия: «не должно существовать классов, которые…».
noClasses()classes().that(…).should(…)noClasses().that().resideInAnyPackage("…core..")
.should().dependOnClassesThat()
.resideInAnyPackage("…plugin..")
Отбор субъектов
that() + пакеты / именаФильтр «кого проверяем»: пакет, имя, аннотация, модификаторы.
resideInAnyPackage — суффикс .. = пакет и вложенные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-partymcp ↛ plugin/perf/IntelliJperf ↛ plugin/mcp/Ktor/MCPplugin ↛ mcp/perf/Ktor (в plugin-модуле)
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.(*).. режет код на slicesbeFreeOfCycles() — запрет A→B→A между slicesslices()
.matching("ru.eda.plgn.bizgen.(*)..")
.should().beFreeOfCycles()
.because("модули не должны образовывать циклы")
Ещё в Library (не используем): onionArchitecture(),
adhereToPlantUmlDiagram, GeneralCodingRules.
Когда Fluent API не выражает правило (naming 1:1, полнота реестра) —
пишем свои DescribedPredicate и ArchCondition.
В BizGen — только в BenchmarkNamingArchTest.
.that(predicate)
.should(condition)
.check(classes)
Отбор субъектов
DescribedPredicate<JavaClass>Именованный фильтр. Описание попадает в failure report рядом с нарушителем.
isAssignableTo(Generator)ABSTRACT.jmh_generated.Base* / Str*BenchmarkDescribedPredicate.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.
<SimpleName>Benchmark в setobject : 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 не входит
Файл ModuleBoundaryArchTest.kt — семь правил в
:ru-bizgen-archunit.
«ядро не должно зависеть от адаптеров (plugin, mcp, perf)»
noClasses().that().resideInAnyPackage("…core..")
.should().dependOnClassesThat().resideInAnyPackage(
"…plugin..", "…mcp..", "…perf..")
core остаётся переиспользуемым ядром без знания об адаптерах.
ru.eda.plgn.bizgen.mcp.ToolDispatcher из класса в core.«ядро не должно зависеть от фреймворков адаптеров и инструментов бенчмаркинга»
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.«perf не должен зависеть от plugin и mcp»
noClasses().that().resideInAnyPackage("…perf..")
.should().dependOnClassesThat().resideInAnyPackage(
"…plugin..", "…mcp..")
Бенчмарки меряют генераторы ядра, не UI и не HTTP-транспорт.
«mcp не должен зависеть от plugin и perf»
noClasses().that().resideInAnyPackage("…mcp..")
.should().dependOnClassesThat().resideInAnyPackage(
"…plugin..", "…perf..")
MCP-сервер изолирован от IntelliJ-плагина и JMH.
BizGenMainAction или к пакету …perf.bench из MCP.«mcp не должен зависеть от IntelliJ Platform»
noClasses().that().resideInAnyPackage("…mcp..")
.should().dependOnClassesThat().resideInAnyPackage(
"com.intellij..", "com.jetbrains..")
Сервер не тащит IDE API (com.intellij.. / com.jetbrains..).
«perf не должен зависеть от IntelliJ Platform, MCP SDK и Ktor»
noClasses().that().resideInAnyPackage("…perf..")
.should().dependOnClassesThat().resideInAnyPackage(
"com.intellij..", "com.jetbrains..",
"io.modelcontextprotocol..", "io.ktor..")
Стек бенчмарков — JMH + core, без чужих адаптеров.
…perf...«модули ru.eda.plgn.bizgen не должны образовывать циклических зависимостей»
slices()
.matching("ru.eda.plgn.bizgen.(*)..")
.should().beFreeOfCycles()
Top-level slice = core / plugin / mcp / perf. Цикл A→B→A падает тестом.
PluginBoundaryArchTest.kt в
ru-bizgen-plugin/…/archunit/ —
анализ только пакета ru.eda.plgn.bizgen.plugin.
«plugin не должен зависеть от mcp и perf»
noClasses().that().resideInAnyPackage("…plugin..")
.should().dependOnClassesThat().resideInAnyPackage(
"…mcp..", "…perf..")
IDE-плагин говорит только с core.
«plugin не должен зависеть от MCP SDK и Ktor»
noClasses().that().resideInAnyPackage("…plugin..")
.should().dependOnClassesThat().resideInAnyPackage(
"io.modelcontextprotocol..", "io.ktor..")
Даже без прямого project-dependency на mcp нельзя «подтянуть» чужой стек библиотекой.
ktor-server-netty в plugin и использование его типов.
CoreLayerArchTest — направление зависимостей только внутри
ru.eda.plgn.bizgen.core...
«внутри 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()
GeneratorInfoProvider из …generator.impl, или generator из utils.Полнота реестра
@ArchTest
fun concreteBenchmarkCountMatchesGeneratorInfos(importedClasses: JavaClasses) {
concreteBenchmarks(importedClasses).size shouldBe GeneratorInfoProvider.generatorInfos.size
}
Сломает: новый FooGeneratorInfo без JMH-класса.
Generator → 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)
}
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 и осталось читаемым.
PluginBoundaryArchTest. Иначе → ru-bizgen-archunit.@AnalyzeClasses(packages = ["ru.eda.plgn.bizgen"], …) или уже существующий scope..because("…") на русском — это текст падения в CI.noClasses() помните инверсию: dependOnClassesThat.:ru-bizgen-archunit:test (и plugin-тест при правилах plugin).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)jmhApiByConf, archunit-junit5…/archunit/ModuleBoundaryArchTest.kt,
CoreLayerArchTest.kt,
BenchmarkNamingArchTest.kt,
plugin/…/PluginBoundaryArchTest.kt
Открыть эту страницу снова:
docs/presentations/archunit/index.html