Привет! Я Масгутов Руслан, архитектор в Т-Банке. Одна из моих задач — вести архитектурный надзор по техническим решениям. Проверка структуры проектов при ревью довольно быстро становится скучной рутиной, и появляется желание автоматизировать эту деятельность, чтобы освободить время для более интересных задач.  

Расскажу, как мы используем 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 — делитесь своими правилами, подходами и находками.  

Если только присматриваетесь — начните с малого, и эффект не заставит себя ждать.

Комментарии (0)