Модульная архитектура в крупных Android-приложениях: когда вводить, как строить и чего ждать

Модульная архитектура Android-проекта

Когда это вообще нужно?

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

Два грубых ориентира: меньше 5 разработчиков и меньше 40–50 экранов. Цифры — прокси, а не правило. Они указывают на более важный сигнал: есть ли у вас независимые команды, которые реально мешают друг другу, и ощущается ли время сборки как проблема.

Команда из 4 человек с 60 экранами, которые спокойно работают в одном репозитории — не целевая аудитория. Монолит с чистой архитектурой внутри на таком масштабе выиграет: меньше накладных расходов, быстрее старт, проще онбординг.

Модульность оправдана, когда возникают эти симптомы:

  • Полная сборка после изменения любого файла занимает 3+ минут. Это реалистично для проектов от ~80–100 тысяч строк Kotlin на среднестатистическом CI-агенте (8 ядер, 16 ГБ RAM) — при условии интенсивного использования KAPT и большого числа аннотаций. Проекты с тем же объёмом кода, но без annotation processing, добираются до этого порога позже. Модульная сборка решает это через инкрементальность: Gradle отслеживает, какие модули затронуло изменение, и перекомпилирует только их вместе с зависящими от них. Если граф зависимостей выстроен так, что :feature:feed не зависит от :feature:profile:impl, изменение в реализации профиля физически не может привести к перекомпиляции фида.
  • Несколько команд конфликтуют в git. Механика снижения конфликтов прямая: в монолите команда профиля и команда фида правят файлы в одних и тех же пакетах (com/example/ui/, com/example/data/) — git не знает о том, что правки логически независимы. В модульной структуре каждая команда работает в своей директории (:feature:feed/, :feature:profile/) — пересечений по пути к файлам становится меньше, и git автоматически разрешает большинство изменений без конфликта. Не потому что файлов стало меньше, а потому что они перестали лежать в одном месте.
  • Вы хотите поставлять отдельные фичи через Dynamic Delivery или управлять их включением без пересборки всего приложения. Dynamic Delivery позволяет скачивать модули по запросу — например, редко используемый экран настроек доставляется только тем пользователям, которые его открыли. Это сокращает размер базового APK и ускоряет первый запуск. Фиче-флаги без пересборки реализуются через условную регистрацию модулей в :app — флаг включает или исключает :impl из графа зависимостей при компиляции.

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

Анатомия модульного проекта

Модуль — это отдельный Gradle-проект, который компилируется независимо и экспортирует только то, что явно объявлено публичным.

Типовая структура:

:app
:feature:feed:api
:feature:feed:impl
:feature:profile:api
:feature:profile:impl
:domain
:core:network
:core:database
:core:ui
:core:navigation

Каждая фича разбита на два модуля — это не усложнение ради усложнения, а механизм, который делает изоляцию между фичами структурно контролируемой, а не договорной. Инструменты (Module Graph Assert, CI) делают нарушения видимыми и блокируемыми — при условии, что они настроены.

Паттерн :feature:api / :feature:impl

Типичная ошибка в первых реализациях модульности — фича как единый модуль. Проблема возникает, как только двум фичам нужно взаимодействовать: :feature:feed хочет открыть экран профиля из :feature:profile. Если :feature:feed зависит от :feature:profile целиком — вы получаете связанность, которую хотели устранить.

Циклические зависимости при такой схеме возникают не автоматически, но давление в их сторону нарастает: как только команд становится больше двух, каждая начинает тянуть зависимости от соседних фич напрямую — это проще, чем договариваться о контракте. Без структурного ограничения это вопрос дисциплины, а дисциплина под давлением дедлайнов ломается.

Решение — разбить фичу на два модуля:

:feature:profile:api — только публичный контракт: интерфейсы, навигационные роуты, внешние модели данных. Никакой реализации.

// :feature:profile:api
interface ProfileNavigator {
    fun openProfile(userId: String, navController: NavController)
}

// Строковые роуты — пример контракта для проектов до Compose Navigation 2.8.
// При type-safe подходе роуты переезжают в :core:navigation —
// см. раздел «Навигация» ниже и объяснение, почему именно туда.
object ProfileRoutes {
    const val PROFILE = "profile/{userId}"
    fun profile(id: String) = "profile/$id"
}

:feature:profile:impl — реализация: экраны, ViewModel, UseCase. Зависит от своего :api-модуля. Repository может жить здесь, если данные нужны только этой фиче, или в :core:data — если к тем же данным обращаются несколько фич (например, UserRepository используется и профилем, и фидом).

