5 min read

Gradle Convention Plugins: Killing 40 Copies of the Same Build Script

Copy-pasted build.gradle.kts files are duplication like any other. Convention plugins move shared build logic into typed, testable Kotlin in build-logic — one place to change, modules that declare intent, and a configuration cache that stays valid.

AndroidGradleBuild LogicModularizationKotlin
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Duplicated build.gradle.kts files are duplication, and they rot the same way. The fix isn't subprojects { } — that breaks the configuration cache and applies Android config to modules that aren't Android. Put shared build logic in convention plugins inside an included build-logic build: real Kotlin, with types, applied per module as id("myapp.android.library").

The problem every modularised app reaches

Module 41 is created by copying module 40's build file. It works, so nobody objects. Then:

  • A Kotlin upgrade means editing 41 files, and three get missed.
  • Compiler flags drift between modules, and nobody knows which set is intentional.
  • A new engineer copies a file containing two options that stopped being necessary in 2023.

This is ordinary code duplication. We'd never accept 41 copies of a mapper; we accept it in the build because build files don't feel like code.

Why subprojects { } isn't the answer

// Root build.gradle.kts — please don't
subprojects {
    apply(plugin = "com.android.library")
    android { compileSdk = 35 }
}

Three real problems:

  1. It breaks the configuration cache. Cross-project configuration at execution time is exactly what the configuration cache forbids. On a large build, that cache is worth more than the convenience.
  2. It assumes homogeneity. The moment you add a pure-Kotlin :domain module, you're applying the Android plugin to something that isn't an Android library, and you start writing if statements about module names.
  3. It's untyped and invisible. A module's build file no longer tells you how the module is configured; you have to know the root file exists and read it too.

Convention plugins

Create an included build that holds your build logic as source:

// settings.gradle.kts
pluginManagement { includeBuild("build-logic") }
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
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 = 35
            defaultConfig.minSdk = 24
            configureKotlin(this)
        }

        dependencies {
            add("testImplementation", libs.findLibrary("junit").get())
        }
    }
}

Register it with an id:

// build-logic/convention/build.gradle.kts
gradlePlugin {
    plugins {
        register("androidLibrary") {
            id = "myapp.android.library"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
    }
}

And every consuming module collapses to:

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

dependencies {
    implementation(projects.core.domain)
}

What you actually gain

One place to change. A Kotlin version bump, a new compiler flag, a lint rule — one file.

Modules that declare intent. myapp.android.feature states what a module is. Compare that to sixty lines describing how it happens to be configured. New engineers read the plugin id and know what they're looking at.

A valid configuration cache. Plugins are applied per project, in the normal lifecycle, so the cache stays usable. On a large multi-module build this is frequently the single biggest local build speed win available.

Build logic you can test. It's Kotlin in a real source set with real types. You can unit test a convention plugin with Gradle TestKit — and more importantly, your IDE autocompletes it and refactors it.

Where to draw the plugin boundaries

Start with the ones that map to a kind of module, then add capability plugins:

  • myapp.android.application
  • myapp.android.library
  • myapp.android.feature — library + Compose + navigation + the feature test setup
  • myapp.android.hilt
  • myapp.jvm.library — for pure-Kotlin modules

Keep them composable. A feature module applying feature and hilt should be normal; a plugin that does everything for one specific module is just the old build file with extra steps.

Migrating without a big bang

You don't need a rewrite. Write one plugin, apply it to a single module, and delete the lines it now provides. When that module's build file is three lines and it still builds, do the next one. The duplicated files can coexist with the plugin indefinitely, so this is a genuinely incremental refactor — which is rare enough in build work to be worth exploiting.

The mindset

Your build is software. It has duplication, abstractions, naming, and a design that gets better or worse over time. The reason build files rot isn't that Gradle is hard — it's that we exempt them from the standards we apply to the 40,000 lines of Kotlin sitting next to them.

Keep reading