As Java developers, we may maintain many applications using Maven for their dependency management. These applications need upgrades from time to time to be up to date and to add new features or security updates.
This easy task - updating dependencies' versions - can easily turn out to become a nightmare because of conflicts between certain dependencies. The resolution of these dependency conflicts can take a lot of time.
To make dependency management easier, we can use the Bill of Materials (BOM), a feature that offers easier and safer dependency management.
In this article, we are going to look at dependency management in Maven and look at the BOM with some examples.
Direct vs. Transitive Dependencies
Let’s imagine we write some business code that requires logging the output, using some String utilities, or securing the application. This logic can be implemented in our project, or we can use a library instead. It often makes sense to use existing libraries to minimize the amount of code we need to write ourselves.
The use of libraries encourages reuse since we will rely on other libraries that solve problems similar to ours: these libraries are our dependencies.
There are two types of dependencies in Maven:
-
direct dependencies: dependencies that are explicitly included in our Project Object Model (
pom.xml
) file in the<dependencies>
section. They can be added using the<dependency>
tag. Here is an example of a logging library added to apom.xml
file:<dependencies> <dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.17</version> </dependency> </dependencies>
-
transitive dependencies: a project that we include as a dependency in our project, like the logging library above, can declare its own dependencies in a
pom.xml
file. These dependencies are then considered transitive dependencies to our project. When Maven pulls a direct dependency, it also pulls its transitive dependencies.
Transitive Dependencies with Maven
Now that we have an overview of the different dependency types in Maven, let’s see in detail how Maven deals with transitive dependencies in a project.
As an example, we’ll look at two dependencies from the Spring Framework: spring-context
and spring-security-web
.
In the pom.xml
file we add them as direct dependencies, deliberately selecting two different version numbers:
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.5</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>5.4.5</version>
</dependency>
</dependencies>
Visualize Version Conflicts with a Dependency Tree
Someone who is not aware of transitive dependencies will think that using this dependency declaration only two JAR files will be pulled. Fortunately, Maven provides a command that will show us what was pulled exactly concerning these two dependencies.
We can list all the dependencies including the transitive ones using this command:
mvn dependency:tree -Dverbose=true
We use the verbose mode of this command so that Maven tells us the reason for selecting one version of a dependency over another.
The result is this:
+- org.springframework:spring-context:jar:5.3.5:compile
| +- org.springframework:spring-aop:jar:5.3.5:compile
| | +- (org.springframework:spring-beans:jar:5.3.5:compile - omitted for duplicate)
| | \- (org.springframework:spring-core:jar:5.3.5:compile - omitted for duplicate)
| +- org.springframework:spring-beans:jar:5.3.5:compile
| | \- (org.springframework:spring-core:jar:5.3.5:compile - omitted for duplicate)
...
+- (org.springframework:spring-expression:jar:5.2.13.RELEASE:compile - omitted for conflict with 5.3.5)
\- org.springframework:spring-web:jar:5.2.13.RELEASE:compile
+- (org.springframework:spring-beans:jar:5.2.13.RELEASE:compile - omitted for conflict with 5.3.5)
\- (org.springframework:spring-core:jar:5.2.13.RELEASE:compile - omitted for conflict with 5.3.5)
We started from two dependencies, and in this output, we find out that Maven pulled additional dependencies. These additional dependencies are simply transitive.
We can see that there are different versions of the same dependency in the tree. For example, there are two versions of the spring-beans
dependency:5.2.13.RELEASE
and 5.3.5
.
Maven has resolved this version conflict, but how? What does omitted for duplicate and omitted for conflict mean?
How Does Maven Resolve Version Conflicts?
The first thing to know is that Maven can’t sort versions: The versions are arbitrary strings and may not follow a strict semantic sequence. For example, if we have two versions 1.2
and 1.11
, we know that 1.11
comes after 1.2
but the String comparison gives 1.11
before 1.2
. Other version values can be 1.1-rc1
or 1.1-FINAL
, that’s why sorting versions by Maven is not a solution.
That means that Maven doesn’t know which version is newer or older and cannot choose to always take the newest version.
Second, Maven takes the approach of the nearest transitive dependency in the tree depth and the first in resolution. To understand this, let’s look at an example:
We start with a POM file having some dependencies with transitive dependencies (to make it short, all the dependencies will be represented by the letter D):
D1(v1) -> D11(v11) -> D12(v12) -> DT(v1.3)
D2(v2) -> DT(v1.2)
D3(v3) -> D31(v31) -> DT(v1.0)
D4(v4) -> DT(v1.5)
Note that each of the direct dependencies pulls in a different version of the DT
dependency.
Maven will create a dependency tree and following the criteria mentioned above, a dependency will be selected for DT
:
We note that the resolution order played a major role in choosing the DT
dependency since the v1.2
and v1.5
had the same depth, but v1.2
came first in the resolution order. So even if v1.2
is not the last version of DT
, Maven chose it to work with.
If we wanted to use version v1.5
in this case, we could simply add the dependency D4
before D2
in our POM file. In this case, v1.5
will be first in terms of resolution order and Maven will select it.
So, to help us understand the dependency tree result from above, Maven indicates for each transitive dependency why it was omitted:
- “omitted for duplicate” means that Maven preferred another dependency with the same name and version over this one (i.e. the other dependency had a higher priority according to the resolution order and depth)
- “omitted for conflict” means that Maven preferred another dependency with the same name but a different version over this one (i.e. the other dependency with the different version had a higher priority according to the resolution order and depth)
Now it is clear for us how Maven resolves transitive dependencies. For some reason, we may be tempted one day to pick a specific version of a dependency and get rid of all the processes made by Maven to select it. To do this we have two options:
Overriding Transitive Dependency Versions
If we want to resolve a dependency conflict ourselves, we have to tell Maven which version to choose. There are two ways of doing this.
Override a Transitive Dependency Version Using a Direct Dependency
Adding the desired transitive dependency version as a direct dependency in the POM file will result in making it the nearest in depth. This way Maven will select this version. In our previous example, if we wanted version v1.3
to be selected, then adding the dependency DT(v1.3)
in the POM file will ensure its selection.
Override a Transitive Dependency Version Using the dependencyManagement
Section
For projects with sub-modules, to ensure compatibility and coherence between all the modules, we need a way to provide the same version of a dependency across all sub-modules. For this, we can use the dependencyManagement
section: it provides a lookup table for Maven to help determine the selected version of a transitive dependency and to centralize dependency information.
A dependencyManagement
section contains dependency elements. Each dependency is a lookup reference for Maven to determine the version to select for transitive (and direct) dependencies. The version of the dependency is mandatory in this section. However, outside of the dependencyManagement
section, we can now omit the version of our dependencies, and Maven will select the correct version of the transitive dependencies from the list of dependencies provided in dependencyManagement
.
We should note that defining a dependency in the dependencyManagement
section doesn’t add it to the dependency tree of the project, it is used just for lookup reference.
A better way to understand the use of dependencyManagement
is through an example. Let’s go back to our previous example with the Spring dependencies. Now we are going to play with the spring-beans
dependency. When we executed the command mvn dependency:tree
, the version resolved for spring-beans
was 5.3.5
.
Using dependencyManagement
we can override this version and select the version that we want. All that we have to do is to add the following to our POM file:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.2.13.RELEASE</version>
</dependency>
</dependencies>
</dependencyManagement>
Now we want Maven to resolve version 5.2.13.RELEASE
instead of 5.3.5
.
Let’s execute the command mvn dependency:tree
one more time. The result is:
+- org.springframework:spring-context:jar:5.3.5:compile
| +- org.springframework:spring-aop:jar:5.3.5:compile
| +- org.springframework:spring-beans:jar:5.2.13.RELEASE:compile
| +- org.springframework:spring-core:jar:5.3.5:compile
| | \- org.springframework:spring-jcl:jar:5.3.5:compile
| \- org.springframework:spring-expression:jar:5.3.5:compile
\- org.springframework.security:spring-security-web:jar:5.4.5:compile
+- org.springframework.security:spring-security-core:jar:5.4.5:compile
\- org.springframework:spring-web:jar:5.2.13.RELEASE:compile
In the dependency tree, we find the 5.2.13.RELEASE
version for spring-beans
. This is the version that we wanted Maven to resolve for each spring-beans
transitive dependency.
If spring-beans
was a direct dependency, in order to take advantage of the dependencyManagement
section, we will no longer have to set the version when adding the dependency:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
</dependency>
This way, Maven will resolve the version using the information provided in the dependencyManagement
section.
Introducing Maven’s Bill of Material (BOM)
The Bill Of Material is a special POM file that groups dependency versions that are known to be valid and tested to work together. This will reduce the developers' pain of having to test the compatibility of different versions and reduce the chances to have version mismatches.
The BOM file has:
- a
pom
packaging type:<packaging>pom</packaging>
. - a
dependencyManagement
section that lists the dependencies of a project.
As seen above, in the dependencyManagement
section we will group all the dependencies required by our project with the recommended versions.
Let’s create a BOM file as an example:
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>reflectoring</groupId>
<artifactId>reflectoring-bom</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
<name>Reflectoring Bill Of Material</name>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.reflectoring</groupId>
<artifactId>logging</artifactId>
<version>2.1</version>
</dependency>
<dependency>
<groupId>io.reflectoring</groupId>
<artifactId>test</artifactId>
<version>1.1</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
This file can be used in our projects in two different ways:
- as a parent POM, or
- as a dependency.
Third-party projects can provide their BOM files to make dependency management easier. Here are some examples:
- spring-data-bom: The Spring data team provides a BOM for their Spring Data project.
- jackson-bom: The Jackson project provides a BOM for Jackson dependencies.
Using a BOM as a Parent POM
The BOM file that we created previously can be used as a parent POM of a new project. This newly created project will inherit the dependencyManagement
section and Maven will use it to resolve the dependencies required for it.
<project ...>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>reflectoring</groupId>
<artifactId>reflectoring-bom</artifactId>
<version>1.0</version>
</parent>
<groupId>reflectoring</groupId>
<artifactId>new-project</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>New Project</name>
<dependency>
<groupId>io.reflectoring</groupId>
<artifactId>logging</artifactId>
</dependency>
</project>
In this example, we note that the logging dependency in our project doesn’t need a version number. Maven will resolve it from the list of dependencies in the BOM file.
If a version is added to the dependency, this will override the version defined in the BOM, and Maven will apply the “nearest depth version” rule.
For a real-life example, Spring Boot projects created from the start.spring.io platform inherit from a parent POM spring-boot-starter-parent
which inherits also from spring-boot-dependencies
. This POM file has a dependencyManagement
section containing a list of dependencies required by Spring Boot projects. This file is a BOM file provided by the Spring Boot team to manage all the dependencies.
With a new version of Spring Boot, a new BOM file will be provided that handles version upgrades and makes sure that all the given dependencies work well together. Developers will only care about upgrading the Spring Boot version, the underlying dependencies' compatibility was tested by the Spring Boot team.
We should note that if we use a BOM as a parent for our project, we will no longer be able to declare another parent for our project. This can be a blocking issue if the concerned project is a child module. To bypass this, another way to use the BOM is by dependency.
Adding a BOM as a Dependency
A BOM can be added to an existing POM file by adding it to the dependencyManagement
section as a dependency with a pom
type:
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>reflectoring</groupId>
<artifactId>new-project</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>New Project</name>
<dependency>
<groupId>io.reflectoring</groupId>
<artifactId>logging</artifactId>
</dependency>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>reflectoring</groupId>
<artifactId>reflectoring-bom</artifactId>
<version>1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
Maven will behave exactly like the example with the parent BOM file in terms of dependency resolution. The only thing that differs is how the BOM file is imported.
The import
scope set in the dependency section indicates that this dependency should be replaced with all effective dependencies declared in its POM. In other words, the list of dependencies of our BOM file will take the place of the BOM import in the POM file.
Conclusion
Understanding dependency management in Maven is crucial to avoid getting version conflicts and wasting time resolving them.
Using the BOM is a good way the ensure consistency between the dependencies versions and a safer way in multi-module projects management.