// :feature:profile:impl
class ProfileNavigatorImpl @Inject constructor() : ProfileNavigator {
    override fun openProfile(userId: String, navController: NavController) {
        navController.navigate(ProfileRoutes.profile(userId))
    }
}

Теперь :feature:feed:impl зависит только от :feature:profile:api — лёгкого модуля без единого экрана или ViewModel. Изменения внутри реализации профиля не инвалидируют кеш фида.

Возврат результата между фичами

Возврат результата между фичами — задача, которую паттерн :api/:impl сам по себе не решает. Если :feature:feed открывает экран выбора в :feature:profile и ждёт результата, есть два рабочих подхода:

  1. SharedViewModel на уровне :app. Граф Hilt позволяет создать ViewModel, которая видна нескольким фичам через ActivityComponent. Фид помещает запрос в StateFlow, профиль читает его и возвращает результат через тот же поток. Обе фичи зависят только от контракта в :core или :app — не друг от друга.
  2. Callback-интерфейс в :api. :feature:profile:api объявляет интерфейс ProfileResultCallback, :feature:feed:impl реализует его и передаёт при навигации через DI:
// :feature:profile:api
interface ProfileResultCallback {
    fun onProfileSelected(userId: String)
}

// :feature:feed:impl
class FeedToProfileCallback @Inject constructor(
    private val feedViewModel: FeedViewModel
) : ProfileResultCallback {
    override fun onProfileSelected(userId: String) {
        feedViewModel.onProfileSelected(userId)
    }
}

Этот подход более явный — зависимость видна в коде, — но усложняет граф при большом числе взаимодействий между фичами.

Первый подход проще масштабируется по конкретной причине: при N взаимодействиях между фичами SharedViewModel остаётся одним классом — фичи добавляют StateFlow в существующий граф. При подходе с callback-интерфейсами каждое взаимодействие добавляет новый интерфейс в :api и новую реализацию в :impl — граф DI растёт линейно. При 5–10 взаимодействиях это становится заметным, при 20+ — трудно поддерживаемым.

Второй подход оправдан там, где важна явная трассируемость каждого взаимодействия: когда несколько команд договариваются о контракте и хотят видеть его зафиксированным в коде.

Слои и граф зависимостей

В Android-контексте нарушение принципа «зависимости идут строго вниз» стоит дорого в буквальном смысле. Если :feature:feed:impl зависит от :feature:profile:impl, а не от :api, изменение любого экрана профиля инвалидирует кеш фида. На проекте с 30+ модулями одна неверная зависимость может удвоить время инкрементальной сборки.

Правильный граф выглядит так:

:app
  ↓
:feature:*:impl
  ↓
:feature:*:api
  ↓
:domain
  ↓
:core:*

Ключевое правило: каждый :feature:*:impl зависит только от :api соседних фич, не от их :impl. :domain содержит UseCase и domain-модели, нужные нескольким фичам. :core:* — инфраструктура без бизнес-логики.

:domain — отдельный горизонтальный слой между фичами и инфраструктурой. Сюда попадают UseCase и доменные модели, которые нужны нескольким фичам одновременно.

Пример: OrderSummaryUseCase зависит от UserRepository (нужен профиль пользователя) и CartRepository (нужна корзина). Оба репозитория используются разными фичами. Решение — интерфейсы UserRepository и CartRepository объявляются в :domain, их реализации живут в :core:data. UseCase в :domain зависит от интерфейсов, не от реализаций. Ни одна фича не видит детали другой — только :domain-контракты.

Для автоматического контроля графа используйте Module Graph Assert:

// build.gradle.kts (root)
moduleGraphAssert {
    maxHeight = 4
    denied = [":feature:.*:impl --> :feature:.*:impl"]
}

Это поймает прямые зависимости между impl-модулями на этапе CI, до ревью.

Dependency Guard — дополняющий инструмент с другой задачей. При первом запуске он создаёт baseline — снимок дерева зависимостей каждого модуля. При следующем запуске сравнивает с baseline и падает с ошибкой, если обнаруживает новую зависимость. Разработчик обязан явно обновить baseline через отдельную Gradle-таску, что фиксирует изменение в git — и ревьюер видит его в PR. Это делает случайное добавление транзитивных зависимостей заметным, а не скрытым.

Module Graph Assert отвечает за форму графа (кто от кого зависит и в каком направлении); Dependency Guard — за то, что новые зависимости не появляются без осознанного решения.

api vs implementation

