DEV Community

Cover image for Stop Copying Gradle Code
Tom Horvat
Tom Horvat

Posted on • Originally published at underdroid.hashnode.dev

Stop Copying Gradle Code

If you've worked on a multi-module Android project, you know the pain: build.gradle.kts files multiplying like rabbits, each containing the exact same Android compileSdk, Kotlin compiler options, and Compose configurations. When you need to bump a version or change a compiler flag, you end up doing a massive "Find and Replace" across 20 different modules.

We know we shouldn't copy and paste application code, yet we routinely accept copy-pasting our build configuration.

It's time to stop. In this article, we'll explore how to use a build-logic module to create custom Gradle convention plugins. By moving boilerplate out of your module-level build files, you can enforce consistency across your entire project and make maintaining your build system a breeze.

To demonstrate, we will reference a real-world implementation from the RandomPokemon repository.

The "After" Picture: A Clean app/build.gradle.kts

Before we look at how to build convention plugins, let's look at why we want them.

Here is what the final app/build.gradle.kts looks like after migrating to convention plugins:

plugins {
    // Plugins
    id("myapp.android.application")
    id("myapp.android.compose")
}

dependencies {
    // Modules
    implementation(project(":core"))
    implementation(project(":common"))
    implementation(project(":navigation"))
    implementation(project(":landing"))
    // Module specific dependencies
    implementation(libs.koin)
    implementation(libs.voyager.navigator)
    implementation(libs.timber)
}
Enter fullscreen mode Exit fullscreen mode

Notice what’s missing: No compileSdk, no minSdk, no buildFeatures { compose = true }, no Kotlin compiler arguments, and no core Compose dependencies. All of that complex boilerplate is completely hidden behind our custom plugins. The app-level build file is now strictly concerned with things specific to the app module.

Setting Up the build-logic Module

To achieve this, we create a completely separate, standalone Gradle build inside our main project directory called build-logic. As with any new module you want to add to your project, for Gradle to know it exists, you must add it to the project level settings.gradle.kts. The only difference is, since this is a build folder, the keyword is different:

includeBuild("build-logic") // <-- add this before all other inclusions
include(":app")
...
Enter fullscreen mode Exit fullscreen mode

Because it's an isolated build, Gradle doesn't automatically know about our primary project's version catalog (libs.versions.toml). We need to explicitly link it. Also, add entries into the catalog so that the Gradle script can reach them:

[versions]
agp = "8.13.2"
kotlin = "2.3.20"
room = "2.8.4"
...
[libraries]
android-gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
kotlin-gradle = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
room-gradle = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" }
...
Enter fullscreen mode Exit fullscreen mode

1. Linking the Version Catalog (build-logic/settings.gradle.kts)

Create a settings.gradle.kts file inside your new build-logic directory. We need to tell this separate build where to find our shared versions and dependencies:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml")) // <-- This points to your version catalog
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Configuring Dependencies (build-logic/build.gradle.kts)

Next, inside build-logic/build.gradle.kts, we define the dependencies that our plugins will need to compile. We use compileOnly so we don't force our plugin's AGP/Kotlin versions onto the project that consumes this plugin.

This is also where we register our plugins so Gradle knows what ID maps to which class:

plugins {
    `kotlin-dsl`
}

dependencies {
    compileOnly(libs.android.gradle)
    compileOnly(libs.kotlin.gradle)
    compileOnly(libs.room.gradle)
}

