close

DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Modularizing Your Android Build with Convention Plugins and Version Catalogs: The Gradle Architecture That Cuts CI Time in Half

---
title: "Modularizing Your Android Build with Convention Plugins and Version Catalogs"
published: true
description: "A hands-on guide to replacing buildSrc with build-logic composite builds, structuring TOML version catalogs, and reshaping your dependency graph so Gradle can actually parallelize compilation."
tags: android, kotlin, architecture, devops
canonical_url: https://blog.mvp-factory.com/modularizing-android-builds-convention-plugins
---

## What You Will Build

By the end of this workshop, you will have replaced your monolithic `buildSrc` with a `build-logic` composite build containing convention plugins, set up a TOML version catalog with bundle declarations, and restructured your dependency graph for parallel compilation. On a 42-module project, this exact approach cut incremental build times by 30-50% and CI wall-clock time by 55%.

Let me show you a pattern I use in every project.

## Prerequisites

- Android Studio Hedgehog or later
- Gradle 8.x+
- An existing multi-module Android project (or the willingness to create one)
- Familiarity with `build.gradle.kts` syntax

## Step 1: Replace buildSrc with build-logic

Here is the gotcha that will save you hours: every change to `buildSrc` invalidates your entire build cache. One version bump, and every module recompiles from scratch. A `build-logic` composite build fixes this — only consumers of the changed plugin recompile.

Register the composite build in your root settings file:

Enter fullscreen mode Exit fullscreen mode


kotlin
// settings.gradle.kts (root)
pluginManagement {
includeBuild("build-logic")
}


Then create the convention module:

Enter fullscreen mode Exit fullscreen mode


kotlin
// build-logic/convention/build.gradle.kts
plugins {
kotlin-dsl
}

dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
}


Now write your first convention plugin:

Enter fullscreen mode Exit fullscreen mode


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

    extensions.configure<LibraryExtension> {
        compileSdk = 35
        defaultConfig.minSdk = 26
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

}


Each module's build file shrinks to this:

Enter fullscreen mode Exit fullscreen mode


kotlin
plugins {
id("myapp.android.library")
id("myapp.android.hilt")
}


On a 42-module project I profiled, this single change dropped average incremental build time from 47s to 28s — a 40% improvement. The configuration cache hit rate went from 0% to 94%.

## Step 2: Set Up TOML Version Catalogs with Bundles

The docs do not mention this, but the feature most teams overlook in version catalogs is **bundles** — named groups that reduce boilerplate and enforce consistency:

Enter fullscreen mode Exit fullscreen mode


toml
[versions]
compose-bom = "2024.12.01"
coroutines = "1.9.0"

[libraries]
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" }
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }

[bundles]
compose-ui = ["compose-ui", "compose-material3"]
coroutines = ["coroutines-core", "coroutines-android"]


Reference `libs.bundles.compose.ui` inside your convention plugins — not in module build files. One source of truth, enforced by the build system.

## Step 3: Flatten Your Dependency Graph

Here is the minimal setup to get this working. Most teams modularize by count, not by shape. They chain `:feature-checkout` → `:feature-cart` → `:feature-catalog`, and Gradle can't parallelize any of it. You want a wide, shallow graph:

Enter fullscreen mode Exit fullscreen mode


plaintext
:app
├── :feature-home
├── :feature-search
├── :feature-profile
├── :feature-settings
│ └── (each depends only on :core-ui, :core-domain, :core-data)
├── :core-ui
├── :core-domain (pure Kotlin, no Android)
├── :core-data
└── :core-network


This restructuring took max parallel module compilation from 3 to 14 on a 4-core GitHub Actions runner. CI wall-clock dropped from 22 minutes to 9m 45s. Enforce it with a validation task:

Enter fullscreen mode Exit fullscreen mode


kotlin
tasks.register("validateDependencyGraph") {
doLast {
val featureModules = subprojects.filter { it.path.startsWith(":feature-") }
featureModules.forEach { module ->
val deps = module.configurations["implementation"].dependencies
deps.forEach { dep ->
require(!dep.name.startsWith("feature-")) {
"${module.path} depends on ${dep.name}. Feature modules must not depend on each other."
}
}
}
}
}


## Gotchas

- **Modularization is a graph problem, not a counting problem.** 40 modules in a linear chain are worse than 10 modules in a flat graph. Run `./gradlew :app:dependencies` and break feature-to-feature edges.
- **Use `api` vs `implementation` strictly.** Feature modules should never expose transitive dependencies to other feature modules — because they should never depend on each other.
- **Configuration cache requires dropping buildSrc.** You cannot get meaningful cache hit rates while `buildSrc` is in play. This is the single highest-ROI change you can make.
- **Clean build times improve less dramatically than incremental ones.** Expect clean builds to go from 8m 12s to around 5m 48s. The real payoff is in the incremental cycle you hit hundreds of times per day.

## Wrapping Up

Start with the `build-logic` migration — it typically takes a day and unlocks configuration caching immediately. Then flatten your dependency graph so feature modules only reach down to `:core-*` modules. Finally, move version catalog bundle references into your convention plugins.

I've watched teams accept 20-minute CI runs for months because "that's just how Gradle is." It isn't. A day of restructuring paid back within a week on our team.

For further reading, check the [Gradle composite builds docs](https://docs.gradle.org/current/userguide/composite_builds.html) and the [Now in Android](https://github.com/android/nowinandroid) project, which implements this exact pattern at scale.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)