implementation скрывает зависимость от вышестоящих модулей, api — делает её видимой транзитивно.

Последствие конкретное: если :feature:feed:impl использует api(project(":core:network")) вместо implementation, то :app начинает транзитивно видеть типы из :core:network. При следующем изменении в :core:network Gradle инвалидирует кеш не только :feature:feed:impl, но и всех, кто «видит» :core:network через feed. На проекте с 20 фичами это может означать перекомпиляцию 10–15 модулей вместо одного.

При стабильном ABI (публичный интерфейс зависимости не меняется) api не вредит сборке. Используйте его только когда вышестоящий модуль действительно должен видеть типы из зависимости в своём публичном интерфейсе — это осознанное решение, а не выбор по умолчанию.

Dependency Injection: Hilt и overhead на сборку

Hilt — наиболее распространённый выбор. Но у него есть специфическая проблема в многомодульных проектах: annotation processing запускается в каждом модуле параллельно, и при использовании KAPT это становится заметным overhead на сборку. Чем больше модулей, тем ощутимее.

KSP вместо KAPT — прямой ответ на эту проблему. На практике миграция снижает время annotation processing в диапазоне 30–50% — конкретная цифра зависит от числа модулей и интенсивности использования аннотаций. Измерить эффект на своём проекте можно через Build Scan: запустить сборку до миграции и после, сравнить время шага annotation processing в отчёте.

// build.gradle.kts
plugins {
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}

Каждый модуль объявляет свои @Module:

// :feature:profile:impl
@Module
@InstallIn(SingletonComponent::class)
abstract class ProfileModule {
    @Binds
    abstract fun bindProfileNavigator(impl: ProfileNavigatorImpl): ProfileNavigator
}

Граф собирается в :app автоматически — Hilt находит все @Module через annotation processing.

Важное ограничение: при очень большом числе модулей (50+) Hilt может стать узким местом не только из-за KSP, но и из-за генерации компонентов — особенно если много @Provides с широким скоупом. По схожим соображениям Now in Android от Google частично перешёл на ручной DI: количество модулей и компонентов сделало автоматическую генерацию заметной статьёй в профиле сборки. Это не стандартный путь, но если Build Scan показывает, что генерация Hilt-компонентов доминирует в профиле — это стоит рассмотреть.

Ускорение сборки: диагностика и настройка

Прежде чем что-то менять — выясните, что именно тормозит. Gradle Build Scan даёт для этого веб-отчёт:

./gradlew assembleDebug --scan

После выполнения команда выводит ссылку. В отчёте — timeline всех тасок: какие выполнялись параллельно, какие ждали, какие взяли результат из кеша, а какие нет, и сколько времени занял annotation processing по каждому модулю отдельно.

Это позволяет ответить на вопрос точно: «annotation processing в :feature:feed съел 40 секунд, потому что KAPT не мигрирован» или «:core:database перекомпилировался, потому что :feature:orders использует api() вместо implementation()». Без этих данных любые настройки — угадывание.

Для корпоративных проектов с CI-инфраструктурой Gradle предлагает Develocity (бывший Gradle Enterprise) — он хранит историю сборок и позволяет сравнивать профили между агентами и между ветками.

После диагностики — настройка:

# gradle.properties
org.gradle.parallel=true
org.gradle.caching=true

# Включать только после аудита всех плагинов на совместимость:
org.gradle.configuration-cache=true

Важно про Configuration Cache: он несовместим с рядом Gradle-плагинов — в частности, старые версии Hilt Gradle Plugin и некоторые кастомные таски его не поддерживают. Включение без предварительного аудита всех плагинов в проекте с высокой вероятностью сломает сборку. Это не «настройте и забудьте» — это опция, которую включают после явной проверки совместимости каждого плагина.

Convention Plugins: когда они окупаются

Convention plugins — подходящее решение, но не бесплатное. Это отдельный Gradle-проект (build-logic/), который нужно написать, поддерживать и обновлять при каждом апгрейде AGP или Kotlin.

Написание базового набора плагинов занимает 3–5 дней для разработчика, знакомого с Gradle API. Для команды из 2–3 человек без выделенного инфраструктурного инженера это ощутимые затраты. Оправдано, когда модулей больше 15–20 и копипаста в build.gradle стала заметной проблемой. До этого порога — преждевременная оптимизация.

// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin {
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("com.android.library")
        pluginManager.apply("org.jetbrains.kotlin.android")
        configureKotlinAndroid()
        configureAndroidCompose()
    }
}

