The Multi-Module Build Configuration Problem
build.gradle.kts, edits the module-specific lines and moves on. A year later the files have drifted, and nobody can say which differences are deliberate.
Gradle has one mechanism for each half of the problem. A version catalog answers one question in one file: which version of this library? A convention plugin answers another in one place: what does a library module's build look like? Gradle's own definition of the second: "a plugin that normally configures existing core and community plugins with your own conventions (i.e. default values)" (Gradle).
This article was rewritten in October 2026 against Gradle 9.8.1 and Android Gradle plugin 9.4.0, because AGP 9 changed what a convention plugin for Android should contain. The earlier version applied a plugin that AGP 9 now rejects. If you have not split the app into modules yet, start with the modularization guide; none of this pays off in a single module.What does a Gradle version catalog give you?
gradle/libs.versions.toml and build scripts reach it through the libs object with no further setup. The format is fixed: "The version catalog TOML file has four sections" (Gradle): [versions] for version identifiers, [libraries] for coordinates, [bundles] for groups of libraries and [plugins] for plugin versions.
Instead of implementation("androidx.core:core-ktx:1.19.1") in every module, you write implementation(libs.androidx.core.ktx). Gradle lists the advantages itself: it "generates type-safe accessors for each catalog, enabling autocompletion in IDEs", and "Each catalog is visible to all projects in a build". Aliases map predictably: "Each dash in the alias maps to a dot in the accessor", so ktor-client-core becomes libs.ktor.client.core.
A bundle is a named group. Declare compose once and a feature module pulls the set with implementation(libs.bundles.compose).
One claim made for catalogs is false, and it was made in the earlier version of this article: a catalog does not make version drift impossible. Gradle is explicit: "Version catalogs declare requested versions but do not enforce them. Gradle may still select different versions due to dependency graph conflicts or constraints applied through platforms or other dependency management APIs." A catalog guarantees that every module *asks* for the same version. What the build *resolves* is still decided by the dependency graph. If you need enforcement, that is what a platform or BOM is for, which is why the Compose libraries below carry no version of their own.
The version numbers in the sample are the latest stable releases on 11 October 2026. They are a snapshot, not a tested combination. Check each library's compatibility notes before you copy them.# gradle/libs.versions.toml
[versions]
agp = "9.4.0"
kotlin = "2.4.21"
compose-bom = "2026.09.00"
coroutines = "1.11.0"
hilt = "2.60.1"
room = "3.0.3"
ktor = "3.6.0"
[libraries]
android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.19.1" }
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" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
room-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
turbine = { group = "app.cash.turbine", name = "turbine", version = "1.2.1" }
[bundles]
compose = ["compose-ui", "compose-material3", "compose-ui-tooling-preview"]
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room3", version.ref = "room" }Convention Plugins: Extracting Shared Build Logic
buildSrc is the older. The other is "a dedicated composite build, typically named build-logic": a "Standalone build logic project that defines convention plugins", which the root settings.gradle.kts pulls in as an included build (Gradle). Google's Now in Android sample uses the same layout (settings.gradle.kts).
The simplest convention plugin is a precompiled script plugin: an ordinary .gradle.kts file whose name becomes the plugin id. Per Gradle, a script named code-quality.gradle.kts in src/main/kotlin "would be exposed as the code-quality plugin" (Gradle). There is no class to write and nothing to register.
Three things trip teams up, and Gradle documents all three:
1. The build-logic build does not see your catalog. Gradle says of buildSrc that it "does not automatically inherit the version catalog from the main project", and gives the fix: import the file in the nested settings.gradle.kts. Now in Android does exactly that for build-logic.
2. External plugins must be dependencies of build-logic. Gradle's sample says in as many words that convention plugins do not resolve external plugins automatically and that those plugins must be declared as dependencies of the build-logic project. For Android, that means AGP itself goes in the dependencies block.
3. alias(libs.plugins...) does not work inside the convention plugin. Apply plugins there by id.
What AGP 9 changed. "Android Gradle plugin 9.0 introduces built-in Kotlin support and enables it by default. That means you no longer have to apply the org.jetbrains.kotlin.android (or kotlin-android) plugin" (Android). A convention plugin that still applies it fails with "The 'org.jetbrains.kotlin.android' plugin is no longer required for Kotlin support since AGP 9.0." The Kotlin JVM target block can go too: "you don't need to set kotlin.compilerOptions.jvmTarget because its value defaults to android.compileOptions.targetCompatibility". The result is a shorter plugin than the one this article used to show. AGP 9.4 itself needs Gradle 9.6.0 and JDK 17 as minimums (Android).// settings.gradle.kts (root)
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
// build-logic/settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
// build-logic/build.gradle.kts
plugins {
`kotlin-dsl`
}
dependencies {
// The Android plugin has to be on build-logic's classpath
// before a convention plugin can apply it.
implementation(libs.android.gradle.plugin)
}
// build-logic/src/main/kotlin/android-library-conventions.gradle.kts
// The file name is the plugin id: "android-library-conventions".
plugins {
id("com.android.library")
// No kotlin-android plugin: AGP 9 compiles Kotlin itself.
}
android {
compileSdk = 36
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
// Kotlin's jvmTarget follows targetCompatibility under AGP 9.
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}Wiring It Together: The Feature Module Build File
android-library-conventions.gradle.kts. A new module cannot forget the Java level or the consumer ProGuard file, because nobody is asked to remember them.
Keep the convention plugins small and layered: one for the Android library baseline, one that adds Compose, one that adds Hilt. A pure data module applies the first. A UI feature applies all three. One large plugin with flags for every case recreates the problem in a new location.
Should the plugins be published to a repository? For one codebase, no. An included build keeps the build logic versioned with the code it configures, so a build change and the feature that needs it land in the same commit. Publishing is worth it only when several independent projects must share the same conventions.
The sample uses project(":core:data") on purpose. Gradle's generated projects.core.data accessors are shorter, and Gradle still labels them: "Type-safe project accessors are an incubating feature which must be enabled explicitly. Implementation may change at any time" (Gradle).// feature/search/build.gradle.kts
plugins {
id("android-library-conventions")
id("android-compose-conventions")
id("android-hilt-conventions")
}
android {
namespace = "com.myapp.feature.search"
}
dependencies {
implementation(project(":core:data"))
implementation(project(":core:design-system"))
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coroutines.android)
implementation(libs.ktor.client.core)
testImplementation(libs.coroutines.test)
testImplementation(libs.turbine)
}Migration Strategy: Incremental Adoption
gradle/libs.versions.toml and move one module's dependencies to catalog accessors. Android's own migration guide walks through the syntax. No build logic changes, so a reviewer can confirm that the resolved dependencies are identical before and after.
Phase 2: the build-logic build. Add the included build and one convention plugin, the Android library baseline. Move one module to it. This proves the wiring, including the catalog import, before anything depends on it.
Phase 3: expand. Add the Compose and Hilt plugins and migrate one architectural layer at a time: core modules, then features. Each change is the same diff: delete inline configuration, add one plugin id.
Phase 4: enforce. Once every module uses the convention plugins, fail the pull request when a module's build file sets a convention-managed property such as compileSdk directly. A CI pipeline is the natural place for that check. Without it, drift returns one hurried change at a time.
If the project is still on AGP 8, do the AGP 9 upgrade as a separate step, before or after, never in the same change. Removing the kotlin-android plugin touches every module, and you want that diff to contain nothing else. Shared build logic also changes how much work Gradle can skip; the build optimization guide covers measuring that.Key Takeaways
- 1A version catalog (gradle/libs.versions.toml) has four sections: versions, libraries, bundles and plugins. Modules reach it through generated libs accessors.
- 2A catalog makes every module request the same version. It does not enforce what Gradle resolves; a platform or BOM does that.
- 3A convention plugin holds the build logic that repeats across modules, and the simplest form is a precompiled script plugin named after its plugin id.
- 4The build-logic included build does not inherit the catalog. Import gradle/libs.versions.toml in its own settings file.
- 5AGP 9 compiles Kotlin itself. Remove the kotlin-android plugin and the jvmTarget block from Android convention plugins.
- 6Adopt in phases: catalog, one convention plugin, the rest, then a CI check that stops drift from coming back.
Frequently Asked
What problem do Gradle version catalogs solve?
They put every dependency coordinate and version in one file, gradle/libs.versions.toml, and give build scripts generated, type-safe accessors to it. Every module then requests the same version. They do not enforce the version Gradle finally resolves: the Gradle documentation says a catalog declares requested versions but does not enforce them.
What is a convention plugin?
A Gradle plugin that applies your own defaults to existing plugins, so a module states them once by id. For Android that usually means compileSdk, minSdk, Java compatibility and the test runner. The simplest form is a precompiled script plugin: a .gradle.kts file in a build-logic build whose file name becomes the plugin id.
Do I still apply the kotlin-android plugin in a convention plugin?
Not on Android Gradle plugin 9.0 or later. AGP 9 has built-in Kotlin support, enabled by default, and applying org.jetbrains.kotlin.android now fails with a message that the plugin is no longer required. Remove it from the convention plugin, from every module build file, and from the plugins section of the version catalog.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.