Spring dependencies in Gradle can be tricky

Piotr Kubowicz
January 13, 2020

Spring is the most popular Java web framework for many years and Gradle has an established position as a build tool. You might expect it’s easy to find instructions on how to set up those two together — yet the Internet is filled with advice that will get you into trouble. The official Spring documentation does not make the situation any better in this case.

Using Spring in your applications typically means your classpath contains not only Spring Framework itself, but also other Spring projects like Spring Security plus Spring dependencies that are independent libraries. It may require lots of work to get versions of dependencies right, avoiding incompatible versions being used together. So a much better solution is — not to manage all those versions manually and choose a set suggested by Spring. Technically speaking: importing a BOM (bill of materials).

Gradle support for BOM import appeared in April 2018 as a feature preview and officially in release 5.0 (November 2018), but you can still find articles written in April 2019 claiming that “unfortunately Gradle doesn’t have such built-in functionality, but you can use plugin io.spring.dependency-management”. Why?

As Gradle initially did not have this feature, Spring authors independently created Dependency Management Plugin, which hacks Gradle dependency resolution system to make it import BOMs as Maven does. As the plugin predates native Gradle support by more than a year, it became the solution that is easiest to google. People repeat gossip instead of consulting the up-to-date Gradle documentation.

Troublesome Spring Dependency Management Plugin #

Different people suggest to use the plugin in slightly different ways, let’s take a look at probably the most representative one:

In the simplest case, the plugin does what it promises, setting a compatible Spring Security version:

% ./gradlew dependencyInsight --dependency=spring-security

> Task :dependencyInsight
org.springframework.security:spring-security-config:5.1.1.RELEASE (selected by rule)

Now let’s imagine a yet another critical security vulnerability is discovered in Jackson Databind, one of the libraries coming with Spring Boot. Currently our project uses Jackson 2.9.7, there is a patched version 2.10.1 and we want to push it to our production instances immediately, without waiting for Spring Framework to release a new version and then Spring Boot to release a new version based on the updated framework. It’s a critical vulnerability, so better not to wait that long!

As far as we know Gradle, the following fragment should do the job:

Still, let’s double-check we are safe now:

% ./gradlew dependencyInsight --dependency=jackson-databind

> Task :dependencyInsight
com.fasterxml.jackson.core:jackson-databind:2.9.7
   variant "compile" [
      org.gradle.status              = release (not requested)
      org.gradle.usage               = java-api
      org.gradle.libraryelements     = jar (compatible with: classes)
      org.gradle.category            = library (not requested)

      Requested attributes not found in the selected variant:
         org.gradle.dependency.bundling = external
         org.gradle.jvm.version         = 11
   ]
   Selection reasons:
      - Selected by rule
      - By constraint : versions below are vulnerable to CVE-2019-16942

com.fasterxml.jackson.core:jackson-databind:2.9.7
+--- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.9.7
|    \--- org.springframework.boot:spring-boot-starter-json:2.1.0.RELEASE
|         \--- org.springframework.boot:spring-boot-starter-web:2.1.0.RELEASE
|              \--- compileClasspath (requested org.springframework.boot:spring-boot-starter-web)
+--- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.7
|    \--- org.springframework.boot:spring-boot-starter-json:2.1.0.RELEASE (*)
+--- com.fasterxml.jackson.module:jackson-module-parameter-names:2.9.7
|    \--- org.springframework.boot:spring-boot-starter-json:2.1.0.RELEASE (*)
\--- org.springframework.boot:spring-boot-starter-json:2.1.0.RELEASE (*)

com.fasterxml.jackson.core:jackson-databind:2.10.1 -> 2.9.7
\--- compileClasspath

We still use the vulnerable 2.9.7! What happened? We see the constraint we have just typed in Selection reasons, but what is the ‘rule’ there?

The thing is, when you apply Spring’s Dependency Management Plugin to your Gradle project, then in order to be able to understand and control what happens it’s not enough that you know Gradle — you also need to know how the plugin works. For example, a fundamental principle in Gradle dependency management says that in the case of a version conflict, the newer one wins. As we can see here, it’s no longer true after you apply Spring’s plugin. Because the plugin is quite invasive, prepare yourself for bizarre issues if you use it in a more advanced way.

Ok, so how can we fix our issue while still using the plugin? We need to override the BOM property using the plugin-specific syntax (and first find the name of the property controlling version of our dependency):

Native Gradle way #

Instead of relying on a third-party plugin, simply use the built-in BOM import support:

Spring Security dependency is resolved in the same way as when we used the plugin:

% ./gradlew dependencyInsight --dependency=spring-security

