Sadeq Dousti
Posted on December 7, 2023
Preamble
I'm a maintainer of a popular open-source project called Logbook. It is "an extensible Java library for HTTP request and response logging". The project uses Maven for build automation.
I have recently made a change in the dependency management section of one of the modules, that resulted in an unwanted effect: Lombok became a transitive dependency of our project, but only if you use Gradle in your project! If you use Maven, you won't face this issue.
But why?!
I'm going to share the result of my investigations in this post. The structure of the article is as follows:
- Toolchain used, plus an intro to dependency management
- Example: Creating a library (with the dependency management section, and using it in a Maven and a Gradle project
- Showing disagreement: Adding submodules to the library, overriding the dependency management, and seeing how Maven and a Gradle projects interpret it differently
- Conclusion and personal opinion
Toolchain
I used:
- OpenJDK Temurin 17.0.9+9
- Apache Maven 3.9.6
- Gradle 8.5
You can find the source code developed below on GitHub.
Dependency management
Dependency management is a way to tell Maven a few key information about the dependencies of the project. These include:
- Dependency version
- Dependency scope
- Excluded dependencies
- Optional dependencies
For a refresher, see Introduction to the Dependency Mechanism.
Example
Consider a simple project with the following pom.xml
file (here, I'm using jcip-annotations
as a no-fluff dependency).
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.msdousti</groupId>
<artifactId>maven-library</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>net.jcip</groupId>
<artifactId>jcip-annotations</artifactId>
<version>1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>net.jcip</groupId>
<artifactId>jcip-annotations</artifactId>
</dependency>
</dependencies>
</project>
You can see that the dependencyManagement
section specifies both version and scope for the dependency, so we can simply include the dependency in the dependencies
section without specifying those.
If you run mvn dependency:tree
in the project root (I assume that you have installed Maven and Java, and they are properly configure), you'll see
[INFO] --- dependency:3.6.1:tree (default-cli) @ maven-library ---
[INFO] io.msdousti:maven-library:jar:1.0-SNAPSHOT
[INFO] \- net.jcip:jcip-annotations:jar:1.0:provided
This clearly shows that the JAR file of net.jcip:jcip-annotations
is imported with proper version (1.0
) and scope (provided
). Cool!
You can also use your IDE to see this info graphically. For instance, in IntelliJ IDEA, you can open the Maven tool window:
As a side note, IntelliJ IDEA also provides this nifty feature called "Analyze Dependencies", which is very handy in seeing which dependencies conflict and which version is ultimately chosen by Maven:
Including the project as a dependency in other project
To including the project as a dependency in other project, we first need to build and install it in the local Maven repository. Run this in the root of the project:
mvn install
And then check the local Maven repository:
ls ~/.m2/repository/io/msdousti/maven-library/1.0-SNAPSHOT
It should contain the project files:
_remote.repositories maven-library-1.0-SNAPSHOT.jar maven-library-1.0-SNAPSHOT.pom maven-metadata-local.xml
Now let's include it as a dependency in a Maven and a Gradle project.
Maven Project
The pom.xml
would look like this:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.msdousti</groupId>
<artifactId>test-maven</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.msdousti</groupId>
<artifactId>maven-library</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
If you check the dependency tree by running mvn dependency:tree
, you will see:
[INFO] --- dependency:3.6.1:tree (default-cli) @ test-maven ---
[INFO] io.msdousti:test-maven:jar:1.0-SNAPSHOT
[INFO] \- io.msdousti:maven-library:jar:1.0-SNAPSHOT:compile
Note that jcip-annotations
is not included. This is because it has the scope provided
in the io.msdousti:maven-library
dependency, so it won't be transitively included.
Gradle
The build.gradle
will be like this (Notice that I added mavenLocal()
to the list of repositories
, as I want to include the dependency maven-library
from the local .m2
repository):
plugins {
id 'java'
}
group = 'io.msdousti'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
mavenLocal()
}
dependencies {
implementation 'io.msdousti:maven-library:1.0-SNAPSHOT'
}
Using gradle :dependencies
, we can view the dependencies, but as the output is bulky, I just show the graphical version from my IDE:
We see that maven-library
is included in all four Gradle "class paths". But again, jcip-annotations
is not included, which is in agreement with the Maven project.
Disagreement begins
Maven and Gradle start to diverge when our maven-library
has a sub-module with overriding dependencyManagement
section.
What does that mean?! Let's find out.
Creating sub-modules
Let's add two sub-modules to maven-library
. The directory structure will be like this (For simplicity, I ignored directories like src
that are not relevant here):
.
|
├─ pom.xml
├─ my-bom
| └─ pom.xml
└─ my-module
└─ pom.xml
💡 BOM stands for Bill of Material. It is often a dependency that includes the
dependencyManagement
for the project.
In the parent pom.xml
, we have to specify a packaging of pom
, plus two modules
elements (add them just below the project coordinates):
<groupId>io.msdousti</groupId>
<artifactId>maven-library</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- add below -->
<packaging>pom</packaging>
<modules>
<module>my-bom</module>
<module>my-module</module>
</modules>
The pom.xml
file of the first child (my-bom
) will look like this:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.msdousti</groupId>
<artifactId>maven-library</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>my-bom</artifactId>
<packaging>pom</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>net.jcip</groupId>
<artifactId>jcip-annotations</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
Note that it has a dependencyManagement
section, that specifies the compile
scope for jcip-annotations
.
The pom.xml
file of the other child (my-module
) will look like this:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.msdousti</groupId>
<artifactId>maven-library</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>my-module</artifactId>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.msdousti</groupId>
<artifactId>my-bom</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
</project>
It includes my-bom
in the dependencyManagement
section, with scope import
and type pom
. If you look at the dependencies of this module, you see that it honors the scope specified by the parent project:
Do a mvn install
on the parent project, and let's find out what how our Maven and Gradle projects that use maven-module
will look like.
Maven project
Refresh the Maven project test-maven
in your IDE (or use Maven command mvn dependency:tree
at the root of the project).
The dependencies are still the same:
Gradle project
Refresh the Maven project test-gradle
in your IDE (or use Maven command gradle :dependencies
at the root of the project).
You'll see that net.jcip:jcip-annotations:1.0
will appear as a transitive dependency:
Conclusion
Looking at the article I shared at the beginning of this post, we stumble upon this section about the import
scope (emphasis mine):
This scope is only supported on a dependency of type pom in the
<dependencyManagement>
section. It indicates the dependency is to be replaced with the effective list of dependencies in the specified POM's<dependencyManagement>
section. Since they are replaced, dependencies with a scope of import do not actually participate in limiting the transitivity of a dependency.
So, the expectation is that the dependencies are replaced. This is exactly what Gradle does, and Maven does not seem to honor this section.
At least this is what I understand! If you have further insights, please feel free to share them in the comments section.
Posted on December 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.