
Привет! Я Масгутов Руслан, архитектор в Т-Банке. Одна из моих задач — вести архитектурный надзор по техническим решениям. Проверка структуры проектов при ревью довольно быстро становится скучной рутиной, и появляется желание автоматизировать эту деятельность, чтобы освободить время для более интересных задач.
Расскажу, как мы используем ArchUnit для автоматизации архитектурного контроля. Покажу, как мы обернули правила в Gradle-плагин, встроили их в CI/CD, боремся с архитектурными отклонениями до того, как они попадают в pull request, и расскажу о возможности сбора архитектурных метрик.
Что такое архитектурная чистота
Архитектурная чистота проекта — то, о чем все договариваются в начале, но что почти неизбежно начинает разрушаться с ростом команды и числа сервисов. Мы это проходили: количество сервисов кратно возрастает, и уже не хватает времени на надлежащий контроль.
Так возникают архитектурные отклонения — мелкие (а иногда и не очень) нарушения принципов, которые накапливаются и со временем приводят к деградации архитектуры:
границы между слоями размываются;
появляется нежеланная связность;
усложняется сопровождение;
команда начинает бояться «ломать старое».
Часто отклонения не злонамеренны: кто-то просто хотел быстрее закрыть таску, кто-то не знал правил, кто-то «временно» сделал как проще. И это нормально, пока таких «временных» решений не становится сотни.
Мы пробовали держать архитектуру на ревью, митингах и вики-страницах. Это работало — до тех пор, пока проектов не стало слишком много.
Причины, по которым ручной подход не выдержал масштаб:
разработчики забывают, путаются или просто не знают правил;
архитекторы физически не успевают смотреть каждый PR;
ошибки обнаруживаются, когда уже все срослось и переписывать больно.
На этом фоне мы начали искать способ автоматизировать архитектурный контроль — и нашли ArchUnit.
Масштабы нашей архитектуры
У нас в отделе 10 команд разработки, каждая отвечает за свои сервисы и бизнес-домены. В активной разработке более 20 проектов на Kotlin, часть из них — микросервисы, часть — внутренние библиотеки.
Каждая команда имеет определенную степень автономии, но мы придерживаемся общих принципов по архитектуре:
Принципы DDD (Domain-Driven Design): четкие границы контекстов.
Слоистая архитектура (Layered Architecture): зависимости направлены сверху вниз.
Single Responsibility Principle (SRP): каждый класс и модуль отвечает за одну зону ответственности, контроллеры не содержат бизнес-логики, сервисы работают с данными не напрямую, а через репозитории.
Общая специфика проектов:
использование фреймворков Spring Boot, Ktor или Camunda;
обработка OLTP-нагрузки, асинхронных событий или задач;
использование REST или Kafka для взаимодействия между сервисами;
есть единые внутренние библиотеки, которые используются для логирования, сбора метрик и трассировки.
Кроме архитектурных принципов у нас было описано соглашение о структуре проектов в виде отдельной wiki-страницы. В ней фиксировались правила разметки слоев, структура пакетов, общие подходы к зависимостям между слоями и примеры для команд.

Документ стал основой для формализации правил в ArchUnit — многие проверки напрямую отражают договоренности, зафиксированные в wiki.
Мы искали способ централизовать архитектурные правила, сделать их понятными и проверяемыми. Мы хотели ловить отклонения еще до того, как они попадут в main
ветку, облегчить погружение новых разработчиков в проект и переход существующих между проектами.
Важно было достигнуть наших целей без дополнительной когнитивной нагрузки на команды и усложнения процессов через ADR или контроль со стороны архитектора.
Решением стал ArchUnit с оберткой в виде отдельного модуля с правилами, который можно подключать в проекты.
Основы ArchUnit
ArchUnit — это не отдельный инструмент или фреймворк, а Java-библиотека. Ее можно подключить в проект как обычную зависимость и использовать прямо внутри JUnit-тестов для проверки архитектурных ограничений.
Идея проста: мы описываем правила в виде кода, а библиотека проверяет, нарушены ли они в проекте. Это что-то вроде линтера, но не для кода, а для структуры проекта.
ArchUnit анализирует байткод и строит модель зависимостей классов, после чего позволяет выполнять над ней проверки. Примеры проверок:
какие пакеты импортируют какие;
какие аннотации используются;
какие классы вызывают или наследуют другие;
нет ли циклов, нарушений слоев и тому подобного.
Самое главное: все правила формулируются декларативно, в виде читаемого кода. Это значит, что архитектурные договоренности можно выразить явно и протестировать.
Примеры простых правил.
Запрет на использование java.util.logging
:
@ArchTest
static final ArchRule noJavaUtilLogging =
noClasses()
.should()
.accessClassesThat()
.resideInAPackage("java.util.logging")
.because("мы используем SLF4J вместо java.util.logging");
Слой бизнес-логики должен быть доступен из слоя контроллеров и бизнес-логики:
@ArchTest
static final ArchRule uiDoesNotDependOnDomain = classes()
.that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
Классы не должны находиться вне разрешенных пакетов:
@ArchTest
static final ArchRule classesShouldBeInAllowedPackages =
classes()
.should().resideInAnyPackage("..domain..", "..application..", "..infrastructure..")
.because("мы придерживаемся слоистой архитектуры");
ArchUnit отлично дружит с JUnit 5 (и 4), что позволяет подключить правила в стандартные тестовые сборки проекта:
@AnalyzeClasses(packages = "com.example.myapp")
public class ArchitectureTest {
@ArchTest
static final ArchRule rule = classes()
.that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
}
Организация архитектурных правил
Одно дело — писать архитектурные тесты, другое — поддерживать их единообразно во всех проектах. Если в работе больше двух сервисов, быстро становится понятно, что копировать одинаковые правила в каждый репозиторий — не вариант.
Мы пошли по пути вынесения правил в отдельный Gradle-плагин. Это позволило централизовать архитектурную логику и гибко подключать ее в нужных проектах.
Все архитектурные правила оформлены в виде внутреннего Gradle-плагина arch-checker
, который предоставляет набор готовых задач, подключает зависимости ArchUnit и базовую конфигурацию, позволяет переопределять часть проверок на уровне проекта.
Плагин легко подключается:
plugins {
id("ru.tbank.arch-checker") version "1.2.0"
}
Мы реализовали набор собственных архитектурных правил, оформленных как таски внутри Gradle-плагина. Покажу реализацию двух проверо: «использование ограниченного набора пакетов» и «вызовы между слоями».
Для этого нужно реализовать классы:
имплемент для запуска правил из gradle;
с самим архитектурным правилом;
с особым условием проверки, если стандартных возможностей недостаточно.
Класс ProjectStructureValidatorTask
, реализующий SourceTask
и VerificationTask
, позволяет легко конфигурировать, какие классы и пакеты будут проанализированы ArchUnit, и дает возможность встроить архитектурные проверки в стандартный жизненный цикл Gradle. Это обеспечивает чистую и прозрачную интеграцию с остальными этапами сборки, позволяет использовать зависимости (dependsOn
) от других задач и стандартные возможности Gradle для кэширования и инкрементальной сборки.
Каждый проект может конфигурировать правила через заложенную параметризацию. Это особенно важно в тех проектах, где архитектура уже отличалась от принятого соглашения и инвестировать для исправления ситуации нецелесообразно. Мы предусмотрели механизмы:
фильтрация слоев — например, исключить конкретный пакет, который «исторически» появился);
задание корневого пакета — когда в проекте присутствует несколько пакетов, не выделенных в модули gradle.
/**
* Таска проверки проекта на соответствие соглашениям по структуре.
*/
abstract class ProjectStructureValidatorTask : SourceTask(), VerificationTask {
@InputFiles
@SkipWhenEmpty
@IgnoreEmptyDirectories
@PathSensitive(PathSensitivity.RELATIVE)
override fun getSource() = super.getSource()
/**
* Корневые пакеты проекта, от которых начинается разделение по слоям. * Всегда должен быть один пакет. Но в целях обратной совместимости изменений поддерживаются несколько.
*/
@Input
lateinit var projectRootPackages: List<String>
/**
* RegExp для исключения пакетов из проверки.
* Сделано для обратной совместимости подключения плагина.
* */
@Input
lateinit var excludePattern: List<String>
@TaskAction
fun runArchTests() {
val javaClasses = importJavaClasses(source, excludePattern)
listOf(PackagesStructureArchRule(projectRootPackages), LayeredArchitectureArchRule(projectRootPackages))
.map { it.evaluate(javaClasses) }
.filter { it.hasViolation() }
.takeIf { it.isNotEmpty() }
?.map { it.failureReport }
?.joinToString(separator = "\n\r") { it.toString() }
?.also {
throw GradleException(ARCH_CHECKER_ERROR_MESSAGE_PREFIX + "\n\r" + it)
}
}
companion object {
private const val ARCH_CHECKER_ERROR_MESSAGE_PREFIX = "ArchChecker reported architecture failures: "
/**
* Импорт классов из директории с учетом exclude конфигурации.
* */
private fun importJavaClasses(source: FileTree, excludePackages: List<String>): JavaClasses? {
val excludePatterns = excludePackages.map { ".*$it.*" }.map { Pattern.compile(it) }
return ClassFileImporter()
.withImportOption { location -> excludePatterns.none { location.matches(it) } }
.importPaths(source.files.map { it.toPath() })
}
}
}
Немного теории про используемые Gradle-классы:
org.gradle.api.tasks.SourceTask
— базовый класс Gradle для задач, которые работают с исходным кодом или исходными файлами проекта.
На org.gradle.api.tasks.SourceTask
возложена ответственность:
за получение набора исходных файлов (например,
.class
-файлы,.java
,.kt
);конфигурацию путей к исходникам или классам через свойства задачи;
удобную обработку и фильтрацию файлов в рамках Gradle-пайплайна.
org.gradle.api.tasks.VerificationTask
— интерфейс, который маркирует задачи как задачи верификации. Задачи, реализующие интерфейс, обладают несколькими преимуществами:
их легко интегрировать в цепочку сборки, например в
check
илиverify
: Gradle автоматически запускает такие задачи как часть процесса валидации;они обычно завершаются с ошибкой, если проверка не пройдена, что останавливает билды в CI/CD;
позволяют разделять задачи на «проверяющие» и «генерирующие».
Класс LayeredArchitectureArchRule
, реализующий интерфейс com.tngtech.archunit.lang.ArchRule
, представляет само архитектурное правило. В нашем случае проверка структуры описывается через два правила: LayeredArchitectureArchRule
и PackagesStructureArchRule
.
LayeredArchitectureArchRule
проверяет вызовы классов между слоев. Для реализации пришлось объединить пакеты в слои. В нашем случае для удобства задаются четыре слоя, что отходит от общепринятого разделения:
in interaction пакеты, в которых есть реализация контроллеров, консюмеров кафки и делегатов камунды;
logic — пакеты с классами, реализующие бизнес-логику;
persistence — пакеты, в которых содержатся классы по взаимодействию с БД;
out interaction — пакеты с классами rest-клиентов и продюсеров событий в кафку.
/**
* Проверка взаимодействия слоев приложения.
*/
class LayeredArchitectureArchRule private constructor(
private val rule: ArchRule
) : ArchRule by rule {
/**
* Проверка доступа классов между слоями приложения.
* Входные точки (контроллеры, листенеры, джобы, делегаты и т. д.) -> бизнес-логика -> выходные точки (БД || внешнее взаимодействие) */ constructor(rootPackages: List<String>) : this(buildLayeredArchitecture(rootPackages))
companion object {
private const val IN_INTERACTION_LAYER_NAME = "inInteraction"
private const val LOGIC_LAYER_NAME = "logic"
private const val PERSISTENCE_LAYER_NAME = "persistence"
private const val OUT_INTERACTION_LAYER_NAME = "outInteraction"
@SuppressWarnings("SpreadOperator")
fun buildLayeredArchitecture(rootPackages: List<String>) =
Architectures.layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer(IN_INTERACTION_LAYER_NAME).definedBy(
*listOf("route", "controller", "job", "listener", "delegate").packagesSetOf(rootPackages)
.toTypedArray()
)
.layer(LOGIC_LAYER_NAME).definedBy(
*listOf("service", "processor", "component").packagesSetOf(rootPackages).toTypedArray()
)
.optionalLayer(PERSISTENCE_LAYER_NAME).definedBy(
*listOf("repository", "dao").packagesSetOf(rootPackages).toTypedArray()
)
.optionalLayer(OUT_INTERACTION_LAYER_NAME).definedBy(
*listOf("client", "producer").packagesSetOf(rootPackages).toTypedArray()
)
.whereLayer(IN_INTERACTION_LAYER_NAME).mayNotBeAccessedByAnyLayer()
.whereLayer(LOGIC_LAYER_NAME).mayOnlyBeAccessedByLayers(IN_INTERACTION_LAYER_NAME)
.whereLayer(OUT_INTERACTION_LAYER_NAME).mayOnlyBeAccessedByLayers(
LOGIC_LAYER_NAME
)
.whereLayer(PERSISTENCE_LAYER_NAME).mayOnlyBeAccessedByLayers(
LOGIC_LAYER_NAME
)!!
}
}
Правило PackagesStructureArchRule
проверяет, что классы находятся в разрешенных пакетах
/**
* Проверка структуры пакетов в проекте.
*/
@Suppress("unused")
class PackagesStructureArchRule private constructor(private val rule: ArchRule) : ArchRule by rule {
constructor(projectRootPackages: List<String>) : this(
ArchRuleDefinition.classes() .should(PackagesStructureArchCondition(ROOT_PACKAGES.packagesSetOf(projectRootPackages)))!!
)
companion object {
/**
* Список возможных пакетов для разделения по слоям. */ private val ROOT_PACKAGES = listOf(
"route",
"controller",
"job",
"listener",
"delegate",
"service",
"processor",
"component",
"repository",
"dao",
"client",
"producer",
"config",
"extension",
"exception",
"dto",
"entity",
"enum",
"property",
)
}
}
В правиле PackagesStructureArchRule
встроенных возможностей ArchUnit'а оказалось недостаточно, но в библиотеке заложена возможность расширения проверки через реализацию com.tngtech.archunit.lang.ArchCondition
— и это наш третий класс.
/**
* Условие проверяет пакеты в корне модуля на соответствие разрешенным пакетам. */
class PackagesStructureArchCondition(
private val definedPackages: List<String>,
) : ArchCondition<JavaClass>("be in certain root packages") {
/**
* Проверяем для каждого класса начало full qualified имени.
* При проверке класса учитываются пакеты-исключения.
*/
override fun check(item: JavaClass, events: ConditionEvents) {
val isDefinedPackage = definedPackages.any { item.fullName.startsWith("$it.") }
if (!isDefinedPackage) {
events.add(
SimpleConditionEvent.violated(
item,
"${item.fullName} расположен не в определенном списке пакетов: ${definedPackages.joinToString()}"
)
)
}
}
}
В итоге мы получили плагин с такой структурой:
arch-checker/
├── src/
│ └── main/
│ └── kotlin/
│ └── ru/tbank/archchecker/
│ ├── ArchCheckerPlugin.kt
│ ├── condition/ -- содержит сложные условия проверки
│ │ └── PackagesStructureArchCondition.kt
│ ├── extenstion/ -- функции-утилиты
│ │ └── StringExtension.kt
│ ├── rule/ -- проверяемые правила
│ │ ├── LayeredArchitectureArchRule.kt
│ │ ├── ...
│ │ └── PackagesStructureArchRule.kt
│ └── task/ -- обертки для вызова правил через gradle task api
│ ├── ProjectStructureValidatorTask.kt
│ └── ...
Архитектурные проверки стали таким же стандартом, как линтеры или тесты. При этом подход с Gradle-плагином дал нам:
единый источник архитектурной истины;
прозрачную и версионируемую систему правил;
гибкость при кастомизации без потери поддержки.
Подводные камни
Как и с любой архитектурной инициативой, внедрение ArchUnit не обошлось без граблей. Вот список проблем, которых мы ожидали на входе или столкнулись в процессе внедрения подхода.
Проблема: сопротивление команд.
> «Зачем еще один слой тестов?»
> «У нас и так все нормально, мы пишем по совести»
Многие разработчики сначала воспринимают архитектурные проверки как избыточный контроль. Особенно если правила навязываются централизованно.
Решение: мы включили команды в процесс формулирования правил еще до автоматизации. Проверки стали ожидаемы и имели мотивацию, исходящую от команд разработки.
Проблема: хрупкие правила. Некоторые ArchUnit-правила могут разбиваться при незначительных изменениях, особенно если:
проект нестабилен;
в именовании или структуре нет конвенций;
проверка завязана на «магические строки» пакетов.
Решение: мы добавляли кастомные фильтры для исключений и писали понятную ошибку в каждое правило — объяснение очень помогает при падении. А еще качественно документировали реализацию правил.
Проблема: сложности с Kotlin. Хотя ArchUnit совместим с Kotlin, бывают нюансы:
некоторые зависимости не видны, особенно при использовании
inline
иreified
;kotlin-модули компилируются позже, и
compileKotlin
надо явно указывать какdependsOn
;extension-функции при компиляции становятся классами.
Решение:
настроили Gradle так, чтобы ArchUnit запускался после полной компиляции всех kotlin-классов;
учли в правилах соответствующие пакеты с extension-функциями.
Проблема: исходный набор исключений. При внедрении проще «временно замьютить» проверку, чем ее чинить. В итоге можно случайно замаскировать архитектурный долг.
Решение: при внедрении в существующие проекты исправляли исключения там, где возможно было сделать через минимальные инвестиции.
Эффект от внедрения
Использование ArchUnit оказывает ощутимое влияние на процессы в команде и архитектуру проектов, особенно в условиях роста, множества сервисов и распределенной разработки.
Архитектурные нарушения отлавливаются на раннем этапе. Большинство типовых ошибок — проброс сущностей через слои, случайные зависимости от инфраструктуры, обход сервисного слоя — автоматически ловятся еще до code review. Это разгружает ревьюеров и экономит время.
Новые проекты стартуют на четком архитектурном каркасе. Даже если сервис еще MVP, архитектурные правила дают ему «скелет». Команда с первых дней работает в четко очерченных границах: домен не знает про UI, инфраструктура не «протекает» в ядро и каждый слой отвечает за свое.
Меньше ручной рутины, больше автоматизации. Архитектурный контроль превращается в обычную часть пайплайна. ArchUnit проверяет правила так же, как линтер — стиль, а тесты — корректность. Архитектор перестает быть «сторожем входа» и больше фокусируется на эволюции системы, а не на микроконтроле.
Заключение
ArchUnit — это не про запреты, а про помощь в поддержании архитектурного порядка. Он усиливает проектную дисциплину, помогает держать архитектурную рамку и делает структуру кода предсказуемой.
Правила можно формализовать и делиться ими между проектами, а автоматизация позволяет масштабировать архитектурные подходы на десятки команд.
Инструмент требует настройки, времени и аккуратного внедрения. Но чем раньше он становится частью процессов, тем меньше хаоса копится в коде.
ArchUnit не серебряная пуля, но отличный повод перестать надеяться на «само не развалится».
Архитектурный контроль — не только правила вроде «Controller не должен знать про Repository». Иногда важно смотреть в корень системной сложности, измеряя, насколько структура проекта способствует или препятствует изменениям. Сейчас мы в процессе использования ArchUnit как инструмента для анализа архитектурных метрик, которые библиотека представляет «из коробки»:
Cumulative Dependency Metrics (John Lakos);
Component Dependency Metrics (Robert C. Martin);
Visibility Metrics (Herbert Dowalil).
Если вы уже используете ArchUnit — делитесь своими правилами, подходами и находками.
Если только присматриваетесь — начните с малого, и эффект не заставит себя ждать.