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:
buildscript {
repositories {
jcenter()
}
dependencies {
classpath(
'org.springframework.boot:spring-boot-gradle-plugin:2.1.0.RELEASE')
}
}
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
repositories {
jcenter()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
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:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
constraints {
implementation('com.fasterxml.jackson.core:jackson-databind:2.10.1') {
because 'versions below are vulnerable to CVE-2019-16942'
}
}
}
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):
ext['jackson.version'] = '2.10.1'
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
Native Gradle way
Instead of relying on a third-party plugin, simply use the
built-in BOM import support:
plugins {
id 'java'
id 'org.springframework.boot' version '2.1.0.RELEASE'
}
repositories {
jcenter()
}
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:2.1.0.RELEASE')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
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:
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:2.1.0.RELEASE')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
constraints {
implementation('com.fasterxml.jackson.core:jackson-databind:2.10.1') {
because 'versions below are vulnerable to CVE-2019-16942'
}
}
}
% ./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:
apply plugin: 'org.springframework.boot'
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:
allprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
}
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?
allprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
repositories {
jcenter()
}
}
apply plugin: 'org.springframework.boot'
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:
buildscript {
repositories {
jcenter()
}
dependencies {
classpath(
'org.springframework.boot:spring-boot-gradle-plugin:2.1.0.RELEASE')
}
}
allprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
repositories {
jcenter()
}
dependencyManagement {
imports {
mavenBom 'org.springframework.boot:spring-boot-dependencies:2.1.0.RELEASE'
}
}
}
apply plugin: 'org.springframework.boot'
dependencies {
implementation project(":core")
implementation "org.springframework.boot:spring-boot-starter-web"
}
With Native Gradle BOM Import
Here the root build.gradle
is much simpler:
plugins {
id 'java-library'
id 'org.springframework.boot' version '2.1.0.RELEASE'
}
allprojects {
apply plugin: 'java-library'
repositories {
jcenter()
}
}
dependencies {
implementation project(':core')
implementation 'org.springframework.boot:spring-boot-starter-web'
}
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
:
dependencies {
api platform('org.springframework.boot:spring-boot-dependencies:2.1.0.RELEASE')
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
When using Spring Dependency Management Plugin we are not able to control exclusively the single jackson-databind
dependency. By setting the property
ext['jackson.version'] = '2.10.1'
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:
dependencies {
components.all(JacksonAlignmentRule)
implementation platform('org.springframework.boot:spring-boot-dependencies:2.1.0.RELEASE')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
constraints {
implementation('com.fasterxml.jackson.core:jackson-databind:2.10.1') {
because 'versions below are vulnerable to CVE-2019-16942'
}
}
}
class JacksonAlignmentRule implements ComponentMetadataRule {
void execute(ComponentMetadataContext ctx) {
ctx.details.with {
if (id.group.startsWith('com.fasterxml.jackson')) {
belongsTo("com.fasterxml.jackson:jackson-platform:${id.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.
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:2.1.0.RELEASE')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
// updating due to CVE-2019-16942
implementation platform('com.fasterxml.jackson:jackson-bom:2.10.1')
}
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.