// Регистрация в build-logic/convention/build.gradle.kts.
// Именно здесь задаётся id "convention.android.feature",
// который потом используется в модулях.
gradlePlugin {
    plugins {
        register("androidFeature") {
            id = "convention.android.feature"
            implementationClass = "AndroidFeatureConventionPlugin"
        }
    }
}

После регистрации — применение в любом модуле одной строкой:

plugins {
    id("convention.android.feature")
}

Более продвинутая альтернатива — composite builds через includeBuild. Вместо того чтобы build-logic был частью того же проекта, он подключается как полностью изолированная сборка:

// settings.gradle.kts
includeBuild("build-logic")

Это позволяет разрабатывать и тестировать build-logic независимо, переиспользовать его в нескольких проектах и избежать проблем с кешированием, которые иногда возникают при изменении convention plugins внутри того же проекта.

Для небольших команд разница незначительна, для крупных проектов с выделенной инфраструктурной командой — composite build предпочтительнее.

Тестирование: что реально меняется

В монолите тест на ViewModel часто тянет за собой половину приложения через транзитивные зависимости в classpath — даже если ViewModel напрямую использует только один репозиторий, Gradle видит всё дерево зависимостей :app и включает его в тест. Это замедляет компиляцию тестов и затрудняет изоляцию: приходится мокировать объекты, которые тест не должен знать.

В модульном проекте :feature:profile:impl видит только то, от чего явно зависит — и это единственное, что попадает в classpath теста.

Фейковые реализации выносятся в отдельный модуль :core:testing, который подключается через testImplementation. Каждый :feature подключает :core:testing и получает готовые фейки — без зависимости от реальной сети или базы данных, и без дублирования кода между модулями.

Вот пример фейка для UserRepository, который используется в тестах :feature:profile:

// :core:testing
class FakeUserRepository : UserRepository {
    private val users = mutableMapOf()
    var shouldFailNextCall = false

    override suspend fun getUser(userId: String): Result =
        if (shouldFailNextCall) Result.failure(IOException("network unavailable"))
        else Result.success(users[userId] ?: error("User $userId not found"))

    override suspend fun updateProfile(userId: String, name: String): Result {
        if (shouldFailNextCall) return Result.failure(IOException("timeout"))
        users[userId] = users[userId]?.copy(name = name) ?: error("User $userId not found")
        return Result.success(Unit)
    }

    fun seedUser(user: User) {
        users[user.id] = user
    }
}

// :feature:profile:impl — build.gradle.kts
dependencies {
    testImplementation(project(":core:testing"))
}

Взаимодействие через SharedViewModel тестируется иначе — без Hilt. Тест создаёт SharedViewModel напрямую, запускает корутину, имитирующую действие источника (например, фид отправляет запрос), и проверяет StateFlow на принимающей стороне:

@Test
fun `feed request triggers profile response`() = runTest {
    val sharedVm = NavigationSharedViewModel()

    // Фид инициирует запрос
    sharedVm.requestProfileSelection(requesterId = "feed")

    // Профиль возвращает результат
    sharedVm.onProfileSelected(userId = "user-42")

    // Проверяем, что результат получен
    val result = sharedVm.profileSelectionResult.first()
    assertEquals("user-42", result.userId)
}

Обе фичи здесь заменены прямыми вызовами — сам SharedViewModel тестируется изолированно, без зависимостей от :feature:feed или :feature:profile.

Границы этого подхода: фейки в :core:testing покрывают инфраструктурные интерфейсы из :domain и :core. Если два :feature:*:impl взаимодействуют через :api-контракт, фейк для этого взаимодействия делается в :core:testing отдельно. Не все зависимости можно зафейкать через один общий модуль — но большинство инфраструктурных (сеть, база, аналитика) — да.

Рефакторинг монолита: с чего начать

Распространённый совет «начните с :core:network» — интуитивно понятный, но не оптимальный. Инфраструктурные модули не дают команде параллелизма в разработке — они просто перекладывают код. Максимальный прирост от модульности даёт выделение фичей: именно фичевые границы позволяют командам работать независимо и снижают время инкрементальной сборки там, где изменения происходят чаще всего.

Перед тем как выбрать первую фичу для выделения, нужно понять, насколько она изолирована. Практический инструмент: запустите dependency-analysis плагин (Gradle Dependency Analysis Plugin) и посмотрите, какие пакеты импортируют файлы предполагаемой фичи. Альтернатива — простой grep:

# Найти все импорты из других фич в пакете feature.profile
grep -r "import com.example.feature" feature/profile/src --include="*.kt" \
  | grep -v "feature.profile"