gradlePlugin {
    plugins {
        register("myapp.android.library") {
            id = "myapp.android.library"
            implementationClass = "AndroidLibraryConventionPlugin"
        }

        register("myapp.android.application") {
            id = "myapp.android.application"
            implementationClass = "AndroidApplicationConventionPlugin"
        }

        register("myapp.android.compose") {
            id = "myapp.android.compose"
            implementationClass = "AndroidComposeConventionPlugin"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Creating the Plugins

Now for the fun part: writing the actual plugins. In build-logic/src/main/kotlin/, we write standard Kotlin classes that implement Gradle's Plugin<Project> interface.

A Focused, Reusable Plugin: ProjectPluginExtensions.kt

First, let's look at how to centralize base Android and Kotlin configuration. The best practice here is to write an extension function that configures the CommonExtension (which both Android Libraries and Android Applications use), and then call that function from a concrete Plugin class.

Here is the setup and the usage example of how it gets applied:

internal fun Project.configureKotlinAndroid(
    commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
    commonExtension.apply {
        compileSdk = 36
        defaultConfig {
            minSdk = 31
        }

        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_21
            targetCompatibility = JavaVersion.VERSION_21
        }
    }

    tasks.withType<KotlinCompile>().configureEach {
        compilerOptions {
            jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
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> {
                configureKotlinAndroid(this) // <-- Use the extension function with the LibraryExtension

                // Set library specific flags, general build type configuration, etc.
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

By splitting the logic, an AndroidLibraryConventionPlugin can easily call configureKotlinAndroid(this) on its LibraryExtension, guaranteeing identical setups across your entire project. The same logic applies to a BaseAppModuleExtension:

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

            extensions.configure<BaseAppModuleExtension> {
                configureKotlinAndroid(this) // <-- Use the extension function with the BaseAppModuleExtension

                // Set app module specific flags
                namespace = "com.example.myapp"

                defaultConfig {
                    applicationId = "com.example.myapp"
                    targetSdk = 36
                    versionCode = 1
                    versionName = "1.0.0"
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A Complex Plugin: AndroidComposeConventionPlugin.kt

Sometimes a plugin needs to do more than just set flags—it needs to pull in dependencies and configure specific tooling features. Compose is a perfect candidate for this.

Here is an example of a plugin that configures Compose and injects required dependencies directly into the module applying it:

class RandomPokemonAndroidComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

            pluginManager.apply("org.jetbrains.kotlin.plugin.compose")

            pluginManager.withPlugin("com.android.application") {
                extensions.configure<ApplicationExtension> {
                    buildFeatures.compose = true
                }
            }
            pluginManager.withPlugin("com.android.library") {
                extensions.configure<LibraryExtension> {
                    buildFeatures.compose = true
                }
            }

            dependencies {
                "implementation"(platform(libs.findLibrary("androidx.compose.bom").get()))
                "implementation"(libs.findLibrary("androidx.ui").get())
                "implementation"(libs.findLibrary("androidx.ui.tooling.preview").get())
                "implementation"(libs.findLibrary("androidx.material3").get())
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

By applying this single plugin, any module in your project that implements this plugin immediately gets Compose enabled, the correct Compose compiler linked, the BOM applied, and the tooling dependencies configured. If you ever need to update the Compose BOM or compiler, you do it centrally in your version catalog. Your feature module is clean and only lists specific flags and dependencies particular to it.

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

android {
    namespace = "io.bitbot.bemusedbaboon.landing"
}

dependencies {

    implementation(project(":core"))
    implementation(project(":common"))
    implementation(project(":navigation"))

    implementation(libs.timber)

    implementation(libs.koin)
    implementation(libs.koin.compose)

    implementation(libs.voyager.navigator)

    implementation(libs.coil.compose)
    implementation(libs.coil.network)
}
Enter fullscreen mode Exit fullscreen mode

Bonus: Dynamically Generating Library Namespaces

If you want to take your boilerplate reduction a step further, look at how you declare namespaces in your library modules.

Typically, every library's build.gradle.kts requires an android block just to declare a deeply nested namespace:

android { 
    namespace = "com.example.myapp.commons" 
} 
Enter fullscreen mode Exit fullscreen mode

Since the base namespace com.example.myapp never changes, repeating it across dozens of modules is prone to typos and adds unnecessary noise. Because our build-logic is written in Kotlin, we can create a simple top-level extension function to handle this for us.

1. The Helper Function (build-logic/src/man/kotlin/ProjectPluginExtensions.kt )

Create a new Kotlin file in your convention plugin source directory. We'll define our base namespace as a constant and write an extension function on Project that configures the LibraryExtension:

// Define your single source of truth for the base namespace
private const val BASE_NAMESPACE = "com.example.myapp"

/**
 * Automatically applies the base namespace and appends the provided suffix.
 */
fun Project.libraryNamespace(suffix: String) {
    extensions.configure<LibraryExtension> {
        namespace = "$BASE_NAMESPACE.$suffix"
    }
}
Enter fullscreen mode Exit fullscreen mode

2. The Final Library build.gradle.kts

Now, you can completely eliminate the android { ... } block from your library modules. Whenever you create a new library, you simply call your custom function directly in the build script:

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

libraryNamespace("commons")

dependencies {
    ...
}
Enter fullscreen mode Exit fullscreen mode

By adding this tiny extension function, your library build scripts become incredibly concise, and your project enforces a strict, typo-free naming convention for all module namespaces.

Conclusion

Migrating your Gradle build configuration to custom convention plugins requires an initial upfront investment, but the ROI is massive:

  1. Readability: Module-level build.gradle.kts files become declarative summaries of what a module is, rather than a script of how it compiles.

  2. Safety: New modules are guaranteed to use the correct compiler flags, Java targets, and core dependencies.

  3. Maintainability: Updating your project to a new Java version, bumping compileSdk, or changing compiler arguments happens in exactly one place.

Stop copying Gradle code. Treat your build configuration with the same architectural respect you give to your application architecture. Your future self—and your team—will thank you.

Top comments (0)