> Task :dependencyInsight
org.springframework.security:spring-security-config:5.1.1.RELEASE (by constraint)

but now we can use well-known Gradle mechanisms for controlling transitive dependencies:

% ./gradlew dependencyInsight --dependency=jackson-databind

> Task :dependencyInsight
com.fasterxml.jackson.core:jackson-databind:2.10.1
   variant "compile" [
      org.gradle.status              = release (not requested)
      org.gradle.usage               = java-api
      org.gradle.libraryelements     = jar (compatible with: classes)
      org.gradle.category            = library (not requested)

      Requested attributes not found in the selected variant:
         org.gradle.dependency.bundling = external
         org.gradle.jvm.version         = 11
   ]
   Selection reasons:
      - By constraint : versions below are vulnerable to CVE-2019-16942
      - By constraint
      - By conflict resolution : between versions 2.10.1 and 2.9.7

Multi-project builds #

Now let’s compare how both approaches perform in a Gradle build with sub-projects.

Our example project here would consist of the root project exposing REST controllers with Spring Boot and ‘core’ project having no web interface but still using Spring Framework for dependency injection and so on.

With Spring Dependency Management Plugin #

In the ‘legacy’ single-project build described initially we had: which served two purposes: allowed to build an executable Spring Boot JAR and provided Spring’s Dependency Management Plugin with information which BOM to choose.

We cannot simply move the single-project configuration to allprojects{} block of the root project:

because only the root project contains the main class:

% ./gradlew build
> Task :core:bootJar FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':core:bootJar'.
> Main class name has not been configured and it could not be resolved

What should we do then? Maybe make just the Dependency Management Plugin common?

It also won’t work, because without Spring Boot plugin the source BOM is not configured:

% ./gradlew build
> Task :core:compileJava FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':core:compileJava'.
> Could not resolve all files for configuration ':core:compileClasspath'.
   > Could not find org.springframework.boot:spring-boot-starter:.
     Required by:
         project :core

How can we solve the problem? The official documentation doesn’t even consider the case of a multi-project build, which is really sad. Googling for an answer might bring you to a working build script like the following one:

With native Gradle BOM import #

Here the root build.gradle is much simpler:

Since the imported BOM is treated as a regular dependency, it will be propagated transitively. It’s enough to import the Spring BOM once, in core/build.gradle:

When using Spring Dependency Management Plugin we are not able to control exclusively the single jackson-databind dependency. By setting the property we are actually upgrading version of a group of dependencies at once — and sometimes this is exactly what we want.

How can we achieve a consistent version for a group of dependencies when using the native Gradle BOM import? Spring includes so many Jackson dependencies that it is inconvenient to create a constraint for every single one of them:

+--- org.springframework.boot:spring-boot-starter-json:2.1.0.RELEASE
    +--- org.springframework:spring-web:5.1.2.RELEASE
    +--- com.fasterxml.jackson.core:jackson-databind:2.9.7
    +--- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.9.7
    +--- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.7
    \--- com.fasterxml.jackson.module:jackson-module-parameter-names:2.9.7

Plus this approach would be very error-prone: it would be easy to miss a dependency.

One solution is to create a virtual platform (think of it as of an in-memory BOM) for Jackson, so informing Gradle that artifacts with a group similar to com.fasterxml.jackson should share a version:

Now a single constraint affects all Jackson dependencies:

% ./gradlew dependencyInsight --dependency=jackson-datatype-jdk8

> Task :dependencyInsight
com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.10.1
   variant "compile" [
      org.gradle.status              = release (not requested)
      org.gradle.usage               = java-api
      org.gradle.libraryelements     = jar (compatible with: classes)
      org.gradle.category            = library (not requested)

      Requested attributes not found in the selected variant:
         org.gradle.dependency.bundling = external
         org.gradle.jvm.version         = 11
   ]
   Selection reasons:
      - By constraint : belongs to platform com.fasterxml.jackson:jackson-platform:2.10.1
      - By constraint
      - By conflict resolution : between versions 2.10.1 and 2.9.7

Things are much easier if authors of the library publish their own BOM. In such a case, we simply apply the BOM, which takes care of aligning versions, so we don’t need to declare a virtual platform.

Conclusion #

Although Spring Dependency Management Plugin for Gradle is officially recommended by Spring authors and used in many online tutorials, it does not fit Gradle build model very well. The power of inertia causes people to repeat old instructions, ignoring recommendations from the official Gradle documentation. Features recently added to Gradle allow to achieve the same output without including third-party plugins for dependency management and often result in more concise and maintainable build scripts.

Now, let's talk about your project!

We don't have one standard offer.
Each project is unique, rest assured that we will approach the next one full of energy and engagement.

LET'S CONNECT