Gradle convention plugins for a multi-module Android app: build-logic, AGP 9 and the version catalog
Stop repeating twenty lines in every build.gradle.kts. How to set up a build-logic included build, write class-based convention plugins (library, Compose, Hilt, feature), keep compileSdk and minSdk in one place with AGP 9's CommonExtension, and use libs.versions.toml properly.

Once an Android app has a dozen or more modules, every build.gradle.kts starts to repeat the same twenty lines: the Android plugin, compileSdk, minSdk, Java and Kotlin options, the Compose setup, the Hilt plugin and processor, the test dependencies. Bumping minSdk means editing every file, and forgetting one means a module that quietly builds differently from the rest.
Convention plugins fix that. A convention plugin is a small Gradle plugin you write yourself, which applies and configures other plugins the way your project wants. Each module then says what kind of module it is, a library or a feature, and the plugin supplies the how. Google's Now in Android sample is built this way, and Gradle's documentation recommends this approach over cross-project configuration with subprojects {} and allprojects {}.
buildSrc or build-logic
Gradle offers two places for shared build logic.
buildSrcis a special directory Gradle builds automatically before your project. It works, but any change inside it invalidates the whole build's configuration, because it is on the classpath of every build script.- An included build, by convention named
build-logic, is a separate Gradle build that your main build includes withincludeBuild. Its plugins are resolved by ID like any other plugin, and only the modules that apply a changed plugin are affected.
This post uses build-logic, as Now in Android does.
Setting up build-logic
MyApp/
├── build-logic/
│ ├── settings.gradle.kts
│ └── convention/
│ ├── build.gradle.kts
│ └── src/main/kotlin/
│ ├── AndroidLibraryConventionPlugin.kt
│ ├── AndroidLibraryComposeConventionPlugin.kt
│ ├── AndroidFeatureConventionPlugin.kt
│ ├── HiltConventionPlugin.kt
│ └── com/example/buildlogic/KotlinAndroid.kt
├── gradle/libs.versions.toml
└── settings.gradle.kts // pluginManagement { includeBuild("build-logic") }
build-logic/settings.gradle.kts shares the main project's version catalog, so plugins and libraries have one source of versions:
// build-logic/settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") { from(files("../gradle/libs.versions.toml")) }
}
}
rootProject.name = "build-logic"
include(":convention")
The convention module is a Kotlin DSL plugin project. It puts the Android, Kotlin and KSP Gradle plugins on its classpath as compileOnly dependencies, so it can reference their types without forcing versions on the main build, and registers each plugin under an ID:
// build-logic/convention/build.gradle.kts
plugins {
`kotlin-dsl`
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
}
gradlePlugin {
plugins {
register("androidLibrary") {
id = "myapp.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidFeature") {
id = "myapp.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
// ...one register() per plugin
}
}
You can write a convention plugin as a precompiled script (myapp.android.library.gradle.kts, file name = plugin ID) or as a class implementing Plugin<Project>. Classes are more verbose but are ordinary Kotlin, so plugins can share helper functions.
One place for compileSdk and minSdk
The shared helper holds the project-wide Android settings:
// KotlinAndroid.kt
internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
commonExtension.apply {
compileSdk = 36
defaultConfig.apply {
minSdk = 26
}
compileOptions.apply {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
configure<KotlinAndroidProjectExtension> {
compilerOptions { jvmTarget = JvmTarget.JVM_17 }
}
}
Bumping compileSdk or minSdk is now a one-line change.
Note the shape: CommonExtension has no type arguments, and blocks are reached as properties (defaultConfig.apply { }) rather than lambdas. That is an AGP 9 change. The AGP 9.0 release notes state that the parameterization of CommonExtension was removed and the block methods moved to ApplicationExtension and LibraryExtension. Convention plugins written for AGP 8 used CommonExtension<*, *, *, *, *, *> and defaultConfig { }, and need this update when you move to AGP 9.
A tiny extension gives every plugin the version catalog:
val Project.libs: VersionCatalog
get() = extensions.getByType<VersionCatalogsExtension>().named("libs")
The plugins themselves
An Android library:
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
apply(plugin = "com.android.library")
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
dependencies {
"testImplementation"(libs.findLibrary("junit").get())
"testImplementation"(libs.findLibrary("kotlinx.coroutines.test").get())
}
}
}
There is no Kotlin Android plugin to apply: AGP 9's built-in Kotlin support compiles the sources.
Hilt, in the style of Now in Android's HiltConventionPlugin:
class HiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
apply(plugin = "com.google.devtools.ksp")
pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
dependencies {
"implementation"(libs.findLibrary("hilt.core").get())
"ksp"(libs.findLibrary("hilt.compiler").get())
}
}
pluginManager.withPlugin("com.android.base") {
apply(plugin = "com.google.dagger.hilt.android")
dependencies {
"implementation"(libs.findLibrary("hilt.android").get())
"ksp"(libs.findLibrary("hilt.android.compiler").get())
}
}
}
}
pluginManager.withPlugin runs its block only if that plugin is applied, so one convention plugin serves both pure Kotlin and Android modules.
The feature plugin encodes your module rules in one place: it stacks the Compose library and Hilt plugins and adds what every feature needs.
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
apply(plugin = "myapp.android.library.compose")
apply(plugin = "myapp.hilt")
dependencies {
"implementation"(project(":core:data"))
"implementation"(project(":core:designsystem"))
"implementation"(project(":core:ui"))
"testImplementation"(project(":core:testing"))
}
}
}
What a module's build file looks like now
// feature/notes/build.gradle.kts
plugins {
alias(libs.plugins.myapp.android.feature)
}
android {
namespace = "com.example.myapp.feature.notes"
}
dependencies {
implementation(projects.core.domain)
}
Everything a feature always needs comes from the plugin; the module lists only what is special about it.
The version catalog
gradle/libs.versions.toml is where Gradle looks by default for dependency versions. It has four sections: [versions], [libraries], [bundles] for groups of libraries always used together, and [plugins] for Gradle plugin IDs. Your convention plugins get [plugins] entries too, without versions, because they come from the included build:
[plugins]
android-library = { id = "com.android.library", version.ref = "agp" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
myapp-android-library = { id = "myapp.android.library" }
myapp-android-feature = { id = "myapp.android.feature" }
A few rules keep the catalog useful:
- Dashes become dots.
androidx-navigation3-runtimeislibs.androidx.navigation3.runtimein Kotlin, andlibs.findLibrary("androidx.navigation3.runtime")inside a plugin. - Libraries under a BOM have no version. Compose entries take their versions from the Compose BOM, which the Compose convention plugin adds as a
platform()dependency. - Bundles only for libraries that are always used together.
- Versions live only in the catalog. A version string in any
build.gradle.ktsis a version that will be missed in the next upgrade.
Checklist
- Shared build logic lives in a
build-logicincluded build, not insubprojects {}orallprojects {}. compileSdk,minSdkand the Java and Kotlin targets are set in exactly one place.- Convention plugins use AGP 9's unparameterized
CommonExtension. - Each module's build file names its kind and lists only its own extras.
- All versions live in
gradle/libs.versions.toml, shared withbuild-logic.
