---
title: "Version Catalogs and Convention Plugins: Scaling Gradle for Multi-Module Android Projects"
description: "Version catalogs centralize dependency declarations; convention plugins extract shared build logic. Together they end copy-paste config in multi-module Android."
url: https://android-x.com/blog/version-catalogs-convention-plugins
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-04-04
updated: 2026-10-11
category: "Build Tools"
reading_time: "13 min read"
---

# Version Catalogs and Convention Plugins: Scaling Gradle for Multi-Module Android Projects

> Version catalogs centralize dependency declarations; convention plugins extract shared build logic. Together they end copy-paste config in multi-module Android.

By Rocky Elsalaymeh. Published 2026-04-04, updated 2026-10-11. Canonical page: https://android-x.com/blog/version-catalogs-convention-plugins

## The Multi-Module Build Configuration Problem

Every Android project that grows past one module hits the same wall: duplicated build configuration. Each module declares the same compileSdk, the same Java level, the same test dependencies. Someone copies a working `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](https://docs.gradle.org/current/userguide/implementing_gradle_plugins_convention.html)).

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](https://android-x.com/blog/android-modularization-guide); none of this pays off in a single module.

## What does a Gradle version catalog give you?

A version catalog is a TOML file. Put it at `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](https://docs.gradle.org/current/userguide/version_catalogs.html)): `[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.

```toml
# 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

A catalog covers dependencies. Everything else that repeats, compileSdk, minSdk, Java compatibility, the test runner, belongs in a convention plugin.

Gradle documents two homes for one. `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](https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html)). Google's Now in Android sample uses the same layout ([settings.gradle.kts](https://github.com/android/nowinandroid/blob/main/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](https://docs.gradle.org/current/userguide/implementing_gradle_plugins_precompiled.html)). 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](https://github.com/android/nowinandroid/blob/main/build-logic/settings.gradle.kts).
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](https://developer.android.com/build/migrate-to-built-in-kotlin)). 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](https://developer.android.com/build/releases/gradle-plugin)).

```kotlin
// 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

With both pieces in place, a feature module's build file states only what is particular to that module: its namespace and its own dependencies. The shared decisions are applied by id.

The structural gain is that a decision now lives in one file. Raising compileSdk is one edit to `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](https://docs.gradle.org/current/userguide/declaring_dependencies_basics.html)).

```kotlin
// 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

Neither tool needs a rewrite of the build in one pull request. The migration has four phases, and each can merge alone.

**Phase 1: the catalog only.** Create `gradle/libs.versions.toml` and move one module's dependencies to catalog accessors. Android's own [migration guide](https://developer.android.com/build/migrate-to-catalogs) 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](https://android-x.com/blog/github-actions-android-cicd) 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](https://android-x.com/blog/gradle-build-optimization-android) covers measuring that.

## Key takeaways

- A version catalog (gradle/libs.versions.toml) has four sections: versions, libraries, bundles and plugins. Modules reach it through generated libs accessors.
- A catalog makes every module request the same version. It does not enforce what Gradle resolves; a platform or BOM does that.
- A convention plugin holds the build logic that repeats across modules, and the simplest form is a precompiled script plugin named after its plugin id.
- The build-logic included build does not inherit the catalog. Import gradle/libs.versions.toml in its own settings file.
- AGP 9 compiles Kotlin itself. Remove the kotlin-android plugin and the jvmTarget block from Android convention plugins.
- Adopt in phases: catalog, one convention plugin, the rest, then a CI check that stops drift from coming back.

## Frequently asked questions

### 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.

## Related articles

- [Gradle Build Optimization: Where Android Build Time Actually Goes](https://android-x.com/blog/gradle-build-optimization-android.md)
- [Android Modularization: Structuring Large-Scale Apps for Speed](https://android-x.com/blog/android-modularization-guide.md)
- [Clean Architecture on Android: Building a Scalable Domain Layer](https://android-x.com/blog/clean-architecture-android.md)
