Gradle Version Catalogs and the Dependency Drift They Prevent
Dependency drift is structural, not a discipline problem: the same version string living in five build files will diverge. What a version catalog fixes by construction, what bundles and plugin aliases add, and the transitive conflicts it deliberately does not solve.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — A Gradle version catalog (gradle/libs.versions.toml) gives every dependency exactly one
declaration site, so the version can't diverge across modules. You get compile-time typo checking,
bundles for groups that must move together, plugin aliases (where drift actually breaks builds), and
one-file dependency-bot PRs. It does not resolve transitive conflicts — for those you still need
./gradlew :app:dependencies and, occasionally, a strictly constraint.
What drift actually looks like
Nobody decides to run three versions of OkHttp. It happens like this: :app was on 4.11.0. Six months
ago someone adding :feature:player copied the dependency block from :core:network, which was on
4.9.3. Last month a dependency bot bumped :app to 4.12.0 and nothing else, because nothing else
mentioned OkHttp in a file the bot recognised.
Gradle doesn't fail. Its default conflict resolution picks the highest version on the classpath, so
everything compiles and everything runs — against a version two of your three modules were never
tested with. The symptom arrives later as an inexplicable NoSuchMethodError on a device where R8
kept a different overload, or a behaviour change in a module nobody edited.
The root cause is not carelessness. It's that the version string exists in five places, and five places will diverge. Version catalogs remove the second place.
The catalog in one file
gradle/libs.versions.toml is picked up automatically — no plugin, no settings change beyond what
recent Gradle versions already do:
[versions]
okhttp = "4.12.0"
retrofit = "2.11.0"
compose-bom = "2026.09.00"
hilt = "2.53"
[libraries]
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-material3 = { module = "androidx.compose.material3:material3" }
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
And the build file stops carrying version information at all:
dependencies {
implementation(libs.okhttp)
implementation(libs.okhttp.logging) // same version.ref — can never drift apart
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
}
Two properties come free. libs.okhttp.logging and libs.okhttp share a version.ref, so the
interceptor can't fall behind the client it plugs into — a genuinely common bug. And libs.okhtp is a
typo the Kotlin compiler rejects, where "com.squareup.okhttp3:okhtp:4.12.0" was a string that failed
at resolution time in whichever module was unlucky.
Bundles, and the module that took eight of nine
Some dependencies only make sense as a set. Compose is the obvious one: UI, tooling preview, material3, foundation. Declaring them individually in eleven modules means eleven chances to omit one.
[bundles]
compose = ["compose-ui", "compose-ui-graphics", "compose-material3", "compose-foundation"]
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
Adding a fifth member to the bundle updates every consuming module in one commit. That's the point of a bundle — not the line count, but that membership is defined once and can't be partially applied.
Don't over-bundle. A bundle is for things that must move together, not for "dependencies this module
happens to use". If you find yourself writing libs.bundles.feature-module-stuff, you've built an
alias for a copy-paste block and inherited its problems.
Plugins are where drift actually hurts
Library version skew usually degrades quietly. Plugin skew breaks the build, and it breaks it in the most confusing way possible — a KSP or Hilt version that disagrees with the Kotlin version produces an error message about a class you've never heard of.
// settings-level or root build file
plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}
// module
plugins {
alias(libs.plugins.hilt)
}
Pin KSP, Kotlin, Compose compiler and any annotation processor in the [versions] block with names
that make the coupling visible (kotlin, ksp sharing a prefix), so the next person upgrading Kotlin
sees what else has to move in the same commit.
What a catalog does not fix
Be clear about the boundary, because people expect more than it offers:
- Transitive conflicts. If your image library pulls Coil 2.6 and your analytics SDK pulls Coil 2.4,
the catalog is silent — neither is your declaration. Gradle still picks the highest. Diagnose with
./gradlew :app:dependencies --configuration releaseRuntimeClasspathand pin with a constraint if the resolved version matters. - Whether an upgrade is safe. It centralises the edit, not the decision.
- Unused dependencies. Nothing in the catalog tells you
:feature:settingsstopped needing Retrofit a year ago.
// When you genuinely need to force one
implementation(libs.coil) {
version { strictly("2.6.0") }
}
Use strictly sparingly, and leave a comment saying which transitive dependency you're overriding —
it's a constraint that will confuse someone in eighteen months otherwise.
Migrating without a big-bang PR
You don't need a flag day. Catalog entries and hardcoded coordinates coexist fine, so:
- Create
libs.versions.tomlwith the dependencies that appear in more than one module. Those are the only ones that can drift, and they're where the value is. - Convert module by module, one PR each — mechanical diffs, easy review.
- Add plugins next, since that's where a mismatch actually fails.
- Point Renovate or Dependabot at the catalog and let single-file upgrade PRs land.
Step 1 alone captures most of the benefit. The single-module dependencies can wait indefinitely.
The rule
A version number should exist in exactly one place, and that place should be checked by the compiler. Everything else — bundles, aliases, tidy build files — is a consequence. The question "which version of OkHttp are we on?" should have an answer you can point at, not one you have to go and derive.