Если вывод пустой или минимальный — фича хорошо изолирована. Если grep возвращает 50 строк с импортами из feature.feed, feature.cart и feature.notifications — это не первый кандидат на выделение.

Практическая последовательность:

  1. Создайте build-logic/ с базовыми convention plugins — до появления большого числа модулей, иначе придётся переписывать все build.gradle задним числом.
  2. Создайте :domain — перенесите UseCase и доменные модели, которые уже сейчас нужны нескольким местам.
  3. Выделите первую фичу в формате :api + :impl — ту, которую правит одна команда и которая по результатам grep наименее связана с остальными. Это даст немедленный параллелизм в разработке.
  4. Выделите :core:network и :core:database. После того как фичевые границы обозначены, место инфраструктуры становится очевидным технически: :core:network нужен нескольким фичам, значит, он не может жить внутри одной из них. Это не метафора — это прямое следствие графа зависимостей.
  5. Подключите Module Graph Assert и Gradle Build Scan — до того, как граф стал сложным.
  6. Двигайтесь фичу за фичей, не пытаясь мигрировать всё за один спринт. Гибридное состояние — когда часть кода ещё в монолитном :app, а часть уже в модулях — это ожидаемый временный компромисс, который может растянуться на несколько месяцев. Его не нужно ускорять в ущерб стабильности: каждая новая граница модуля должна быть осознанной, а не продиктованной желанием «закончить миграцию».

Реальные компромиссы

Что усложняется при переходе на модули:

  • Время первоначальной настройки — 1–3 недели на создание build-logic, базовых :core модулей и первого :feature.
  • Gradle sync становится медленнее при большом числе модулей: каждый модуль добавляет overhead на конфигурацию, Gradle должен разрешить граф зависимостей и выполнить конфигурационную фазу для каждого из них. При 80+ модулях sync может занимать 30–60 секунд даже на мощном железе. Основной рычаг — не дробить модули избыточно: если :feature:profile:repository, :feature:profile:ui и :feature:profile:di всегда меняются вместе, выделение их в отдельные модули добавляет overhead sync без выгоды инкрементальности. Дополнительно помогают Configuration Cache и поддержание активного Gradle Daemon.
  • Build Variants и Product Flavors — отдельная сложность. Когда у вас 50 модулей и каждый может иметь debug/release/staging конфигурацию, управление flavor-матрицей через convention plugins становится обязательным, а не опциональным. Без централизованной конфигурации расхождения между модулями в одном и том же flavor — вопрос времени.
  • Рефакторинг границ — если вы ошиблись с делением ответственности между модулями, перемещение кода болезненнее, чем в монолите. Это весомый аргумент в пользу того, чтобы тщательно проверять изолированность фичи перед выделением, а не резать по живому.
  • Онбординг — новому разработчику сложнее понять, где что лежит и почему. Этот минус снижается через документацию архитектурных решений (ADR), понятные именования модулей и README в каждом :feature, где описан его публичный контракт и ограничения.

Большинство из этих проблем не являются аргументами против модульности на масштабе — там альтернатива хуже. Но все они — аргументы за то, чтобы не торопиться с переходом раньше, чем он реально нужен. На небольших проектах некоторые из этих минусов (например, рефакторинг границ) перевешивают пользу.

Итог

Модульная архитектура — это управление сложностью через явные границы. Главный вопрос не «как» это сделать, а «когда» и «где провести границы».

Паттерн :api/:impl — инструмент с измеримым эффектом: он определяет, какие изменения в одном модуле вызывают перекомпиляцию в другом. Граф, где каждый :feature:*:impl видит только :api соседних фич и :domain-контракты, делает работу одной команды независимой от сборки другой.

Convention plugins и composite builds — способ сохранить этот граф управляемым по мере роста числа модулей. Они окупаются после 15–20 модулей; до этого — лишняя сложность.

Архитектурные границы имеют долгосрочные последствия: неверно выбранная граница между модулями — это месяцы болезненного рефакторинга.

С чего начать прямо сейчас: запустите ./gradlew assembleDebug --scan и изучите отчёт. Если время сборки ниже 90 секунд — ничего менять не нужно. Если выше — найдите в отчёте, что именно тормозит: KAPT в конкретном модуле, нарушение кеша из-за неверного api/implementation, отсутствие параллелизма. Если Build Scan показывает, что проблемы нет — значит, её нет. Когда она появится, у вас уже будет понимание, что именно менять и в какой последовательности.