ANDROID-ARCHITECT

AI-powered Android development assistant.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:18
UP00:01
MOD · ARTICLE · BUILD TOOLSS/N · AX-VERSIOPUBLISHED
Build ToolsApr 4, 202613 MIN

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 · Founder & Principal Consultant, Strategia-X

The Multi-Module Build Configuration Problem

Every Android project that grows beyond a single module hits the same wall: duplicated build configuration. Module after module declares the same compileSdk, the same Kotlin version, the same set of testing dependencies, the same proguard rules, the same compose compiler configuration. Developers copy a working build.gradle.kts from an existing module, tweak the module-specific parts, and move on. Within months, the project has 15 modules with 15 slightly different build files — different dependency versions creeping in through inconsistent updates, different compile options accidentally diverging, and a cognitive overhead that makes build maintenance a dreaded chore. Gradle provides two mechanisms that solve this problem at different layers: **version catalogs** centralize dependency declarations (group, artifact, version) into a single file that all modules reference by type-safe accessors, and **convention plugins** extract repeated build logic (compileSdk, kotlin options, compose configuration, test setup) into reusable Gradle plugins that modules apply with a single line. Catalogs solve the "what version of this library?" question. Convention plugins solve the "what does a feature module's build look like?" question. Together, they transform multi-module build configuration from an error-prone copy-paste exercise into a structured, maintainable system. This article walks through implementing both in a real multi-module Android project. By the end, adding a new module requires one build.gradle.kts file with three lines — a plugin application, a module-specific dependency list, and nothing else. All shared configuration lives in exactly one place.

Version Catalogs: Single Source of Truth for Dependencies

A Gradle version catalog is a TOML file (by convention, `gradle/libs.versions.toml`) that declares all dependency coordinates in one location. Modules reference these declarations through generated type-safe accessors instead of hardcoding group:artifact:version strings. The TOML file has three sections: **[versions]** declares version numbers as named variables, **[libraries]** declares dependency coordinates referencing those versions, and **[plugins]** declares Gradle plugin coordinates. This separation lets you update a library's version in one place and have every module that uses it automatically pick up the change. The generated accessors appear under `libs.*` in your build scripts. Instead of writing `implementation("androidx.core:core-ktx:1.15.0")`, you write `implementation(libs.androidx.core.ktx)`. The version is declared once in the TOML file, and the accessor is type-safe — a typo in the accessor name is a compile-time error, not a runtime surprise. IDE autocompletion works on these accessors, making dependency declarations discoverable without opening the TOML file. Version catalogs also support **bundles** — named groups of libraries that are commonly used together. Define a `compose` bundle containing the Compose UI, Material 3, and tooling dependencies, then apply the entire bundle in one line: `implementation(libs.bundles.compose)`. This reduces boilerplate in feature modules that all need the same foundational libraries. The practical impact on a 15-module project: dependency version drift becomes impossible (there is only one source of truth), adding a new dependency requires editing one file instead of fifteen, and Renovate or Dependabot can propose version updates as single-line TOML changes with clear diff visibility.
toml
# gradle/libs.versions.toml

[versions]
agp = "8.8.2"
kotlin = "2.1.10"
compose-bom = "2026.03.00"
coroutines = "1.10.1"
hilt = "2.54.1"
room = "2.7.1"
ktor = "3.1.1"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
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.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", 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" }

[bundles]
compose = ["compose-ui", "compose-material3", "compose-ui-tooling-preview"]
room = ["room-runtime", "room-ktx"]

[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" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room", version.ref = "room" }

Convention Plugins: Extracting Shared Build Logic

Version catalogs solve dependency declaration. Convention plugins solve everything else: compileSdk, minSdk, targetSdk, Kotlin compiler options, Compose compiler configuration, test runner setup, proguard rules, lint checks, and any other build configuration that is repeated across modules. A convention plugin is a Gradle plugin defined in a special `build-logic` module (often called `build-logic` or `convention-plugins`) that lives inside your project. This module is a standalone Gradle project that produces plugins consumed by the rest of your build. Because it is a regular Kotlin project, you get full IDE support, type safety, and the ability to write tests for your build logic. The `build-logic` module uses `kotlin-dsl` and `java-gradle-plugin` plugins. Each convention plugin is a Kotlin class that implements the `Plugin<Project>` interface. Inside the `apply` method, you configure the project exactly as you would in a build.gradle.kts file — but now that configuration is written once and applied to every module that needs it. The key insight is layering. Create focused convention plugins for specific concerns: `AndroidLibraryConventionPlugin` (compileSdk, minSdk, Kotlin JVM target), `ComposeConventionPlugin` (Compose compiler plugin, compose-bom platform), `HiltConventionPlugin` (Hilt plugin + dependencies), `TestConventionPlugin` (JUnit5 + Turbine + coroutines-test). Then feature modules compose the plugins they need: a Compose feature module applies android-library + compose + hilt + test. A pure domain module applies only android-library + test. This layered approach means adding a new feature module requires a build.gradle.kts with roughly 10 lines: plugin applications and module-specific dependencies. All shared configuration — which represents 80-90% of a typical build file — lives in the build-logic module, testable, version-controlled, and impossible to accidentally diverge between modules.
kotlin
// 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

class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("com.android.library")
        pluginManager.apply("org.jetbrains.kotlin.android")

        extensions.configure<LibraryExtension> {
            compileSdk = 36
            defaultConfig {
                minSdk = 26
                testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                consumerProguardFiles("consumer-rules.pro")
            }
            compileOptions {
                sourceCompatibility = JavaVersion.VERSION_17
                targetCompatibility = JavaVersion.VERSION_17
            }
        }

        // Kotlin JVM target
        tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
            compilerOptions {
                jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
                freeCompilerArgs.addAll(
                    "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
                    "-opt-in=kotlin.ExperimentalStdlibApi"
                )
            }
        }
    }
}

