How Gradle disagreed with our Maven project

msdousti

Sadeq Dousti

Posted on December 7, 2023

How Gradle disagreed with our Maven project

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Viewing dependencies in Maven tool window of IntelliJ IDEA

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:

Analyze Dependencies feature of IntelliJ IDEA

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
Enter fullscreen mode Exit fullscreen mode

And then check the local Maven repository:

ls ~/.m2/repository/io/msdousti/maven-library/1.0-SNAPSHOT
Enter fullscreen mode Exit fullscreen mode

It should contain the project files:

_remote.repositories  maven-library-1.0-SNAPSHOT.jar  maven-library-1.0-SNAPSHOT.pom  maven-metadata-local.xml
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

Using gradle :dependencies, we can view the dependencies, but as the output is bulky, I just show the graphical version from my IDE:

Dependencies of the Gradle project

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
Enter fullscreen mode Exit fullscreen mode

💡 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

Dependencies of module my-module

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:

dependencies of the Maven project  raw `test-maven` endraw

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:

dependencies of the Gradle project  raw `test-gradle` endraw

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.

💖 💪 🙅 🚩
msdousti
Sadeq Dousti

Posted on December 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related