Since the very beginning, Gradle build system relied on Maven artifacts for managing dependencies and their versions. It was a good move from the adoption point of view (access to large library repositories). However, it also meant that for a long time, Gradle lacked a couple of very useful native features. A central place for managing all dependency versions was one of them. All we could to is using Maven BOM files, a third-party plugin or hand-made plain maps. Fortunately, the new Gradle 7.4 finally covered this gap and introduced Version Catalogs. In this article, I’d like to show, how to use them in your build.

tl;dr;

Version Catalogs are a native Gradle solution for managing dependency versions in your build scripts. They provide a version catalog DSL, support for all core Gradle features, subprojects, and ability for importing external version catalogs.

Problem statement

Let’s assume that we have an application project in Gradle. Usually, such projects use dozens of other libs: the framework, testing libraries, etc. For all of them we need to specify the version to use. The basic way to do this is putting them in the artifact names:

plugins {
	id("org.springframework.boot") version "2.7.0"
	id("io.spring.dependency-management") version "1.0.11.RELEASE"
	kotlin("jvm") version "1.6.21"
	kotlin("plugin.spring") version "1.6.21"
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	implementation("io.github.microutils:kotlin-logging-jvm:2.1.20")
	implementation("com.google.guava:guava:31.1-jre")

	testImplementation("org.springframework.boot:spring-boot-starter-test")
	testImplementation("org.testcontainers:testcontainers:1.17.2")
	testImplementation("org.testcontainers:mongodb:1.17.2")
}

However, as the project grows, this approach becomes more and more inconsistent. At some point, we notice that the same version is used in many places. Other projects use BOM files for controlling their versions. We have more and more plugins, and subprojects add another level of complexity. The naive solution could be putting all versions into a map:

val versions = mapOf(
	"spring_boot" to "2.7.0",
	"kotlin_logging" to "2.1.20",
	"guava" to "31.1-jre",
	"testcontainers" to "1.17.2"
)

dependencies {
	implementation("org.springframework.boot:spring-boot-starter")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	implementation("io.github.microutils:kotlin-logging-jvm:${versions["kotlin_logging"]}")
	implementation("com.google.guava:guava:${versions["guava"]}")

	testImplementation("org.springframework.boot:spring-boot-starter-test")
	testImplementation("org.testcontainers:testcontainers:${versions["testcontainers"]}")
	testImplementation("org.testcontainers:mongodb:${versions["testcontainers"]}")
}

Limitations

The solution with the plain maps has some limitations. We hit them quickly, if we try to use them in plugins section:

plugins {
	id("org.springframework.boot") version versions["spring_boot"]
	id("io.spring.dependency-management") version "1.0.11.RELEASE"
	kotlin("jvm") version "1.6.21"
	kotlin("plugin.spring") version "1.6.21"
}

Unfortunately, it will not work. The compiler would complain: “Unresolved reference: versions” due to how Gradle internals work. This means that for plugins, we need to find other way or leave them as-is.

In short…

Simple, in-house solutions for managing versions usually don’t support many Gradle use cases. It’s easy to hit their limitations and waste time on reworks.

Gradle version catalog approach

For a long time, the only other option was using either a third-party plugin or Maven BOM files. Fortunately, Version Catalogs introduced in Gradle 7.4 as a stable feature (they had experimental status since 7.0, though) finally help us managing versions directly in Gradle build scripts. The fact that they are a native Gradle feature has several benefits:

  • they support all the core Gradle functionality out-of-the-box, including subprojects
  • they can manage versions of libraries, plugins, and even dependencies in buildscript section. Generally we can access versions in every place of build.gradle.kts.

When it comes to features, this is what we can do with them:

  • we can create version catalogs in settings.gradle.kts using a DSL or in TOML files
  • grouping libraries commonly used together into bundles
  • sharing a single version between several dependencies
  • support for version ranges
  • auto-completion in IDE for dependencies from version catalog
  • importing external (shared) version catalogs

Version catalogs scale gracefully, as the project grows. We can start with a simple local version catalog. Later, we can add subprojects. If we have multiple repositores, we can publish a common version catalog and import it.

Example build script

In this article, we will create a version catalog in settings.gradle.kts file using a simple DSL. However, we can also create it using TOML files. Here’s what we need to add to settings.gradle.kts:

dependencyResolutionManagement {
  versionCatalogs {
    create("libs") {
      version("testcontainers", "1.17.2")

      library("kotlin_logging", "io.github.microutils:kotlin-logging-jvm:2.1.20")
      library("guava", "com.google.guava:guava:31.1-jre")
      library("testcontainers-core", "org.testcontainers", "testcontainers")
        .versionRef("testcontainers")
      library("testcontainers-mongodb", "org.testcontainers", "mongodb")
        .versionRef("testcontainers")

      bundle("testcontainers", listOf(
          "testcontainers-core", "testcontainers-mongodb"
      ))
    }
  }
}