// build-logic/convention/build.gradle.kts

plugins {
    `kotlin-dsl`
}

dependencies {
    compileOnly(libs.android.gradle.plugin)
    compileOnly(libs.kotlin.gradle.plugin)
    compileOnly(libs.compose.gradle.plugin)
}

gradlePlugin {
    plugins {
        register("androidLibrary") {
            id = "myapp.android.library"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
        register("androidCompose") {
            id = "myapp.android.compose"
            implementationClass = "ComposeConventionPlugin"
        }
        register("androidHilt") {
            id = "myapp.android.hilt"
            implementationClass = "HiltConventionPlugin"
        }
    }
}

Wiring It Together: The Feature Module Build File

With version catalogs and convention plugins in place, the build.gradle.kts for a new feature module becomes minimal. Every module-common configuration is handled by convention plugins. Every dependency version is handled by the catalog. What remains is module identity and module-specific dependencies. This is the entire build file for a feature module that uses Compose, Hilt, Room, and networking. Compare this to the 60-80 line build files you would write without convention plugins — the difference is not cosmetic; it is structural. When you need to update the compileSdk, you change one file (the convention plugin) instead of 15. When you need to add a Compose compiler option, you change one file. When a new module is created, the developer cannot accidentally forget to set the JVM target or miss a proguard rule, because those decisions are encoded in the convention plugin, not left to individual module authors. The `build-logic` module also enables build logic testing. Write unit tests that verify your convention plugins produce the expected configuration: assert that the compile SDK is correct, that the expected compiler flags are present, that test dependencies are included. This is especially valuable for large teams where build configuration changes can have subtle, project-wide impacts. One common question: should you publish convention plugins to a Maven repository or keep them project-local? For most teams, project-local (the `build-logic` included build) is the right choice. It keeps build logic versioned alongside the code it configures, avoids the complexity of plugin publishing, and allows build logic changes to be atomic with the feature changes that require them. Publish to a repository only if you have multiple independent Android projects that need to share the same build conventions.
kotlin
// feature/search/build.gradle.kts

plugins {
    alias(libs.plugins.myapp.android.library)
    alias(libs.plugins.myapp.android.compose)
    alias(libs.plugins.myapp.android.hilt)
}

android {
    namespace = "com.myapp.feature.search"
}

dependencies {
    implementation(projects.core.data)
    implementation(projects.core.designSystem)

    implementation(libs.bundles.compose)
    implementation(libs.bundles.room)
    implementation(libs.coroutines.android)
    implementation(libs.ktor.client.core)

    testImplementation(libs.coroutines.test)
    testImplementation(libs.turbine)
}

Migration Strategy: Incremental Adoption

Most teams adopt version catalogs and convention plugins incrementally, not as a big-bang rewrite. The migration path has natural phases that can each be merged independently. **Phase 1: Version catalog only.** Create `gradle/libs.versions.toml` and migrate one module's dependencies to use catalog accessors. Verify the build works. Then migrate remaining modules one at a time in separate PRs. This phase requires zero changes to build logic — only dependency declaration syntax changes. Reviewers can verify each PR by confirming that the resolved dependencies are identical. **Phase 2: Create the build-logic module.** Set up the `build-logic` included build with `settings.gradle.kts` configured. Create one convention plugin — start with `AndroidLibraryConventionPlugin` since it has the broadest applicability. Migrate one library module to use it. Verify the build. This proves the infrastructure works before scaling it. **Phase 3: Expand convention plugins.** Add Compose, Hilt, and test convention plugins. Migrate modules in batches — one architectural layer at a time (core modules first, then feature modules). Each migration is a straightforward diff: remove inline configuration, add plugin application. **Phase 4: Enforce conventions.** Once all modules use convention plugins, add CI checks that reject build.gradle.kts files that set compileSdk or other convention-managed properties directly. This prevents configuration drift from creeping back in through rushed PRs. The entire migration can be done over 2-4 weeks for a 15-module project without blocking feature work. Each phase is independently valuable and independently mergeable. The convention plugin infrastructure pays for itself the first time a Kotlin version bump changes one file instead of fifteen, and every time a new module is created with a 10-line build file instead of an 80-line copy-paste.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Version catalogs (libs.versions.toml) centralize dependency coordinates in one TOML file with type-safe, IDE-autocompleted accessors — eliminating version drift across modules.
  2. 2Convention plugins extract repeated build logic (compileSdk, Compose config, Hilt setup) into reusable Gradle plugins that modules apply with a single line.
  3. 3The build-logic included build is a regular Kotlin project: full IDE support, type safety, and testable build configuration.
  4. 4Layered convention plugins (android-library + compose + hilt + test) let feature modules compose only the build configuration they need.
  5. 5Migration is incremental: adopt version catalogs first, then create convention plugins, then expand and enforce — each phase is independently mergeable.
  6. 6A new feature module build.gradle.kts shrinks from 60-80 lines to roughly 10: plugin applications and module-specific dependencies.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

What problem do Gradle version catalogs solve?

They centralize dependency coordinates in one libs.versions.toml with type-safe, IDE-autocompleted accessors, which eliminates version drift between modules.

What is a convention plugin?

A reusable Gradle plugin that holds build logic repeated across modules -- compileSdk, Compose config, Hilt setup -- so a module applies it in one line instead of restating it.

Do I have to migrate the whole build at once?

No. Adopt version catalogs first, then extract convention plugins, then expand and enforce. Each phase is independently mergeable, and a feature build file drops from 60-80 lines to roughly 10.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

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.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA