Gradle Plugins Best Practices

When working on multi-module Gradle projects, you need to ensure that your build behaves consistently. In this post, I'll explain why how you declare and apply your Gradle plugins matters, and how to avoid Classpath and transitive dependency issues.
The Classpath and Transitive Dependencies Explained
Imagine your plugin is a recipe that calls for specific ingredients. In Gradle, these 'ingredients' are the libraries and dependencies your plugin needs to operate correctly. Your pizza plugin might call for tomato sauce, basil and mozzarella. But the tomato sauce itself is made up of garlic, tomatoes and salt. Those extra ingredients are like transitive dependencies - the ingredients your direct ingredients rely on to be complete.
The Classpath is like the pantry where Gradle gathers all these items. If every module in your project goes shopping independently for their ingredients, they might end up with tomatoes from different suppliers, or sauces prepared differently - even if the original recipe (your plugin) is the same. This inconsistency can lead to subtle differences in the final dish (your build).
If it's a pizza we're talking about, a slight change is no big deal. But inconsistencies in Gradle plugin dependencies can lead to build errors that are really hard to track down and resolve, like this one I ran into recently:
... "Caused by: java.lang.NoSuchMethodError: 'java.io.File com.squareup.kotlinpoet.FileSpec.writeTo(java.io.File)
What the hell do I do with this?
Centralising Plugin Declarations
One way to ensure all your plugins use the same classpath is to declare them in your top-level build.gradle:
plugins {
id("com.android.application") version "x.y.z" apply false
alias(libs.plugins.google.ksp) apply false
}
This forces dependency resolution to happen in one central place, so every module gets the same 'shopping list' (Classpath). The apply false
ensures the plugin is not actually applied to the root-level project - it just makes it available to all subprojects. As the Now In Android project puts it:
By listing all the plugins used throughout all subprojects in the root project build script, it ensures that the build script classpath remains the same for all projects. This avoids potential problems with mismatching versions of transitive plugin dependencies. A subproject that applies an unlisted plugin will have that plugin and its dependencies appended to the classpath, not replacing pre-existing dependencies.
Jake Wharton also sums it up nicely:
Remember, 100% of the plugins you use need to be specified in the root with apply false
or else you'll get incredibly cryptic behavior.
This centralised approach minimises the risk of version mismatches or subtle discrepancies between modules.
Tl;DR
Centralise your Gradle plugins by declaring them in the top-level build.gradle
, with apply false
if the plugin isn't needed at the root level. This ensures a uniform classpath and consistent transitive dependencies across your project.
Happy pizza!