Here we create a single catalog called libs. We can have as many catalogs as we want. For example, we can create separate catalogs for plugins and testing dependencies. Below, we can see what each DSL element does:

  • version – declares a single, named version that can be shared between multiple dependencies or accessed in build.gradle.kts
  • library – declares a library version. We can either set the version in the artifact reference, or use .versionRef() to access the shared version number declared by the version() directive.
  • bundle – creates a bundle that groups multiple dependencies commonly used together. In the build script, we just use the bundle, and Gradle will know that it must use all the listed dependencies.

Let’s notice that for every version, library, and bundle, we define an alias (the first argument). It’s up to us how this alias looks like. We will use it to access the versions in the build script:

dependencies {
	implementation("org.springframework.boot:spring-boot-starter")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	implementation(libs.kotlin.logging)
	implementation(libs.guava)

	testImplementation("org.springframework.boot:spring-boot-starter-test")
	testImplementation(libs.bundles.testcontainers)
}

Gradle generates type-safe accessors for everything declared in the version catalogs, e.g. libs.bundles.testcontainers. This means that the IDE can assist us with auto-completion and warn us if we try to do something illegal. The accessor names are built from aliases. In the alias name, we can use special characters like _ or – but Gradle normalizes all of them to dots:

  • version testcontainers becomes libs.versions.testcontainers.get()
  • library kotlin_logging becomes libs.kotlin.logging
  • bundle testcontainers becomes libs.bundles.testcontainers

In the example, we can also see that we cannot use version catalogs for libraries, whose versions are controlled through e.g. BOM files. This is why we don’t have Spring dependencies in settings.gradle.kts.

Sample code

Find a complete example on Github: zone84-examples/versioncatalog-demo

Plugins in Gradle version catalogs

We mentioned earlier that other solutions may have trouble managing plugin versions. However, Gradle version catalogs make it easy, because plugins are first-class citizens of the version catalog DSL. Let’s create another version catalog in our settings.gradle.kts called tools:

dependencyResolutionManagement {
  versionCatalogs {
    create("tools") {
      version("kotlin", "1.6.21")

      plugin("spring-boot", "org.springframework.boot")
        .version("2.7.0")
      plugin("spring-dependencymanager", "io.spring.dependency-management")
        .version("1.0.11.RELEASE")
      plugin("kotlin-lang", "org.jetbrains.kotlin.jvm")
        .versionRef("kotlin")
      plugin("kotlin-spring", "org.jetbrains.kotlin.plugin.spring")
        .versionRef("kotlin")
    }
    create("libs") {
      // ...
    }
  }
}

This is how we use it in our build.gradle.kts in plugins section:

plugins {
	alias(tools.plugins.spring.boot)
	alias(tools.plugins.spring.dependencymanager)
	alias(tools.plugins.kotlin.lang)
	alias(tools.plugins.kotlin.spring)
}

The last use case is a depencency declared directly in buildscript section. This is also supported, although in a slightly different way:

buildscript {
  repositories {
    gradlePluginPortal()
  }
  dependencies {
    classpath("com.example.something:${tools.versions.something.get()}")
  }
}

Healthy version management practices

We have a tool that helps us managing our versions easily. Now we also need a couple of healthy practices within the team to make a good use of it. The main advice is updating versions regularly, e.g. once a month. Why this is important?

  • reduce migration burden. Sometimes, the new version introduces an incompatible change. By bumping versions regularly, we consume such changes in small increments, rather than doing a complex rework once in a while.
  • easier to spot issues. If the new version introduces a regression, it’s easier to find the root cause, if the number of changes is smaller.
  • avoiding end-of-life problem and solutions that make version bumps complex.
  • security reasons. You consume security fixes regularly. If an urgent, critical patch appears, it’s much easier to consume it if your project is up-to-date.

I suggest creating a recurring task in your issue manager to bump the versions. Remember to keep discipline and keep all versions in a single place. If your team manages multiple project repositories, consider creating a shared version catalog.

In short…

Bump your versions regularly. You can create a recurring task in your project to do it e.g. once a month.

Conclusion

In this article, I covered the basics of creating Gradle version catalogs. They are sufficient for single- and multi-module projects with locally stored catalogs. Sharing version catalogs between several projects is covered in the follow-up article How to share Gradle version catalogs between projects.

Version catalogs are still a young feature. If you wonder whether you should use them, remember that over time, they should become a standard syntax that every developer knows. For this reason, it’s worth applying them in your projects, especially that the migration is very easy. If you want to learn more, just reach out the Gradle User Guide.

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments