Getty Images

How to tame Gradle dependency version management

Need to quickly and easily switch between versions of your dependencies at build time? Gradle's dependency catalogs are the answer. Here's how to use them.

Gradle is one of the most popular build tools for the Java ecosystem. It provides a mechanism by which one can stick with a build-by-convention approach, as Maven does. Alternatively, one can choose an imperative build that enables execution of arbitrary operations as part of the build system, as do Make and Ant.

However, the power of Gradle comes at a cost. Users must deliberately choose how much responsibility they wish to assume for their builds. Gradle also creates a heavy reliance on the development team's ability to support and describe all of that flexibility.

This tutorial addresses a dependency management problem I personally encountered: an attempted integration into a common, maintainable, easy-to-work-with method that reduces the sheer amount of dependencies and their versions to accommodate.

We will not cover all the ways you can handle dependencies in Gradle, such as scopes or transitive dependency management or a host of other concerns. This is purely about how to centralize dependency management, and provide it to other projects using version catalogs.

Problem statement

Imagine a simple example: two projects that use the Junit 5 unit testing framework. We have two build.gradle.kts files, one for each project, one has blocks that look like this:

// project A
dependencies {
implementation("com.mycorp:core-lib:1.19.0")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3")
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.3")
testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.3")
}

The other project, which is perhaps a touch more recent, looks like this:

// project B
dependencies {
implementation("com.mycorp:core-lib:1.22.2")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.0")
testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0")
}

This works. We can also externalize version control, such that we're not repeating "5.10.0" for JUnit:

// project B, with externalized version control, as ONE possibility
val junitVersion = "5.10.0"
dependencies {
implementation("com.mycorp:core-lib:1.22.2")
testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}")
testImplementation("org.junit.jupiter:junit-jupiter-engine:${junitVersion}")
testImplementation("org.junit.jupiter:junit-jupiter-params:${junitVersion}")
}

This is better, but still isn't very good.

Project A still has the older dependencies. We could update the specific jUnitVersion value for project A, but we're still burdened with maintaining every other dependency version in Project A -- we'd not only have to update jUnitVersion but the version for core-lib as well.

Thankfully, Gradle supports the concept of a bill of materials (BOM) (as does Maven). This means we can import the JUnit platform and inherit the versions from there instead, which gives us some relief of project management:

// project B, using the JUnit BOM to pull in specific versions
val junitVersion = "5.10.0"
dependencies {
implementation("com.mycorp:core-lib:1.22.2")
implementation(platform("org.junit:junit-bom:${junitVersion}"))
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("org.junit.jupiter:junit-jupiter-engine")
testImplementation("org.junit.jupiter:junit-jupiter-params")
}

What we now have is a declaration of the version of the BOM, which provides dependency management for the other JUnit dependencies, and potentially others. We don't need to specify the version for junit-jupiter-api -- it comes from the BOM, and just so happens to be 5.10.0 (just like the BOM) but there's no mandatory requirement that it be the same version.

However, we're still stuck with managing the BOM version, as well as core-lib.

To some degree, this is a problem we'll always have. We could declare an umbrella dependency that serves the same role as a BOM: a dependency with one transitive dependency on core-lib, and another dependency imported for the test scope that had a transitive dependency on every test library we want.

That feels rather coarse, however. If we have 130 MB of possible dependencies, it's asking a bit much to force every project to have all 130 MB of dependencies in the name of potential simplicity.

The better solution is to have our own version catalogs, so that we follow the pattern used for JUnit above with a few enhancements.

Understand the basics of version catalogs

A version catalog is effectively a distribution element that enables us to specify a number of elements in a library for Gradle. One Gradle build can produce it, and another can consume it.

A version catalog can specify version information, plugin references, library references, and lastly, bundles. Let's see how we can create one with the information my work needed most.

Let's start with a basic build.gradle.kts. We'll include a reference for the catalog, but leave it empty at first and fill it out as we go.

// the version-catalog producer
plugins {
`version-catalog`
`maven-publish`
}

group = "com.theserverside"
version = "1.0-SNAPSHOT" 

repositories {
mavenCentral()
}

catalog {
versionCatalog {
// this is where our catalog will be defined.
}
}

publishing {
publications {
create<MavenPublication>("maven") {
// this can be done more efficiently, but this is enough
groupId = "com.theserverside"
artifactId = "tss-bom"
version = "1.0"

from(components["versionCatalog"])
}
}
}

OK. Now we can start thinking about what we need. The first task is to define a version. This is a useful placeholder, and is a named element that we can use internally and externally, as we'll see soon. Let's change our catalog block:

// the producer
catalog {
versionCatalog {
version("junit", "5.10.0")
}
}

This creates a version reference internally (which won't mean much to us yet), but it's also exported to the version catalog. We can publish the catalog right now:

./gradlew publishToMavenLocal

We also can consider a project that consumes the catalog, as an equivalent to our Projects A and B from earlier in this tutorial.

We didn't care what settings.gradle.kts held for the producer of the catalog, but we do for the consumer. Let's start there. Here's the settings.gradle.kts for a tss-consumer project:

// the version catalog consumer's settings.gradle.kts
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
gradlePluginPortal()
}
}

plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
}

dependencyResolutionManagement {
repositories {
mavenLocal()
mavenCentral()
}
versionCatalogs {
create("libs") {
from("com.theserverside:tss-bom:1.0")
}
}}

rootProject.name = "tss-consumer"

The element dependencyResolutionManagement declares the use of a version catalog, giving it a name (libs) and giving it a source of the version catalog project. That means that can access the version catalog through a named variable, libs.

Let's see how we can use the JUnit version we declared earlier. It's going to be ugly because it's a Provider and not a String, but that's OK because versions aren't really meant to be used like this.

Here's our build.gradle.kts that pulls the junit-bom version from our version catalog:

// the version catalog's consumer
plugins {
    kotlin("jvm") version "1.9.0"
}

group = "com.theserverside"
version = "1.0-SNAPSHOT"

repositories {
  mavenLocal()
  mavenCentral()
  google()
}

dependencies {
    implementation(platform("org.junit:junit-bom:${libs.versions.junit.get()}"))
    testImplementation("org.junit.jupiter:junit-jupiter-api")
    testImplementation("org.junit.jupiter:junit-jupiter-engine")
    testImplementation("org.junit.jupiter:junit-jupiter-params")
}

tasks.test {
  useJUnitPlatform()
}

kotlin {
    jvmToolchain(8)

}

Note the first line of the dependencies block, where we import the BOM for the JUnit ... but we now pull the version from libs. Again, it's a Provider so we need to call get() to pull the value out.

This is useful, sort of. Now we have our BOM version sourced elsewhere, but we can do better. Let's add the BOM as a library, which means we won't need to dereference the Provider like that.

Let's go back to the theserverside-bom project, and update the catalog:

// the version catalog's producer
catalog {
versionCatalog {
version("junit", "5.10.0")
library("junit-bom", "org.junit", "junit-bom")
.versionRef("junit")
}
}

Now there are two elements: a version and a library. The library is a reference to the JUnit BOM, using a "version reference" by name.

Note that we named the library junit-bom, which isn't a valid variable name in Kotlin. Gradle deforms the name and replaces the dashes with dereferenced periods, so when we want to use it we'll use junit.bom instead -- which is a perfect time to see it in action.

Let's modify the consumer again, but this time we'll pull in the BOM with a type-safe accessor, one that makes a lot more sense than what we did with the version:

// the version catalog's consumer
dependencies {
implementation(platform(libs.junit.bom))
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("org.junit.jupiter:junit-jupiter-engine")
testImplementation("org.junit.jupiter:junit-jupiter-params")
}

We have exported the JUnit BOM with our version catalog, and we can now use it as a workable platform. We can do this with any library we want, but let's chase a few more options with this setup.

What we want to do now is remove our manual declaration of all the JUnit subprojects that we get from the BOM.

In our library reference, we specified the version of the BOM with versionRef("junit"). We can also declare a library without a version. Let's do it with the junit-jupiter-api first. Here's the catalog again:

// the version catalog's producer
catalog {
versionCatalog {
version("junit", "5.10.0")
library("junit-bom", "org.junit", "junit-bom")
.versionRef("junit")

library("junit-jupiter-api", "org.junit.jupiter" "junit-jupiter-api")
.withoutVersion()
}
}

This provides a named reference to the junit-jupiter-api reference through the libs variable, under the full name of libs.junit.jupiter.api. Here's our consumer's dependencies again, updated to use the new library reference:

// the version catalog's consumer
dependencies {
implementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter.api)
testImplementation("org.junit.jupiter:junit-jupiter-engine")
testImplementation("org.junit.jupiter:junit-jupiter-params")
}

We are getting the version of junit-jupiter-engine from the BOM, which we're getting via the version catalog.

We're nearly feature-complete, but we've left out something we really want: bundles.

A bundle is a list of library names that can be imported as a group. Let's define the other JUnit dependencies we want as libraries, and then create a bundle from them. We'll use Gradle's imperative nature to help build the list.

// the version catalog's producer
catalog {
versionCatalog { 
version("junit", "5.10.0")
library("junit-bom", "org.junit", "junit-bom")
.versionRef("junit")

val junitDeps = listOf(
"junit-jupiter-api",
   "junit-jupiter-engine",
"junit-jupiter-params"
)
junitDeps.forEach { libName ->
library(libName, "org.junit.jupiter", libName)
.withoutVersion()
}
bundle("junit", junitDeps)
}
}

We're not including the BOM in the bundle, as it's not an actual dependency, and we have to use it separately anyway as a platform in Gradle. But now that we have defined the bundle, we can actually import just the bundle. Here's the consumer with the new dependencies block:

// the version catalog's consumer
dependencies {
implementation(platform(libs.junit.bom))
testImplementation(libs.bundles.junit)
}

Then we can define references to BOMs that consumers can import, and they can use the dependencies from those BOMs without version references.

Here's where the real power comes in.

The version catalog defines the libraries that are associated with the BOMs as well, because they can be used by name in such a way that the IDE can autocomplete the references.

