Stop Dependency Drift in Multi-Module Gradle Builds with Version Catalogs
Scattered dependency declarations cause version drift in multi-module Gradle builds. Version Catalogs centralize versions in a type-safe TOML file.
23 Apr 2026, 22:54 UTC

The version drift you already have
If your Gradle build has more than a handful of modules, you have probably seen this: one module declares Spring Boot 3.2, another declares 3.3, and a third pulls in a transitive version—one brought in indirectly by another dependency—that you never chose. Over time the build files become an archaeological record of half-finished upgrades. Auditing which version of a library is actually on the runtime classpath turns into a manual search across dozens of build.gradle.kts files.
Gradle Version Catalogs offer a direct fix. They move every shared version, library, and plugin into a single gradle/libs.versions.toml file at the root of your project. The build then generates type-safe accessors—names like libs.spring.boot—that are validated at configuration time, before any task runs. A typo fails immediately instead of surfacing later as a mysterious dependency resolution error.
How the catalog maps to your build
The catalog file is plain TOML (Tom's Obvious, Minimal Language), a simple configuration format. It has three main sections: [versions] for version strings, [libraries] for dependency coordinates, and [plugins] for Gradle plugins. Each entry gets an alias that becomes part of the generated accessor name.
For example, a version entry named spring-boot and a library entry named spring-boot-starter produce the accessor libs.spring.boot.starter. A plugin entry named spring-boot produces libs.plugins.spring.boot. Gradle checks these names during configuration, so a misspelled alias is a build error, not a runtime surprise.
A minimal worked example
Suppose you want to centralize Spring Boot across two modules. In gradle/libs.versions.toml, add:
[versions]
spring-boot = "3.3.0"
[libraries]
spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" }
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
Then in a module's build.gradle.kts, reference the accessors instead of hard-coded strings:
plugins {
alias(libs.plugins.spring.boot)
}
dependencies {
implementation(libs.spring.boot.starter)
}
To confirm the catalog is working, run gradle dependencies --configuration runtimeClasspath from the module directory, or gradle :your-module:dependencies --configuration runtimeClasspath from the project root. This command requires no special permissions beyond normal build execution. The expected check is that the resolved dependency tree shows the version declared in the TOML file, not a stray version from a transitive dependency. If the accessor does not exist, Gradle will fail during configuration with an unresolved reference error—usually caused by a misplaced entry or an alias that does not match the file structure.
Next, change the version in the TOML file to a different value and run gradle build from the root directory. Every module that references the catalog accessor should pick up the new version without any edits to individual build files. If a module still resolves the old version, it is likely declaring the dependency directly somewhere. If the change causes unexpected resolution differences, revert the TOML entry to its previous value and re-run the dependencies task to confirm the build returns to its prior state.
Where catalogs stop helping
TOML entries are intentionally simple. They map a name to a coordinate and a version, but they cannot express everything a dependency block can. If you need to exclude a transitive dependency, declare a capability, or use a rich version constraint, you still write a normal dependencies block. The catalog reduces repetition; it does not eliminate the full Gradle dependency DSL.
Adoption is also not all-or-nothing. You can migrate one module at a time, keeping legacy declarations alongside catalog entries until the transition is complete. The main risk here is inconsistency: a module that still hard-codes a version will drift again. A shared convention plugin or a CI validation task that fails when known libraries are declared outside the catalog can prevent this.
One practical limitation is IDE support. While modern IntelliJ IDEA and Android Studio recognize catalog accessors, newly added entries may not appear in autocompletion until you run a Gradle sync. Very old Gradle versions do not support catalogs at all, so check your wrapper version before starting.
Start with the worst offenders
You do not need to rewrite every build file this week. Pick the three or four dependencies that appear in the most modules—often logging frameworks, JSON libraries, or test utilities—and extract them into gradle/libs.versions.toml. Replace the hard-coded declarations in those modules with accessors, then run gradle dependencies to verify the resolved versions have not changed unexpectedly. Once that pattern feels safe, expand the catalog module by module.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.