Convention Plugins & Build Performance
Once your Android project has more than a handful of modules,
copy-pasted build.gradle.kts blocks become the #1 source of build drift.
Convention plugins consolidate shared build logic into one place,
applied via a one-line plugins { id("myapp.android.feature") }. Combined
with configuration cache and Gradle Develocity, this chapter covers the
enterprise-grade build setup.
The build-logic composite build
my-app/
├── app/
├── core/...
├── feature/...
├── build-logic/ ← composite build
│ ├── settings.gradle.kts
│ └── convention/
│ ├── build.gradle.kts
│ └── src/main/kotlin/
│ ├── AndroidApplicationConventionPlugin.kt
│ ├── AndroidLibraryConventionPlugin.kt
│ ├── AndroidFeatureConventionPlugin.kt
│ ├── AndroidComposeConventionPlugin.kt
│ ├── KotlinJvmLibraryConventionPlugin.kt
│ ├── AndroidTestConventionPlugin.kt
│ └── AndroidHiltConventionPlugin.kt
├── gradle/libs.versions.toml
└── settings.gradle.kts
Wire it up
// settings.gradle.kts (root)
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
// build-logic/settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":convention")
// build-logic/convention/build.gradle.kts
plugins {
`kotlin-dsl`
}
group = "com.myapp.buildlogic"
dependencies {
compileOnly(libs.android.gradle.plugin)
compileOnly(libs.kotlin.gradle.plugin)
compileOnly(libs.ksp.gradle.plugin)
compileOnly(libs.compose.gradle.plugin)
}
gradlePlugin {
plugins {
register("androidApplication") {
id = "myapp.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidLibrary") {
id = "myapp.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidFeature") {
id = "myapp.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
register("androidCompose") {
id = "myapp.android.compose"
implementationClass = "AndroidComposeConventionPlugin"
}
register("androidHilt") {
id = "myapp.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
register("kotlinJvmLibrary") {
id = "myapp.kotlin.library"
implementationClass = "KotlinJvmLibraryConventionPlugin"
}
}
}
A library convention plugin
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
buildConfig = false
}
lint {
warningsAsErrors = true
abortOnError = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/LICENSE*"
}
}
}
extensions.configure<KotlinAndroidProjectExtension> {
jvmToolchain(17)
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn",
"-Xjvm-default=all",
"-Xcontext-receivers"
)
}
}
dependencies {
add("implementation", libs.findLibrary("androidx.core.ktx").get())
add("testImplementation", libs.findLibrary("junit.jupiter.api").get())
add("testRuntimeOnly", libs.findLibrary("junit.jupiter.engine").get())
}
tasks.withType<Test> {
useJUnitPlatform()
}
}
}
// Extension to reach the version catalog
val Project.libs: VersionCatalog
get() = extensions.getByType<VersionCatalogsExtension>().named("libs")
A compose convention plugin
class AndroidComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
val bom = libs.findLibrary("androidx-compose-bom").get()
dependencies {
val androidExtension = extensions.getByType<CommonExtension<*, *, *, *, *, *>>()
androidExtension.apply {
buildFeatures { compose = true }
}
add("implementation", platform(bom))
add("implementation", libs.findBundle("compose").get())
add("androidTestImplementation", platform(bom))
add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
add("debugImplementation", libs.findLibrary("androidx-compose-ui-test-manifest").get())
}
// Opt into Compose compiler metrics via a Gradle property
extensions.configure<KotlinAndroidProjectExtension> {
compilerOptions {
val reportsDir = layout.buildDirectory.dir("compose-reports").get().asFile.absolutePath
if (project.findProperty("composeMetrics") == "true") {
freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$reportsDir",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$reportsDir"
)
}
}
}
}
}
A feature convention plugin — combine them
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager) {
apply("myapp.android.library")
apply("myapp.android.compose")
apply("myapp.android.hilt")
}
dependencies {
add("implementation", project(":core:ui"))
add("implementation", project(":core:design"))
add("implementation", project(":core:domain"))
add("implementation", libs.findLibrary("androidx-lifecycle-viewmodel").get())
add("implementation", libs.findLibrary("androidx-hilt-navigation-compose").get())
add("implementation", libs.findBundle("androidx-lifecycle-ktx").get())
}
}
}
Using it
A new feature module's build script is 3 lines:
// feature/catalog/build.gradle.kts
plugins {
id("myapp.android.feature")
}
android {
namespace = "com.myapp.feature.catalog"
}
Everything else — Compose, Hilt, Lifecycle, test runner, lint settings — comes from the convention plugins.
Version catalog deep dive
Bundles
[bundles]
compose = [
"compose-ui",
"compose-ui-tooling-preview",
"compose-foundation",
"compose-material3",
"compose-runtime"
]
testing-unit = [
"junit-jupiter-api",
"mockk",
"turbine",
"coroutines-test"
]
// in a build.gradle.kts
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.compose)
testImplementation(libs.bundles.testing.unit)
}
Bundle once, include across 50 modules. Changing a library version is a single catalog edit.
Plugin references
[versions]
agp = "8.7.2"
kotlin = "2.1.0"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
Configuration cache
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn # or 'fail' to enforce
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.workers.max=8
The configuration cache serializes the task graph. On the second build, Gradle skips the entire configuration phase — huge speedup in large projects (often 10-20 seconds saved per incremental build).
Compatibility
Most modern plugins are compatible. If not:
> ./gradlew assembleDebug
Configuration cache problems found in this build.
1 problem was found storing the configuration cache.
- Task `:app:processDebugResources` of type ...:
cannot serialize object of type java.io.File ...
Workarounds:
- Use providers and lazy configuration:
.set { ... }instead of eager= ... - Avoid
project.execin configuration; use tasks - Report non-compliant plugins and update versions
Incremental builds
With configuration cache:
- First build: normal time
- Second build, same tasks: Gradle skips config (≈ 300 ms instead of 15s)
- After
build.gradle.ktsedit: rebuilds config, then caches
Build scans with Develocity
// settings.gradle.kts
plugins {
id("com.gradle.develocity") version "3.18"
}
develocity {
buildScan {
termsOfUseUrl = "https://gradle.com/terms-of-service"
termsOfUseAgree = "yes"
publishing.onlyIf { isCi || buildResult.failures.isNotEmpty() }
tag(if (isCi) "CI" else "Local")
tag(System.getProperty("os.name"))
}
}
val isCi = System.getenv("CI") == "true"
Build scans give you:
- Task timing (which task took 4s out of 15s)
- Dependency graph visualizations
- Per-test timing
- Flaky test detection (across runs)
- Compare any two builds
Free tier is generous; enterprise Develocity adds remote build cache, Test Distribution (parallelize tests across CI nodes), and insights.
Remote build cache
// settings.gradle.kts
buildCache {
local {
isEnabled = true
}
remote<HttpBuildCache> {
url = uri("https://cache.mycompany.com/cache/")
isPush = isCi
credentials {
username = providers.gradleProperty("cacheUser").orNull
password = providers.gradleProperty("cachePassword").orNull
}
}
}
A shared cache means developer B doesn't recompile what developer A already compiled on CI. For large projects, remote build cache cuts 30-60% off clean-build times.
Gradle properties for performance
# gradle.properties
org.gradle.jvmargs=-Xmx6g -XX:+UseG1GC -XX:MaxMetaspaceSize=1g \
-Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options="-Xmx4g"
# Parallel + caching
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.workers.max=8
# Kotlin
kotlin.code.style=official
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true
# AndroidX + AGP
android.useAndroidX=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
android.defaults.buildfeatures.buildconfig=false
android.defaults.buildfeatures.aidl=false
android.defaults.buildfeatures.renderscript=false
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
Key toggles
nonTransitiveRClass=true— each module'sRonly contains its own resources. Faster incremental R.jar generation.nonFinalResIds=true—R.id.*aren't compile-time constants. Allows more caching.buildconfig=falseby default — turn on only where needed.
Task profiling
./gradlew :app:assembleDebug --profile --scan
Generates build/reports/profile/profile-*.html plus a scan URL. Use
for:
- Finding the slowest task
- Identifying redundant work
- Before/after measuring an optimization
--dry-run to inspect the task graph
./gradlew :app:assembleDebug --dry-run
Shows which tasks would run without executing. Useful when you think "why is that task running?" — check the dependencies.
--rerun-tasks to invalidate cache
./gradlew :app:assembleDebug --rerun-tasks
Forces everything to re-execute, bypassing caches. Useful to measure worst-case clean-build time.
Dependency analysis
// app/build.gradle.kts
plugins {
id("com.autonomousapps.dependency-analysis") version "2.1.4"
}
./gradlew buildHealth
Reports:
- Unused dependencies (can be removed)
apivsimplementationmismatches- Version conflicts
- Duplicate classes
Addresses are often surprising — a "just in case" Kotlin extension library pulls in 5 MB of unused transitive deps.
Dependency locking
For supply-chain security, lock dependency versions:
// root build.gradle.kts
allprojects {
configurations.all {
resolutionStrategy.activateDependencyLocking()
}
}
./gradlew dependencies --write-locks
Produces gradle.lockfile per configuration. Any new dependency forces a
lock update — no surprise version bumps from transitive deps.
KSP over kapt
plugins {
alias(libs.plugins.ksp)
}
dependencies {
ksp(libs.hilt.compiler)
ksp(libs.room.compiler)
ksp(libs.moshi.codegen)
}
KSP is 2× faster than kapt. Every annotation processor you use (Hilt, Room, Moshi, Dagger) supports KSP. No reason to use kapt in new code; migrating old code is worth the effort.
Build script testing
For convention plugins, write tests:
// build-logic/convention/src/test/kotlin/ConventionPluginTest.kt
class AndroidLibraryConventionPluginTest {
@TempDir lateinit var testProjectDir: File
@Test fun plugin_applies_and_configures() {
File(testProjectDir, "settings.gradle.kts").writeText("")
File(testProjectDir, "build.gradle.kts").writeText("""
plugins { id("myapp.android.library") }
android { namespace = "com.test" }
""".trimIndent())
val result = GradleRunner.create()
.withProjectDir(testProjectDir)
.withPluginClasspath()
.withArguments("tasks")
.build()
assertTrue(result.output.contains("assembleDebug"))
}
}
CI-specific optimizations
# .github/workflows/ci.yml
- uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
develocity-access-key: ${{ secrets.DEVELOCITY_KEY }}
- name: Build
run: ./gradlew assembleDebug --no-daemon --parallel --build-cache
cache-read-only=trueon PRs — don't push ephemeral builds to cache--no-daemonon CI — avoids daemon memory bloat in containersetup-gradleaction handles caching, artifact uploads, scan publishing
Migration playbook — going from monolith to convention plugins
- 01
Week 1: introduce build-logic
Set up the composite build skeleton. Create AndroidLibraryConventionPlugin with just minSdk / compileSdk. Apply to one library module.
- 02
Week 2: extract Compose + Hilt conventions
Build AndroidComposeConventionPlugin and AndroidHiltConventionPlugin. Apply to all library modules.
- 03
Week 3: feature convention
Combine library + compose + hilt into AndroidFeatureConventionPlugin. Migrate all feature modules to a 3-line build.gradle.kts.
- 04
Week 4: version catalog
Move every hardcoded version and dependency into libs.versions.toml. Create bundles for common groups (compose, testing, networking).
- 05
Week 5: configuration cache
Enable org.gradle.configuration-cache=true with problems=warn. Work through issues until clean. Flip to problems=fail.
- 06
Week 6: build scans + remote cache
Publish build scans on every CI run. Set up remote cache (if budget allows). Baseline build times before/after each change.
Common anti-patterns
Build script problems
- Copy-paste android { } blocks across 30 modules
- Hardcoded versions in build scripts
- kapt when KSP is available
- org.gradle.jvmargs with -Xmx1g (too small)
- buildconfig / aidl enabled by default everywhere
- No configuration cache (slow incremental)
Modern builds
- Convention plugins own the shared config
- Version catalog with bundles + plugin refs
- KSP for Hilt, Room, Moshi
- JVM args 6g+, UseG1GC, Kotlin daemon 4g
- Build features off by default; enable per-module
- Configuration cache + build cache + parallel
Key takeaways
Practice exercises
- 01
Bootstrap build-logic
Create the build-logic composite build with one AndroidLibraryConventionPlugin. Apply it to one module and verify it still builds.
- 02
Extract feature plugin
Build AndroidFeatureConventionPlugin combining library + compose + hilt. Migrate all feature modules to use it.
- 03
Enable configuration cache
Set org.gradle.configuration-cache=true with warn. Run ./gradlew build --info, fix the top-5 reported incompatibilities.
- 04
Publish a build scan
Add Develocity plugin. Run ./gradlew build --scan. Inspect the task timing breakdown.
- 05
Dependency audit
Add the dependency-analysis plugin. Run buildHealth. Remove 3 unused dependencies and fix one api/implementation mismatch.
Next
Return to Module 14 Overview or continue to Module 15 — Modularization at Scale.