Moreover, you can build groups of associated libraries that can be imported whole. We could, for example, define a set of libraries that our company uses to work with Postgres (a Jackson serializer, the Postgres driver, perhaps JDBI) and use a single implementation block:

// the version catalog's consumer
dependencies {
implementation(platform(libs.junit.bom))
implementation(libs.bundles.rdms)
testImplementation(libs.bundles.junit)
}

Now we have an easy, cohesive way to manage dependencies in our version catalog, and downstream consumers can update simply by referring to the correct version catalog project.

Define the plugin with the version catalog

There's one other type we can put into the version catalog: the plugin. In our consumer project, the one that uses the version catalog, we define the kotlin plugin explicitly:

// the version catalog's consumer
plugins {
kotlin("jvm") version "1.9.0"
}

We can put this in the version catalog, and import it in nearly the same way we have imported everything else from the version catalog. Here's our full version catalog provider build.gradle.kts script, including Kotlin 1.9.0 as a plugin:

// the version catalog's producer
plugins {
`version-catalog`
`maven-publish`}

group = "com.theserverside"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

catalog {
versionCatalog {
version("junit", "5.10.0")
version("kotlin", "1.9.0")

plugin("kotlin", "org.jetbrains.kotlin.jvm")
.versionRef("kotlin")

library("junit-bom", "org.junit", "junit-bom")
.versionRef("junit")

val junitDeps = listOf(
"junit-jupiter-api",
"junit-jupiter-engine",
"junit-jupiter-params"
)
junitDeps.forEach { libName ->
library(libName, "org.junit.jupiter", libName)
.withoutVersion()
}
bundle("junit", junitDeps)
}
}  

publishing {
publications {
create<MavenPublication>("maven") {
groupId = "com.theserverside"
artifactId = "theserverside-bom"
version = "1.0"

from(components["versionCatalog"])
}
}
}

We can import this with the alias language construct in our consumer's build.gradle.kts, represented in its entirety as follows:

// the version catalog's consumer
plugins {
alias(libs.plugins.kotlin)  //  note import of libs.plugins.kotlin here
}  

group = "com.theserverside"
version = "1.0-SNAPSHOT"

repositories {
mavenLocal()
mavenCentral()
}  

dependencies {
implementation(platform(libs.junit.bom))
testImplementation(libs.bundles.junit)
}

tasks.test {
useJUnitPlatform()
}  

kotlin {
jvmToolchain(8)
}

Thus, if we update Kotlin in our version catalog to, say, 1.9.1, as long as we pull in the updated version catalog our project gets the correct (and standardized) Kotlin plugin. We haven't changed our settings.gradle.kts for the consumer at all; updates normally involve changing the imported version catalog's version number.

Benefits of using the version catalog

The version catalog makes using a specific version, including from a BOM, trivial. However, sometimes you want to use a different version -- perhaps for development (to test using a snapshot of a library) or to support older features (i.e., you want to use an old version of a library). That's easy to do with a version catalog, because a version catalog does not change your build.

If you import a bundle, you get the versions included in that bundle. Overriding a specific library from the bundle is certainly doable; you just use the exclude() method in the Gradle ExternalModuleDependency mechanism, followed by whatever you might need to include.

For example, imagine that you have a version catalog that defines multiple libraries in a given bundle bundleOne: com.theserverside:a:1.0, com.theserverside:b:1.1, and com.theserverside:c:1.3, and you've realized that you actually need version 1.0 of library b. This would look like the following:

dependencies {
   implementation(libs.bundle.bundleOne) {
       exclude("com.theserverside:b")
   }
   implementation("com.theserverside:b:1.0")
}

This would pull in libraries a and c from the bundle, excluding library b, allowing us to specify the exact version we need in the classpath.

Minor tradeoffs with Gradle dependency version management

Is all of this perfect? No, it isn't.

For one thing, I haven't found a way around importing the BOMs before the bundles. The version comes from the imported BOM for the bundles, unless the bundles specifically include the versions themselves. You either include the BOM and get the benefit of it, or you include the BOM in the catalog for no good reason because you must manually specify the versions anyway.

Also, importing a BOM makes the versions visible for the catalog. You still must specify the library dependencies themselves. Because Gradle is imperative, you could theoretically fetch the BOM and iterate through its dependencies, which creates the library references as you proceed. This might be nontrivial, however, because a BOM has multiple formats.

Lastly, bundles don't have scopes attached to them. We import a bundle at a scope, such as api, implementation, testImplementation, and everything in that bundle is imported at that scope. We can't define a mongodb bundle that includes a MongoDB driver for implementation scope, and then a testcontainers-mongodb library for testImplementation scope in the same bundle. We'd have both libs.bundles.mongodb and libs.bundles.test.mongodb bundles, and import them at the proper visibility.

However, these are minor caveats. Even if they were severe, they're addressable, and they hardly make the version catalogs impossible to use.

Dig Deeper on Development tools for continuous software delivery

App Architecture
Software Quality
Cloud Computing
Security
SearchAWS
Close