Managing dependencies effectively is a critical part of any application’s development process. Gradle provides two features when it comes to dependency management: Version Catalogs and Dependency Platforms (referred to simply as “Platforms” for the remainder of this post). While Version Catalogs help to centralize dependency coordinates, Platforms are used to ensure that all dependencies in a project align with a consistent set of versions.
In this blog post, we’ll break down the differences between these two features, and explore their use cases. Because Version Catalogs and Platforms are used differently when building a library or a framework compared to building an application, we’ll focus specifically on dependency management for application engineers.
Version Catalogs are a relatively recent feature designed to centralize and standardize the management of dependencies across your project. It provides a single location to declare dependency versions, aliases, and even plugin coordinates, making it easier to keep your build configuration consistent and maintainable.
In order to make the remainder of the post easier to follow, suppose we have the following build script in our project:
// app/build.gradle.kts
plugins {
id("org.gradlex.jvm-dependency-conflict-resolution") version "2.1.2"
}
dependencies {
implementation("org.codehaus.groovy:groovy:3.0.5")
implementation("org.codehaus.groovy:groovy-json:3.0.5")
implementation("org.codehaus.groovy:groovy-nio:3.0.5")
implementation("org.apache.commons:commons-text:1.13.0")
}
With Version Catalogs, we can centralize the dependency coordinates in a libs.versions.toml
file in the gradle/
folder of the project.1
In the build scripts, instead of repeating the same dependency coordinate string, we reference catalog entries using a generated accessor.
This has several advantages over plain string declarations:
Let’s look at a Version Catalog for replacing the dependency coordinates in the build script:
# gradle/libs.versions.toml
[versions]
groovy = "3.0.5"
[libraries]
groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" }
groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "groovy" }
groovy-nio = { module = "org.codehaus.groovy:groovy-nio", version.ref = "groovy" }
commons-text = { module = "org.apache.commons:commons-text", version = "1.13.0" }
[plugins]
dependencyConflicts = { id = "org.gradlex.jvm-dependency-conflict-resolution", version = "2.1.2" }
Here we can see several Version Catalog features:3
The [libraries]
block contains the catalog entries.
Each catalog entry consists of a name and a definition, which includes the module coordinates (group ID and artifact ID) along with a version.
Dependency versions can either be declared in the catalog entry (as for commons-text
), or be defined in the [versions]
block if there are multiple entries sharing the same version.4
Plugin IDs can also be defined in the version catalog, but need to go to the [plugins]
section instead of [libraries]
.
Using the provided Version Catalog, we can create a build script like this:
// app/build.gradle.kts
plugins {
alias(libs.plugins.dependencyConflicts)
}
dependencies {
implementation(libs.groovy.core)
implementation(libs.groovy.json)
implementation(libs.groovy.nio)
implementation(libs.commons.text)
}
As you can see there are no dulicated strings anymore.
This is not only true for the versions, but also for the coordinates.
For example if we wanted to upgrade from Groovy 3 to Groovy 4, which involces changing the cooridnates from org.codehaus.groovy
to org.apache.groovy
, we would only need to change the catalog without modifying any build scripts.
Now that we’ve explored Version Catalogs, let’s turn our attention to Platforms.
Many application developers are likely already familiar with Platforms, as frameworks like Spring Boot or Micronaut provide BOMs (Bill of Materials) that can be imported into a Gradle build using the Platform feature.5
Importing a BOM as a Platform is done with the platform
method in the build script.6
We’ll look at an example of this shortly, after we define our own Platform.
To define a Platform we need to add a new subproject to our build.
The new subproject has to apply the java-platform
plugin and the dependencies blog should only define dependency constraints:7
// dependencies/build.gradle.kts
plugins {
id("java-platform")
}
dependencies {
constraints {
api("org.codehaus.groovy:groovy:3.0.5")
api("org.codehaus.groovy:groovy-json:3.0.5")
api("org.codehaus.groovy:groovy-nio:3.0.5")
api("org.apache.commons:commons-text:1.13.0")
}
}
With our dependencies managed by the new Platform, we can now change the build script from the beginning like this:
// app/build.gradle.kts
plugins {
id("org.gradlex.jvm-dependency-conflict-resolution") version "2.1.2"
}
dependencies {
implementation(platform(project(":dependencies")))
implementation("org.codehaus.groovy:groovy")
implementation("org.codehaus.groovy:groovy-json")
implementation("org.codehaus.groovy:groovy-nio")
implementation("org.apache.commons:commons-text")
}
Compared to using a Version Catalog, we had to add an additional dependency statement to the dependencies
bock, we’ve lost the ability to manage plugin versions, and we still need to duplicate dependency data such das the group ID of the Groovy dependencies.
So why would we choose a Platform over a Version Catalog?
At first glance, it might seem like Platforms are unnecessary if you’re already using a Version Catalog. However, there’s one crucial aspect we haven’t discussed yet: controlling transitive dependencies. What happens if a critical vulnerability is discovered in one of those transitive dependencies? For instance, Apache Commons Text depends on Apache Commons Lang3. Now imagine a severe vulnerability is found in Apache Commons Lang version 3.17.0, which is the version used by Apache Commons Text 1.13.0.
Using the Platform that we’ve defined in the previous section we can easily force on upgrade of the Apache Commons Lang3 dependency by adding the following to the constrains
block:
// dependencies/build.gradle.kts
dependencies {
constraints {
// other constraints
api("org.apache.commons:commons-lang3") {
version {
require("3.13.1")
}
}
}
}
The require
declaration for version 3.13.1 tells Gradle the version for Apache Commons Lang3 cannot be lower than 3.13.1, but it can be higher if there’s another transitive dependency to Apache Commons Lang3 asking for a higher version.8
We don’t need to add anything to the build script that depends on Apache Commons Text, because importing the Platform automatically adds the constraint.
This applies to all occurences of Apache Commons Lang3.
So even if we added another dependency that depends on Apache Commons Lang3 transitively, Gradle will apply the constraint and make sure the version cannot be lower than 3.13.1.
Now let’s go back to Version Catalogs and discuss would we could do. What happens if we add Apache Commons Lang3 3.13.1 to the catalog like so:
# gradle/libs.versions.toml
[libraries]
commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.13.1" }
Well, nothing would happen! This is because the Version Catalog just replaces dependency declarations but does not manage transitive dependencies. We would have to explicitly add the dependency to our build script:
// app/build.gradle.kts
dependencies {
// other dependencies ommitted
implementation(lib.commons.text)
implementation(lib.commons.lang3)
}
This unnecessarily clutters the build script since we don’t use Apache Commons Lang3 which is an implementation detail of Apache Commons Text. Furthermore this approach of managing transitive dependencies by adding them as direct dependencies does not scale in larger projects due to the amount of transtive dependencies found in a typical dependency graph.
This distinction is very important since it helps you to choose the right tool for your situation. If you want to unclutter your build scripts and centralize dependency coordinates, use a Version Catalog. If you want to manage versions of your (transitive) dependencies, use a Platform. Of corse it’s also possible to combine the two features: Use a catalog to centralize dependency coordinate declarations, and use a Platform for controlling transitives.
In this blog post, we’ve explored the key differences between Gradle Version Catalogs and Platforms. The most important takeaway is that Version Catalogs are a dependency declaration feature—they simplify and organize how you declare dependencies in your build scripts. In contrast, Platforms are a dependency alignment feature—they control the versions of dependencies across your dependency graph, including those you haven’t explicitly declared in your build scripts. By understanding these distinctions, you can choose the right tool for managing dependencies in your Gradle projects more effectively. If you’re looking for expert guidance or need help with your Gradle builds, I offer consulting services and would be happy to assist!
The gradle/lib.versions.toml
file is a convention that Gradle automatically sets up.
Build authors can use a DSL in the settings script to define Version Catalogs programatically, use a different TOML file, or import multiple files at once. ↩
While Dependabot is quite limited in it’s ability to update Gradle dependencies, Renovate is capable of finding Gradle dependency definitions even in deeply nested project hierarchies and updating them reliably. ↩
I’ve omitted the [bundle]
feature here, because it adds indirection that I find more confusing than helpful.
You can read more about this feature in the official documentation. ↩
I need to note here, that versions in the catalog don’t have to be simple strings. It’s also possible to use rich versions, but in my opinion there’s seldom a need to do this when developing an application. ↩
In fact Micronaut even comes with a published Version Catalog that can be imported into the project so it’s easier to work with dependencies managed by the framework. ↩
There’s a second method called enforcedPlatform
, see the documentation for more details. ↩
Platforms can also contain regular dependency declarations. This is for importing other platforms or for making a platform add dependencies when it is referenced. This way you can for example make your platform always bring in the dependency to the testing framework of your choise. ↩
Using prefer
is just one way of solving this situation.
Gradle’s rich versions provide a lot of flexibility in how Gradle resolves version conflicts.
Check the documentation for more information. ↩