7 min read

Build Logic as a First-Class Module

Convention plugins fix duplicated build scripts; where you put them decides whether they become infrastructure or a second mess. Why an included build beats buildSrc, and how to engineer build logic like the production code it is.

GradleAndroidBuildConvention PluginsKotlin
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Put convention plugins in a build-logic composite build (includeBuild), not buildSrc: buildSrc is an implicit dependency of every project, so one edit invalidates work across the whole build. Then engineer it like production code — a code owner, a CI job that applies each plugin to a sample module, plugins that compose rather than a base plugin with seven boolean flags, and an explicitly wired version catalog, because libs is not automatically available inside build logic. The payoff is a new feature module whose entire build file is one id("myapp.android.feature").

The problem this is downstream of

You've already accepted that forty build.gradle.kts files repeating the same twenty lines is a liability — that's the convention-plugin argument, and there's a separate post in this series on it. This one is about the question that comes immediately after: where does that code live, and how do you keep it from becoming the new mess?

Because build logic has a property no other code in your repo has: a bug in it blocks every engineer at once. It deserves more engineering than it usually gets, not less.

Why not buildSrc

buildSrc is the path of least resistance — create the directory and Gradle picks it up. It's also an implicit dependency of every project in the build. Gradle has improved the incrementality here over several releases, but the structural fact remains: changes there ripple wider than changes to a normal module, and on a large multi-module Android build that shows up as "I edited one line and now everything is out of date."

There's a second, subtler cost. buildSrc shares a classpath with your build in ways that make dependency conflicts confusing to diagnose, and it has no natural place to put its tests.

The shape that scales: an included build

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

dependencyResolutionManagement {
    repositories { google(); mavenCentral() }
    versionCatalogs {
        create("libs") { from(files("gradle/libs.versions.toml")) }
    }
}

build-logic is now a separate Gradle build that produces plugins your main build consumes. It has its own settings.gradle.kts, its own dependencies, and — the point — it is rebuilt only when it changes.

Inside it, one module is usually enough:

build-logic/
├── settings.gradle.kts
└── convention/
    ├── build.gradle.kts
    └── src/main/kotlin/
        ├── AndroidApplicationConventionPlugin.kt
        ├── AndroidLibraryConventionPlugin.kt
        ├── AndroidFeatureConventionPlugin.kt
        └── AndroidComposeConventionPlugin.kt

And the plugins get registered with ids your feature modules will use:

// build-logic/convention/build.gradle.kts
gradlePlugin {
    plugins {
        register("androidFeature") {
            id = "myapp.android.feature"
            implementationClass = "AndroidFeatureConventionPlugin"
        }
    }
}

The result at the call site is the entire point of the exercise:

// feature/checkout/build.gradle.kts
plugins {
    id("myapp.android.feature")
}

dependencies {
    implementation(projects.core.payments)
}

Compile SDK, Kotlin JVM target, Compose configuration, test runners, lint baseline, shared test dependencies — all decided once. A new module is now a five-line file instead of a copy-paste of the nearest one that looked similar.

Compose plugins; don't parameterise them

The failure mode is one AndroidConventionPlugin with flags:

// don't
class AndroidConventionPlugin : Plugin<Project> {
    var isCompose = false
    var isFeature = false
    var useHilt = false
    var isPublished = false
}

Four booleans is sixteen possible builds, of which you have tested two. Prefer small plugins that apply each other:

class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("myapp.android.library")
        pluginManager.apply("myapp.android.compose")
        pluginManager.apply("myapp.hilt")

        dependencies {
            add("implementation", project(":core:ui"))
            add("androidTestImplementation", libs.findLibrary("compose.ui.test").get())
        }
    }
}

Composition reads top-to-bottom, and every combination that exists is one somebody deliberately created.

The version catalog is not automatic in here

This trips up most first attempts. The type-safe libs accessor is generated for your main build's scripts; inside build-logic it doesn't exist, and the temptation is to hard-code versions — in the one file whose whole purpose is preventing hard-coded versions.

Wire it explicitly, once:

internal val Project.libs
    get() = extensions.getByType<VersionCatalogsExtension>().named("libs")

Then libs.findVersion("kotlin").get() and libs.findLibrary("hilt.android").get() work throughout, and your catalog stays the single source of truth for versions.

Engineer it like production code

Four practices, in the order they pay off:

1. A CI job that applies the plugins. The cheapest useful test is a sample module in the repo that applies each convention plugin and gets assembled in CI. It catches the class of bug that matters — "the plugin no longer configures a valid Android module" — for almost no effort.

2. Functional tests for the logic that's worth it. Gradle's GradleRunner can run a real build against a fixture project and assert on the outcome. Worth writing for the plugins that make decisions (a custom task, a variant filter), overkill for a plugin that only sets compileSdk.

3. An owner and a CODEOWNERS entry. Build logic is the classic tragedy of the commons: everyone depends on it, nobody owns it, so it accretes special cases from whoever was blocked last. A named owner turns "add a flag for my module" into a conversation.

4. Name for intent. myapp.android.feature tells a reader what kind of module this is. myapp.kotlin.android.compose.hilt.base tells them what it does internally, which is exactly the detail the plugin exists to hide.

The honest cost

It is a build inside your build. Stack traces cross the boundary, IDE support for editing plugin source is good but not the same as app code, and a new joiner has one more concept to learn before they can add a module.

That price is real. Weigh it against the one you're paying now: forty build files that disagree about Kotlin versions, a compile SDK bump that takes an afternoon of find-and-replace, and no answer to "where do I change this for everyone."

Below roughly ten modules, convention plugins in buildSrc are fine and the composite build is ceremony. Above thirty, the ceremony is cheaper than the alternative every single week.

The rule

Build logic isn't configuration — it's a product, and your teammates are its users. Give it a home that doesn't invalidate the world, an owner who says no, plugins that compose instead of branch, and a CI job that proves it still works. It is the only code in the repo that can stop everybody at the same time.

Keep reading