Componentization Gradle Dependency Governance: Version Catalogs, api/implementation Boundaries, and Eradicating Circular Dependencies
When an Android project evolves from a single module to a componentized architecture with 20, 50, or even hundreds of modules, dependency management upgrades from a mere "configuration detail" to a full-blown "architectural challenge." Module A uses Retrofit 2.9, while Module B is stuck on 2.7; core:network upgrades an internal dependency, triggering a recompilation of 15 downstream modules; two feature modules depend on each other, causing the build to fail entirely. These are not isolated bugs, but systemic symptoms of absent dependency governance.
This article explores the internal mechanisms of the Gradle dependency system, providing a deep dive into three core subjects: the compiler-level principles of scope configurations like api and implementation, the operational mechanics and best practices of Version Catalogs, and architectural solutions to eradicate circular dependencies permanently.
The Underlying Model of Gradle Dependencies: Configuration and Classpath
To truly understand the difference between api and implementation, one cannot stop at the superficial explanation of "one is transitive, one is not." You must first understand the underlying abstraction of Gradle's dependency system: Configuration.
Configuration: The Dependency "Pipeline"
In Gradle's internal model, Configuration is a central concept. It is neither a simple list nor a folder—it acts more like a pipeline connecting "dependency declarations" with "build tasks."
Think of a Configuration as a pipe network in a water supply system.
implementation,api, andcompileOnlyare different inlets (declarative pipelines) where developers pour dependencies.compileClasspathandruntimeClasspathare the outlets (resolvable pipelines) where the compiler and runtime draw the required libraries. The connection between inlets and outlets is managed via theextendsFrommechanism—which inlet's water flows to which outlet determines the visibility of a dependency across different phases.
Gradle divides Configurations into two categories:
| Type | Role | Attribute | Examples |
|---|---|---|---|
| Declarable | For developers to declare dependencies | canBeResolved = false |
implementation, api, compileOnly, runtimeOnly |
| Resolvable | For build tasks to consume dependencies | canBeResolved = true |
compileClasspath, runtimeClasspath |
Developers never interact directly with Resolvable Configurations—they are automatically assembled behind the scenes via extendsFrom by Gradle plugins (e.g., java-library, com.android.library).
extendsFrom: Topological Pipeline Connections
Gradle's Java Library plugin uses extendsFrom to construct the following topology:
Declarable Pipelines Resolvable Pipelines
compileOnly ─────────────────→ compileClasspath
↑
api ──────────────────────────→ compileClasspath
│
└──────────────────────────→ runtimeClasspath
↑
implementation ──────────────→ compileClasspath (Visible to current module only)
│
└──────────────────────────→ runtimeClasspath
↑
runtimeOnly ─────────────────→ runtimeClasspath
This topology reveals a core design principle: Dependencies declared with implementation flow into both the compile classpath and runtime classpath of the current module, but they do NOT leak into the compile classpath of downstream modules. This is the precise semantic definition of implementation.
In Gradle source code, this topology is managed by the DefaultConfiguration class. Each Configuration maintains a collection of extendsFrom references. During resolution, it recursively collects dependencies from all upstream pipelines, aggregates them, and performs version conflict resolution.
api vs implementation: The Engineering Power of Compile Isolation
The overview article introduced the basic differences between api and implementation. Here, we explain their impact on compilation behavior in multi-module scenarios from the perspective of the Gradle build engine.
Differences in Compile Classpath
Consider a three-tier dependency chain: Module A → Module B → Module C.
Scenario 1: B uses api(C)
A's compileClasspath: [B's classes] + [C's classes] ← A can see C during compilation
A's runtimeClasspath: [B's classes] + [C's classes] ← A can use C at runtime
→ A's code can directly import C's classes.
→ Any public API change in C triggers a recompilation of A.
Scenario 2: B uses implementation(C)
A's compileClasspath: [B's classes] ← A CANNOT see C during compilation
A's runtimeClasspath: [B's classes] + [C's classes] ← A can still use C at runtime
→ A's code cannot import C's classes (compile error).
→ Internal changes in C will NOT trigger a recompilation of A.
Why does the runtime classpath always contain all dependencies? Because the JVM needs to load all actually utilized classes at runtime. Even if A doesn't reference C directly, B's code will call C at runtime; thus, C's .class files must be present in the final APK. implementation isolates compile-time visibility, not runtime existence.
ABI Change Detection: How implementation Accelerates Builds
Gradle's incremental compilation engine uses ABI (Application Binary Interface) change detection to determine whether downstream modules require recompilation. An ABI represents the "fingerprint" of all public APIs in a module—public classes, public method signatures, public field types, etc.
Source code of C is modified
│
▼
Gradle extracts new ABI fingerprint of C
│
▼
Is the ABI fingerprint different from before?
╱ ╲
Yes No
↓ ↓
B needs recompilation B does NOT need recompilation
│ (Incrementally skipped)
▼
Did B use api(C) or implementation(C)?
╱ ╲
api implementation
↓ ↓
A needs recompilation A does NOT need recompilation
(Because A might be (Because Gradle knows A
referencing C's classes) cannot see C's classes)
In a project with 50 modules, if all modules aggressively use api to pass dependencies transitively, a single modification to a core foundation library could trigger a full recompilation of all 50 modules. Using implementation correctly restricts the blast radius to only directly related modules—this is the fundamental reason why compilation speeds can differ by a factor of 10x in componentized projects.
When MUST You Use api?
The rule is straightforward: You must use api when the parameter type, return type, or inherited base class/interface of your public API originates from a specific dependency.
// :core:network module
// Case 1: MUST use api
// Because the return type `Flow` of getUsers() comes from kotlinx-coroutines.
// Modules depending on :core:network must see Flow's type definition at compile time.
class UserRepository {
fun getUsers(): Flow<List<User>> = flow { /* ... */ }
// ↑ Flow comes from kotlinx-coroutines-core
}
// Case 2: SHOULD use implementation
// Because OkHttp is strictly used internally and not exposed externally.
// Modules depending on :core:network do not need to know that OkHttp is used under the hood.
internal class NetworkClient {
private val client = OkHttpClient() // OkHttp is an internal implementation detail
// ↑ External code cannot see this type
}
The corresponding build.gradle.kts:
// :core:network/build.gradle.kts
dependencies {
// Flow appears in the public API → MUST use api
api(libs.kotlinx.coroutines.core)
// OkHttp is only used within internal classes → Use implementation
implementation(libs.okhttp)
}
Comprehensive Guide to Dependency Configuration Scopes
Beyond api and implementation, Gradle provides a refined set of dependency scopes. Understanding the semantics of each scope is vital for making accurate dependency declarations.
Cross-Comparison of the Six Core Configurations
| Configuration | Added to compileClasspath? | Added to runtimeClasspath? | Bundled into Final APK? | Typical Scenarios |
|---|---|---|---|---|
api |
✅ | ✅ | ✅ | Types exposed in public APIs |
implementation |
✅ (Module only) | ✅ | ✅ | Default choice for most dependencies |
compileOnly |
✅ | ❌ | ❌ | Compile-time annotations, Provided APIs |
runtimeOnly |
❌ | ✅ | ✅ | Logging implementations, Database drivers |
ksp / annotationProcessor |
Compiler Phase Only | ❌ | ❌ | Code generation (Room, Hilt) |
testImplementation |
✅ (Test only) | ✅ (Test only) | ❌ | Testing frameworks (JUnit, Mockk) |
The Exact Semantics of compileOnly
compileOnly tells Gradle: This dependency is solely required during compilation. At runtime, it will be provided by the host environment (or isn't needed at all).
dependencies {
// Lombok: Generates getter/setter code at compile time; Lombok classes aren't needed at runtime.
compileOnly(libs.lombok)
// JSR 305 Annotations (@Nullable, etc.): Used exclusively for static analysis; not needed at runtime.
compileOnly(libs.findbugs.jsr305)
}
A common mistake is declaring the Compose Compiler plugin as compileOnly. The Compose compiler indeed only works at compile time, but it hooks in via the Gradle plugin mechanism (plugins {} block); it does not need to, nor should it, appear in the dependencies block.
The Exact Semantics of runtimeOnly
runtimeOnly is the mirror image of compileOnly—invisible at compile time, but injected at runtime. The typical use case for this configuration is the Interface-Implementation Separation pattern:
dependencies {
// SLF4J API: Reference interfaces at compile time.
implementation(libs.slf4j.api)
// Logback: Inject implementation at runtime; code never references Logback classes directly.
runtimeOnly(libs.logback.classic)
}
ksp vs annotationProcessor vs kapt
The configuration of code generation tools has gone through three generations of evolution:
annotationProcessor (Java APT)
↓
kapt (Kotlin bridge solution: Generates Java stubs first, then runs APT)
↓
ksp (Kotlin-native solution: Directly analyzes Kotlin symbol tables)
| Dimension | annotationProcessor |
kapt |
ksp |
|---|---|---|---|
| Language Support | Java Only | Java + Kotlin | Java + Kotlin |
| Implementation | Java Compiler APT | Gen Java Stubs → APT | Directly analyzes Kotlin Compiler IR |
| Compile Speed | Baseline | 30~50% slower than APT | 2x faster than kapt |
| Incremental Compile | Limited Support | Unsupported (Full run) | Fully Supported (Incremental) |
| Status | Valid for Java projects | Officially in maintenance mode | Recommended Solution |
In Kotlin projects, you should migrate all KSP-compatible libraries from kapt to ksp:
dependencies {
// ✗ OLD: kapt must generate Java stubs, causing slow compilation
// kapt(libs.room.compiler)
// kapt(libs.hilt.compiler)
// ✓ NEW: KSP directly analyzes Kotlin symbols, compiling 2x faster
ksp(libs.room.compiler)
ksp(libs.hilt.compiler)
}
Deep Dive into Version Catalogs: Industrial-Grade Unified Version Management
In a componentized project, having 50 modules hard-coding their own dependency versions is a ticking time bomb. Module A uses Retrofit 2.9.0, while Module B uses Retrofit 2.7.2; at runtime, a bizarre NoSuchMethodError is likely to occur. Version Catalogs are Gradle's centralized antidote to this chaos.
What are Version Catalogs?
Version Catalogs, introduced in Gradle 7.0 (and stabilized in 7.4), provide a centralized dependency version declaration mechanism. They utilize a TOML file—gradle/libs.versions.toml—to serve as the entire project's Single Source of Truth for dependency versions.
Version Catalogs act like a Central Pharmacy. Previously, every department (module) procured its own medicines (dependencies) with different brands, dosages, and expiration dates. With a central pharmacy, all medicines are procured, version-managed, and distributed centrally. Each department only needs to write the medicine's name on their prescription (
build.gradle.kts), and the pharmacy fulfills it automatically.
The Four Sections of the TOML File
libs.versions.toml consists of four distinct sections, each with a specific responsibility:
# ═══════════════════════════════════════════
# [versions] — Version Number Declaration Area
# Centrally manages all version numbers, preventing "different versions of the same library across modules".
# ═══════════════════════════════════════════
[versions]
kotlin = "2.0.21"
agp = "8.7.3"
compose-bom = "2024.12.01"
retrofit = "2.11.0"
okhttp = "4.12.0"
room = "2.6.1"
hilt = "2.52"
coroutines = "1.9.0"
navigation = "2.8.5"
# ═══════════════════════════════════════════
# [libraries] — Library Coordinate Declaration Area
# Maps GAV (Group:Artifact:Version) coordinates to semantic aliases.
# version.ref references variables in the [versions] block for version reuse.
# ═══════════════════════════════════════════
[libraries]
# AndroidX Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version = "2.8.7" }
# Compose BOM: Unifies all versions in the Compose ecosystem via a BOM.
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
# Network Layer
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# Data Layer
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Dependency Injection
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Kotlin Coroutines
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# ═══════════════════════════════════════════
# [bundles] — Dependency Grouping Area
# Packages logically related libraries that are often used together into a single bundle.
# Modules can import the entire bundle in a single line.
# ═══════════════════════════════════════════
[bundles]
compose = ["compose-ui", "compose-material3"]
retrofit = ["retrofit", "retrofit-gson", "okhttp-logging"]
room = ["room-runtime", "room-ktx"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
# ═══════════════════════════════════════════
# [plugins] — Plugin Declaration Area
# Centrally manages Gradle plugin IDs and versions.
# ═══════════════════════════════════════════
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.28" }
Internal Working Mechanism: From TOML to Type-Safe Accessors
The magic of Version Catalogs is that once the text-based TOML file is defined, Gradle automatically generates type-safe Kotlin accessors, providing full IDE auto-completion and compile-time validation.
The internal workflow operates as follows:
gradle/libs.versions.toml
│
▼
① Gradle parses TOML during the settings phase
└─ Constructs internal VersionCatalog model
│
▼
② Alias Normalization
└─ kebab-case (retrofit-gson)
→ dot-notation (libs.retrofit.gson)
└─ Delimiters (-, _, .) are universally converted to property access hierarchies
│
▼
③ Code Generation Phase (Configuration Phase)
└─ Generates LibrariesForLibs class (Type-safe accessors)
└─ Each alias maps to a property method
└─ Returns MinimalExternalModuleDependency object
│
▼
④ Consumed in the module's build.gradle.kts
└─ libs.retrofit → "com.squareup.retrofit2:retrofit:2.11.0"
└─ libs.bundles.room → [room-runtime, room-ktx]
└─ Full IDE auto-completion + compile-time type checking
Why choose the TOML format? TOML (Tom's Obvious Minimal Language) is designed to map naturally to hash tables. Compared to JSON, it supports comments; compared to YAML, its rules are simple and less error-prone. The Gradle team chose TOML precisely because the data structure for dependency version management inherently suits key-value pair mapping.
Using Version Catalogs in Modules
With the TOML file in place, the build.gradle.kts of individual modules becomes incredibly streamlined:
// :feature:order/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
}
android {
namespace = "com.example.feature.order"
}
dependencies {
// Single library reference
implementation(libs.androidx.core.ktx)
// Bring in a related set of dependencies simultaneously via bundles
implementation(libs.bundles.compose)
implementation(libs.bundles.retrofit)
// Align all versions across the Compose ecosystem using a BOM
implementation(platform(libs.compose.bom))
// Dependency Injection
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Room Database
implementation(libs.bundles.room)
ksp(libs.room.compiler)
}
BOM (Bill of Materials): Ecosystem-Wide Version Alignment
While Bundles solve the problem of "importing a set of libraries together," BOMs resolve the issue of "ensuring the versions of a set of libraries are mutually compatible."
dependencies {
// Import Compose BOM as a platform
// The BOM itself is not added as a dependency; it merely provides version constraints.
implementation(platform(libs.compose.bom))
// No need to specify versions when importing Compose libraries—the BOM dictates them.
implementation("androidx.compose.ui:ui") // Version determined by BOM
implementation("androidx.compose.material3:material3") // Version determined by BOM
implementation("androidx.compose.ui:ui-tooling-preview") // Version determined by BOM
}
The internal mechanism of BOM in Gradle operates like this: platform() declares the BOM as a Virtual Platform. The versions declared in its <dependencyManagement> section are injected into the resolution process as Dependency Constraints. The priority of these constraints is lower than explicitly declared versions, but higher than versions carried over by transitive dependencies.
Version constraints declared by BOM
↓
┌─────────────────────────────────────────────────────────────┐
│ Gradle Resolution Engine │
│ │
│ Inputs: │
│ ① Dependencies and versions declared directly by the module │
│ ② Versions carried by transitive dependencies │
│ ③ Version constraints injected by the BOM │
│ │
│ Rules (Priority from Highest to Lowest): │
│ 1. strictly() forced versions │
│ 2. Versions directly declared by the module │
│ 3. BOM constraints │
│ 4. Transitive dependency versions (choosing the highest) │
└─────────────────────────────────────────────────────────────┘
The Gradle Dependency Resolution Engine: The Conflict Resolution Algorithm
When multiple modules declare different versions of the same library, Gradle must select a single final version. This decision process is executed by the Dependency Resolution Engine.
Default Strategy: "Newest Wins"
Gradle's default conflict resolution strategy is to select the highest version present in the graph (Newest Wins).
:feature:login ──→ retrofit:2.9.0
:feature:order ──→ retrofit:2.11.0
:core:network ──→ retrofit:2.10.0
Gradle Resolution Result: retrofit:2.11.0 (Newest Wins)
This strategy rests on the assumption that higher versions are generally backward compatible. This holds true in most cases, but when a newer version introduces breaking changes, your :feature:login module might crash at runtime because its code was authored against the 2.9.0 API.
Rich Version Declaration: Fine-Grained Version Control
Gradle provides a "Rich Version Declaration" syntax, enabling significantly finer control than merely writing a flat version string:
dependencies {
implementation("com.squareup.retrofit2:retrofit") {
version {
// strictly: The strongest constraint—MUST use this range, otherwise the build fails.
// Used to lock versions or block incompatible newer versions.
strictly("[2.9, 2.12[") // ≥ 2.9 AND < 2.12
// require: Standard constraint—requires at least this version, but allows higher.
require("2.11.0")
// prefer: Soft constraint—prefers this version, but doesn't mandate it.
prefer("2.11.0")
// reject: Excludes a specific version blacklist.
reject("2.10.0") // Known bug in this specific version
// because: Documents the reason for the constraint—shows up in error logs.
because("2.10.0 has a serialization bug, and 2.12+ breaks API compatibility")
}
}
}
The priority relationship among the four constraints:
strictly > require > prefer > Automatic Resolution (Newest Wins)
strictly ──→ "Must be exactly" (Violation causes build failure)
require ──→ "Must be at least" (Permits higher, but strictly prohibits lower)
prefer ──→ "Would be nice to have" (Treated purely as a hint)
reject ──→ "Absolutely not" (Rejects blacklisted versions)
Dependency Constraints
Aside from controlling versions within specific dependency declarations, Gradle also supports enforcing universal version limits via a Constraints block:
dependencies {
constraints {
// Enforce a global minimum OkHttp version across all modules
implementation("com.squareup.okhttp3:okhttp:4.12.0") {
because("4.11.0 contains a connection leak issue")
}
}
}
The difference between a constraint and a direct dependency is that a constraint does not introduce a dependency itself; it merely influences version selection if the dependency has already been pulled in. This is highly effective for centrally managing the versions of transitive dependencies within the root project or a Convention Plugin.
dependencyInsight: The X-Ray of Dependency Resolution
When a dependency version isn't resolving as expected, dependencyInsight is your most powerful diagnostic tool:
# Inspect how the Retrofit version was resolved
./gradlew :feature:order:dependencyInsight \
--dependency retrofit \
--configuration compileClasspath
The output yields a comprehensive "judgment report" showing who requested what version, which version was ultimately chosen, and why:
com.squareup.retrofit2:retrofit:2.11.0
variant "apiElements" [
org.gradle.status = release
]
com.squareup.retrofit2:retrofit:2.11.0
\--- compileClasspath
com.squareup.retrofit2:retrofit:2.9.0 -> 2.11.0
\--- project :feature:login
\--- compileClasspath
com.squareup.retrofit2:retrofit:2.10.0 -> 2.11.0
\--- project :core:network
\--- project :feature:order
\--- compileClasspath
The -> arrow vividly depicts the version upgrade path: :feature:login explicitly requested 2.9.0, but it was ultimately resolved to 2.11.0.
Dependency Locking: Ensuring Reproducible Builds
In a CI environment, even if your build.gradle.kts hasn't changed, a build might yield different results if a transitive dependency publishes a new version. Dependency Locking ensures the Reproducibility of builds by recording a complete snapshot of all dependency versions generated during a successful build.
// Enable locking in the root project's build.gradle.kts
dependencyLocking {
lockAllConfigurations()
}
# Generate lock files
./gradlew dependencies --write-locks
# Example of the generated gradle.lockfile content:
# com.squareup.retrofit2:retrofit:2.11.0=compileClasspath,runtimeClasspath
# com.squareup.okhttp3:okhttp:4.12.0=compileClasspath,runtimeClasspath
# ...
Lock files should be committed to Git. Once locking is enabled, if the actually resolved versions diverge from the lock file, the build will fail immediately. This forces developers to consciously update dependencies, preventing them from being ambushed by silent upgrades of transitive dependencies.
Convention Plugins and build-logic: The DRY Principle in Build Configuration
When 50 modules all need to configure the identical compileSdk, minSdk, Kotlin compiler options, and Compose compiler parameters—repeating this code in each module's build.gradle.kts flagrantly violates the DRY (Don't Repeat Yourself) principle. Convention Plugins are the industrial-grade solution to this problem.
The Pitfalls of buildSrc vs. The Advantages of build-logic
Gradle initially recommended the buildSrc directory for sharing build logic. However, in large projects, buildSrc suffers from a fatal flaw: modifying any file inside buildSrc invalidates the entire project's build cache, triggering a full, from-scratch reconfiguration.
build-logic mitigates this issue by leveraging Gradle's Composite Build mechanism:
buildSrc (Legacy approach) build-logic (Modern approach)
│ │
▼ ▼
Treated as a special sub-directory Treated as an independent Gradle build
Compilation artifacts lack cache isolation Imported via includeBuild
Any file change → Full reconfiguration Maintains an independent build cache
File changes → Incremental configuration
The Project Structure of build-logic
Project Root Directory/
├── build-logic/
│ ├── settings.gradle.kts # build-logic's own settings file
│ └── convention/
│ ├── build.gradle.kts # Declares dependencies on AGP and Kotlin plugins
│ └── src/main/kotlin/
│ ├── AndroidLibraryConventionPlugin.kt
│ ├── AndroidApplicationConventionPlugin.kt
│ ├── AndroidComposeConventionPlugin.kt
│ └── KotlinAndroidConventionPlugin.kt
├── gradle/
│ └── libs.versions.toml
├── settings.gradle.kts # Main project's settings file
│ └── pluginManagement {
│ includeBuild("build-logic") ← CRUCIAL: Imports the composite build
│ }
├── feature/
│ ├── login/build.gradle.kts
│ └── order/build.gradle.kts
└── core/
├── network/build.gradle.kts
└── database/build.gradle.kts
Typical Implementation of a Convention Plugin
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
/**
* Convention plugin for Android Library modules.
* Centrally configures compileSdk, minSdk, Kotlin compiler options, etc.
* Library modules only need to apply this plugin, eliminating redundant configuration.
*/
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// Apply Android Library and Kotlin plugins uniformly
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
// Centrally configure Android build parameters
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig {
minSdk = 24
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
// Consumer ProGuard rules — automatically active when referenced by other modules
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
// Centrally configure Kotlin compile options
extensions.configure<KotlinAndroidProjectExtension> {
compilerOptions {
jvmTarget.set(
org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
)
// Treat all warnings as errors — enforces a strict code quality baseline
allWarningsAsErrors.set(true)
// Opt-in for experimental APIs globally
freeCompilerArgs.addAll(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
)
}
}
}
}
}
// build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt
/**
* Convention plugin for Compose.
* Applied additionally to any module utilizing Jetpack Compose.
*/
class AndroidComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// Apply the Kotlin Compose compiler plugin
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
// Configure the Compose Compiler
extensions.configure<ComposeCompilerGradlePluginExtension> {
// Enable Strong Skipping Mode — optimizes Compose recomposition performance
enableStrongSkippingMode.set(true)
// Generate Compose compiler reports (for analyzing recomposition metrics)
reportsDestination.set(
layout.buildDirectory.dir("compose_reports")
)
}
}
}
}
Registering and Using a Convention Plugin
Register it in build-logic/convention/build.gradle.kts:
// build-logic/convention/build.gradle.kts
plugins {
`kotlin-dsl`
}
dependencies {
// Convention Plugins must access AGP and Kotlin Plugin APIs at compile time
compileOnly(libs.android.gradle.plugin)
compileOnly(libs.kotlin.gradle.plugin)
compileOnly(libs.compose.compiler.gradle.plugin)
}
gradlePlugin {
plugins {
register("androidLibrary") {
id = "example.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidApplication") {
id = "example.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidCompose") {
id = "example.android.compose"
implementationClass = "AndroidComposeConventionPlugin"
}
}
}
Once implemented, the build.gradle.kts files inside business modules shrink from dozens of lines to a mere handful:
// :feature:login/build.gradle.kts
plugins {
id("example.android.library") // Apply the Android Library convention
id("example.android.compose") // Overlay the Compose convention
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
}
android {
namespace = "com.example.feature.login"
}
dependencies {
implementation(project(":core:designsystem"))
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}
Circular Dependencies: Causes, Symptoms, and Radical Cures
Circular dependencies are one of the most thorny architectural anti-patterns in componentized projects. When Module A depends on B, and B depends on A, Gradle is mathematically incapable of establishing a valid build sequence, resulting in instantaneous build failures.
The Essence of Circular Dependencies
Before commencing a build, Gradle performs a Topological Sort—aligning all modules into a linear sequence where dependencies must be compiled prior to the modules that require them. If a cycle exists, sorting becomes impossible.
Topological Sort
✓ Sortable (DAG) ✗ Unsortable (Contains Cycle)
A → B → C → D A → B → C
↑ │
└───────┘
Build Order: D → C → B → A Gradle Error: Circular dependency
Three Typical Patterns of Circular Dependencies and Their Cures
Pattern 1: Bidirectional Business Dependencies
Two business modules require each other's functionalities.
:feature:login ←──→ :feature:order
login must check if the user has pending orders
order must redirect the user to the login page if they are unauthenticated
Cure: Interface Submergence + Dependency Inversion
Extract the contract interfaces required by both parties into independent -api modules:
Before Fix: After Fix:
login ←──→ order login ──→ order-api ←── order
↑ │
└── login-api ←────────┘
// :feature:order-api (Pure interface module, no implementations)
interface IOrderService {
suspend fun hasPendingOrder(userId: String): Boolean
}
// :feature:login-api (Pure interface module, no implementations)
interface ILoginNavigator {
fun navigateToLogin(context: Context)
}
// :feature:login depends on order-api (to check orders) and implements login-api (provides login navigation)
// :feature:order depends on login-api (to navigate to login) and implements order-api (provides order services)
The dependency direction becomes strictly unidirectional: Business Module → API Module. API modules do not depend on each other, thereby shattering the cycle.
Pattern 2: Foundation Layer Inter-Referencing
Two foundational core modules call one another.
:core:network ←──→ :core:auth
network needs auth to provide a Token to populate HTTP headers
auth needs network to invoke the login/token-refresh APIs
Cure: Extract Public Contracts to an Even Lower Layer
Before Fix: After Fix:
network ←──→ auth network ──→ core:auth-api
↑ ↑
auth ──→ core:auth-api
│
└──→ network
A more elegant solution employs the Callback/Strategy pattern, defining the abstraction of a Token Provider directly within the network module:
// :core:network module
/**
* Token Provider Interface — defined by 'network', implemented by 'auth'.
* This is a textbook application of the Dependency Inversion Principle (DIP):
* High-level modules define abstractions (network defines the interface),
* low-level modules implement abstractions (auth implements the interface).
*/
interface TokenProvider {
/** Fetch the currently valid Access Token */
suspend fun getAccessToken(): String?
/** Refresh the Token and return the new Token */
suspend fun refreshToken(): String?
}
/**
* Authentication Interceptor — relies on TokenProvider to fetch tokens.
* Retains zero dependency on any concrete classes within the auth module.
*/
class AuthInterceptor(
private val tokenProvider: TokenProvider,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking { tokenProvider.getAccessToken() }
val request = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(request)
}
}
// :core:auth module — implements the TokenProvider interface.
// 'auth' depends on 'network' (for HTTP calls), but 'network' DOES NOT depend on 'auth'.
internal class AuthTokenProvider @Inject constructor(
private val authApi: AuthApi, // Comes from network/Retrofit
private val tokenStore: TokenStore,
) : TokenProvider {
override suspend fun getAccessToken(): String? =
tokenStore.getToken()
override suspend fun refreshToken(): String? {
val newToken = authApi.refresh(tokenStore.getRefreshToken())
tokenStore.saveToken(newToken)
return newToken.accessToken
}
}
The dependency relationship is now: auth → network (Unidirectional). network utilizes the auth module's implementation via the TokenProvider interface callback—no reverse dependency exists.
Pattern 3: Implicit Coupling Induced by Shared Utilities
Two modules both require common utility methods or Data Classes.
Both :feature:a and :feature:b need DateUtils and MoneyUtils.
Direct cross-referencing between them induces a cycle.
Cure: Extract into a Common Module
// :core:common module (Pure Kotlin, zero Android dependencies)
// The ultimate sanctuary for pure utilities, extension functions, and data models.
object DateUtils {
fun formatDate(timestamp: Long): String = /* ... */
}
object MoneyUtils {
fun formatCents(cents: Long): String = /* ... */
}
// Both :feature:a and :feature:b depend solely on :core:common
// There is zero direct dependency between :feature:a and :feature:b
The Ironclad Law of Dependency Direction
In a componentized architecture, the legal flow of dependencies must form a Directed Acyclic Graph (DAG):
Legal Directions (Top to Bottom):
:app (App Shell)
↓
:feature:* (Business Component Layer)
↓
:feature:*-api (Contract Layer)
↓
:core:* (Foundation Component Layer)
Forbidden Directions:
✗ Sibling business components depending on each other.
✗ Lower-layer modules depending on upper-layer modules.
✗ Any relationship chain that forms a closed loop.
Automated Validation of the Dependency Graph
Manually policing dependency directions is unsustainable in massive projects. We can mandate automated verification during CI builds:
// build.gradle.kts (Root Project)
/**
* Custom Task: Validates the legality of inter-module dependency directions.
* Executed during CI builds to prevent violating dependencies from merging into the main branch.
*/
tasks.register("checkModuleDependencies") {
doLast {
val violations = mutableListOf<String>()
subprojects.forEach { project ->
project.configurations
.filter { it.isCanBeResolved }
.forEach { config ->
config.dependencies
.filterIsInstance<ProjectDependency>()
.forEach { dep ->
// Rule: Feature modules cannot depend on other feature modules
if (project.path.startsWith(":feature:") &&
dep.dependencyProject.path.startsWith(":feature:") &&
!dep.dependencyProject.path.endsWith("-api")
) {
violations.add(
"${project.path} → ${dep.dependencyProject.path}"
)
}
// Rule: Core modules cannot depend on feature modules
if (project.path.startsWith(":core:") &&
dep.dependencyProject.path.startsWith(":feature:")
) {
violations.add(
"${project.path} → ${dep.dependencyProject.path}"
)
}
}
}
}
if (violations.isNotEmpty()) {
throw GradleException(
"Illegal module dependencies detected:\n${violations.joinToString("\n")}"
)
}
}
}
Engineering Checklist for Dependency Governance
Synthesizing all the knowledge discussed into an actionable engineering checklist:
Dependency Declaration Standards
| Rule | Rationale | Consequence of Violation |
|---|---|---|
Use implementation by default |
Unless public APIs expose dependency types. | Gratuitous compilation propagation; cratered build speeds. |
Use ksp for code generators |
Replaces the deprecated kapt. |
Build speeds suffer a 30~50% penalty. |
Centralize all versions in libs.versions.toml |
Forbids hard-coded version strings in build.gradle.kts. |
Version fragmentation; runtime NoSuchMethodErrors. |
| Utilize BOMs to manage component ecosystem versions | e.g., Compose BOM, Firebase BOM. | Ecosystem components fall out of sync and break compatibility. |
Use because() to document constraint reasoning |
Especially vital for strictly and reject. |
Future maintainers will lack context on why versions are restricted. |
Architectural Constraint Standards
| Rule | Rationale |
|---|---|
Inter-feature communication must route through -api modules |
Prohibits direct implementation(project(":feature:xxx")). |
| Foundation modules are forbidden from reverse-depending on business modules | :core:* → :feature:* is a strict architectural violation. |
| Detect circular dependencies automatically in CI | Enforced via Custom Tasks or the module-graph plugin. |
| Unify build configurations using Convention Plugins | Bans repetitive configuration of SDK versions across business modules. |
| Dependency Locking must be utilized for CI/Release builds | Guarantees absolute build reproducibility. |
The Dependency Diagnostics Toolbox
| Tool / Command | Use Case |
|---|---|
./gradlew :module:dependencies |
Audits the module's entire dependency tree. |
./gradlew :module:dependencyInsight --dependency xxx |
Traces the resolution path of a specific dependency version. |
./gradlew build --scan |
Generates a comprehensive Gradle Build Scan report. |
| module-graph plugin | Renders a Mermaid diagram of all module dependency relationships. |
./gradlew dependencies --write-locks |
Generates or updates dependency lock files. |
The core ethos of dependency governance can be distilled into a single sentence: Make dependency relationships a conscious architectural decision, rather than an unconscious byproduct of code coupling. implementation enforces compile boundaries, Version Catalogs unify version truth, Convention Plugins eradicate configuration duplication, Dependency Locking safeguards reproducible builds, and automated checks repel architectural rot. Only when these mechanisms operate in unison can they sustain the healthy evolution of a componentized project housing hundreds of modules.