6 min read

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.

AndroidGradleBuildDependenciesVersion Catalogs
HR

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 releaseRuntimeClasspath and 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:settings stopped 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:

  1. Create libs.versions.toml with the dependencies that appear in more than one module. Those are the only ones that can drift, and they're where the value is.
  2. Convert module by module, one PR each — mechanical diffs, easy review.
  3. Add plugins next, since that's where a mismatch actually fails.
  4. 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.

Keep reading