DEV Community

ColtonIdle
ColtonIdle

Posted on • Edited on

How to create a "convention" plugin for your multi-module Android app

One thing that trips me up all the time is how to common-ize my build files in a multi-module Android app. Every time I try to learn it, I give up because of overloaded terms, potential footguns, and possible slowdowns in my app. IMO this should be a lot easier, so I end up just duplicating code and sometimes I will just have some sort of subprojects.all{} block in my root build.gradle to apply something to all of my modules instead.

I'm trying to learn once more with a very simple case where I have:

  • Android app module
  • Android library lib1 module
  • Android library lib2 module

And I want to extract the common code in the android libraries (lib1 and lib2)

Some general notes:

  • According to https://docs.gradle.org/current/userguide/best_practices_structuring_builds.html#favor_composite_builds) that buildSrc isn't recommended and so I should go down a path of a convention plugin for sharing build logic
  • Convention plugin is a loaded term and you can have convention plugins in both buildSrc and build-logic . Similarly, you can write your convention plugins as "precompiled script plugins" (.kts ) or regular "binary plugins" (.kt)
  • https://github.com/autonomousapps/gradle-glossary is a good resource to brush up on gradle terms
  • In Android you have gradle "modules", but these modules are really "projects" in the eyes of gradle. Similarly, you might be used to call something a gradle "project" if it uses gradle to build, but in the eyes of gradle this is called a "build". Hence why adding a convention plugin requires you to create another gradle build in your repo, and add it to your initial gradle build via "includedBuild"
  • NowInAndroid saved ~12s in some cases by removing precompiled script plugins
  • If you want the fastest possible performance, you want to publish your convention plugins (annoying for your "typical" android app) (see here)
  • If you see kotlin-dsl in your build, you can treat this as an indication that you can try to remove it in order to gain some build speed
  • Read https://mbonnin.net/2025-07-10_the_case_for_kgp/
  • Many definitions of a "convention plugin"

    1. Convention plugins are just regular plugins
    2. A "convention plugin" is  a plugin that only your team uses
    3. A "convention plugin" is a plugin that you share in your build, and so you could say every plugin is a convention plugin, but typically "convention plugins" are understood as being part of your repo
  • id("java-gradle-plugin") and `java-gradle-plugin`
    are interchangable. Same with maven-publish See here

  • subprojects and allprojects break project isolation, but if you don't care about project isolation then it should be fine to use. Read more here about why it should not: https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html#sec:convention_plugins_vs_cross_configuration

This was like 90% done put together with help from Martin Bonnin, but I had to write it down so I don't forget it

Conversion

So let's just pretend we did file > new project, then added two new android lib modules (lib1 and lib2). By default we'll have this duplicate code in the two lib modules. (this is default code that AS new module wizard will generate in Jan of 2026):

plugins {
  alias(libs.plugins.android.library)
}

android {
  namespace = "com.cidle.lib1"
  compileSdk {
    version = release(36)
  }

  defaultConfig {
    minSdk = 27

    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    consumerProguardFiles("consumer-rules.pro")
  }

  buildTypes {
    release {
      isMinifyEnabled = false
      proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
    }
  }
  compileOptions {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
  }
}

dependencies {
  implementation(libs.androidx.core.ktx)
  implementation(libs.androidx.appcompat)
  implementation(libs.material)
  testImplementation(libs.junit)
  androidTestImplementation(libs.androidx.junit)
  androidTestImplementation(libs.androidx.espresso.core)
}
Enter fullscreen mode Exit fullscreen mode

Let's de-duplicate with a convention plugin!

Steps

1: Create build-logic directory
2: Add settings.gradle.kts in build-logic and fill it with

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

rootProject.name = "build-logic"
Enter fullscreen mode Exit fullscreen mode

3: Inside of this new build-logic dir create a build.gradle.kts

plugins {
  id("java-gradle-plugin")
  alias(libs.plugins.kotlin.jvm)
}

java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(17))
  }
}

dependencies {
  compileOnly(libs.android.gradlePlugin)
  implementation(gradleKotlinDsl())
}

gradlePlugin {
  plugins {
    register("androidLibrary") {
      id = "libtest.android.library"
      implementationClass = "AndroidLibraryConventionPlugin"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: You can’t replace “java-gradle-plugin” with a toml entry since it is a built-in plugin inside Gradle, it’s not a dependency so it doesn’t belong to the version catalog.

When it comes to
"dependencies {
implementation(libs.android.gradlePlugin)
implementation(gradleKotlinDsl())
}"
you needs those as well. gradleKotlinDsl() is not strictly required but it can help with things like tasks.withType() instead of tasks.withType(Jar::class.java) or extensions.configure

4: Then in convention dir, create new package and class structure of src/main/kotlin/AndroidLibraryConventionPlugin.kt

import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType

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

            extensions.configure<LibraryExtension> {
                compileSdk = 36

                defaultConfig {
                    minSdk = 27
                    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                    consumerProguardFiles("consumer-rules.pro")
                }

                buildTypes {
                    release {
                        isMinifyEnabled = false
                        proguardFiles(
                            getDefaultProguardFile("proguard-android-optimize.txt"),
                            "proguard-rules.pro"
                        )
                    }
                }

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

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

            dependencies {
                add("implementation", libs.findLibrary("androidx-core-ktx").get())
                add("implementation", libs.findLibrary("androidx-appcompat").get())
                add("implementation", libs.findLibrary("material").get())
                add("testImplementation", libs.findLibrary("junit").get())
                add("androidTestImplementation", libs.findLibrary("androidx-junit").get())
                add("androidTestImplementation", libs.findLibrary("androidx-espresso-core").get())
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: I wish there was a better way to use our toml file here. I'm not fond of libs.findLibrary, etc. It might be related to this: https://github.com/gradle/gradle/issues/15383

5: Update :lib1 and :lib2 respective build.gradle.kts to be

plugins {
  id("libtest.android.library")
}

android {
  namespace = "com.cidle.lib1"
}
Enter fullscreen mode Exit fullscreen mode

and

plugins {
  id("libtest.android.library")
}

android {
  namespace = "com.cidle.lib2"
}
Enter fullscreen mode Exit fullscreen mode

We basically went down from 37 lines to 6 lines... in 2 modules! So for every time we add a new module, we save at least those 30 lines and as our "base" android library definition expands (adds more dependencies, lint configuration, etc) then you save yourself from having to re-write those lines too.

6: In your settings.gradle.kts you need to add one line to add this new build-logic module as an "included build"

pluginManagement {
  includeBuild("build-logic") <===== this is the line you add!
  repositories {
    google {
      content {
        includeGroupByRegex("com\\.android.*")
        includeGroupByRegex("com\\.google.*")
        includeGroupByRegex("androidx.*")
      }
    }
    mavenCentral()
    gradlePluginPortal()
  }
}
Enter fullscreen mode Exit fullscreen mode

7: In your toml add (under libraries)

android-gradlePlugin = { group = "com.android.tools.build", name = "gradle-api", version.ref = "agp" }
Enter fullscreen mode Exit fullscreen mode

and under plugins add

kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
Enter fullscreen mode Exit fullscreen mode

Done!

Thank you again Martin Bonnin for all of the teaching on this subject!

TL;DR: I think the biggest concept to overcome when trying to setup a convention plugin is that you need another gradle build in your repository. Example: if you had a file > new android project, and then created two android libs (lib1 and lib2) and wanted to have common dependencies/configuration between those two android library modules, you would need to create (what looks like) a brand new build-logic module, but this "module" isn't just "another" module you might be used to in your android project — instead it has to be a new little gradle "project" (the gradle term for this is a new gradle build), which is why you need to write another settings.gradle.kts in build-logic dir, etc. In order for your actual android project to use this new build-logic gradle build, you need to reference it via includeBuild() Get it? Instead of your usual one gradle build in your repo, you will have two, hence the need to call includeBuild()

Please vote for clearer documentation from the Gradle team: https://github.com/gradle/gradle/issues/36495

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.