Transitive Dependency Mediation in Maven and Gradle

2013-05-06

Nighthawks by Edward Hopper. 1942.
Nighthawks by Edward Hopper. 1942. Source Wikipedia, Google Art Project and Khan Academy.

Maven and Gradle walk into a bar:

Maven: I'd like a coffee, please.
Gradle: I'd like a coffee too, please.
Bartender: Here's your coffee, Maven.
Bartender: And here's your coffee with a croissant, Gradle.
Maven: Why don't I get a croissant too?!
Bartender: For you Maven? Just the nearest explicitly stated dependency.

I recently contributed a fluent testing library together with Martin Schimak to the camunda BPM incubation space. The aim of the project is to improve test creation and maintenance when developing business process applications based on the camunda BPM platform. One of the long term objectives of the camunda BPM fluent testing library would be to have tests to which one can come back after a while and still see the wood for the trees, i.e. understand the business logic and not get lost on the technical details of how to use the camunda BPM engine API.

Anyway, enough self-marketing and back to our topic! In the camunda BPM fluent testing project we use Maven to build the project artifacts. The camunda core team has setup Jenkins so the project is continuously built and library snapshots are available via Nexus. So far so good.

I thought I just had to tell other developers to do just two things:

  1. Add the camunda BPM repository to your project
<repositories>
    <repository>
      <id>camunda.com.public</id>
      <name>Camunda Repository</name>
      <url>https://app.camunda.com/nexus/content/groups/public</url>
    </repository>
</repositories>
  1. Declare the fluent testing artifacts as a test scope dependency
 <dependencies>
    <dependency>
      <groupId>org.camunda.bpm.incubation</groupId>
      <artifactId>camunda-bpm-fluent-assertions</artifactId>
      <version>0.4-SNAPSHOT</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.camunda.bpm.incubation</groupId>
      <artifactId>camunda-bpm-fluent-engine-api</artifactId>
      <version>0.4-SNAPSHOT</version>
      <scope>test</scope>
    </dependency>
</dependencies>

But unfortunately, reality has subtle ways of slipping into a developer's life... it's actually called complexity and it's one of the main reasons why software projects get delayed, one day at a time.

Anyway, I went off a tangent again. What's all of this got to do with Maven and Gradle?! Well, since I like to eat my own dog food as much as I can, I refactored The Job Announcement project to use the fluent testing library... and... got the unpleasant surprise of getting the following exception while executing the tests:

-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.plexiti.camunda.bpm.showcase.jobannouncement.process.JobAnnouncementPublicationTest
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.043 sec <<< FAILURE!
Running com.plexiti.camunda.bpm.showcase.jobannouncement.process.JobAnnouncementTest
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.001 sec <<< FAILURE!
Running com.plexiti.camunda.bpm.showcase.jobannouncement.web.JobAnnouncementBeanTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.326 sec
Results :
Tests in error:
initializationError(com.plexiti.camunda.bpm.showcase.jobannouncement.process.JobAnnouncementPublicationTest): org/junit/rules/TestRule
initializationError(com.plexiti.camunda.bpm.showcase.jobannouncement.process.JobAnnouncementTest): org/junit/rules/TestRule
Tests run: 4, Failures: 0, Errors: 2, Skipped: 0

After a bit of investigation I found the explanation: our library has a runtime dependency on junit:junit:jar version 4.9 whereas The Job Announcement pom.xml explicitly declares junit:junit:jar:4.8. The dependency on 4.9 is due to the use of the org.junit.rules.TestRule interface introduced in JUnit 4.9. Given Maven's default dependency conflict resolution strategy, what is explicitly declared in your application's pom.xml ("nearest definition") overrides anything else. This is shown when using mvn dependency:tree:

rafa@trane: ~/dev/the-job-announcement$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building camunda BPM Job Announcement Showcase 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[...]
[INFO]
[INFO] --- maven-dependency-plugin:2.1:tree (default-cli) @ the-job-announcement ---
[INFO] com.plexiti.showcases:the-job-announcement:war:1.0-SNAPSHOT
[...]
[INFO] +- org.camunda.bpm.incubation:camunda-bpm-fluent-engine-api:jar:0.4-SNAPSHOT:test
[INFO] +- org.camunda.bpm.incubation:camunda-bpm-fluent-assertions:jar:0.4-SNAPSHOT:test
[INFO] | +- org.easytesting:fest-assert-core:jar:2.0M9:test
[INFO] | | \- org.easytesting:fest-util:jar:1.2.4:test
[INFO] | \- org.mockito:mockito-all:jar:1.9.5:test
[...]
[INFO] +- junit:junit:jar:4.8:test
[...]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 7.167s
[INFO] Finished at: Sat May 11 12:42:27 CEST 2013
[INFO] Final Memory: 19M/81M
[INFO] ------------------------------------------------------------------------
rafa@trane: ~/tmp/the-job-announcement$

To be honest, I would have expected at least a [WARN] entry while building the project telling me about a transitive dependency (junit:junit:jar:4.9) with a declared version higher than the one in my application's pom.xml!

Even using a dependency version range as indicated here (thanks for the tip Ziga!) in the fluent library's pom.xml like this:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>[4.9]</version>
</dependency>    

gives exactly the same results.

If you are a Maven expert and are reading this, maybe you can drop me a comment (below) on the proper way to do this? Is there a better solution than having to explicitly tell the users of your library that they have to make sure to declare junit:junit:jar version 4.9 or higher in their own project's pom.xml?

What would Gradle do?

I could not help asking myself whether an issue like this would be taken care off by a more modern build tool like Gradle. Or at least I would expect it to be more transparent and warn me about such a dependency conflict.

And so I downloaded and installed Gradle, had a look at the "Dependency Management Basics" chapter and came up with the following barebones Gradle build file to try and reproduce the issue:

apply plugin: 'java'

repositories {
  mavenCentral()
  maven {
    url "https://app.camunda.com/nexus/content/groups/public"
  }
}

dependencies {
    testRuntime group: 'junit', name: 'junit', version: '4.8'
    testRuntime group: 'org.camunda.bpm.incubation', name: 'camunda-bpm-fluent-assertions', version: '0.4-SNAPSHOT'
}

What I basically do in the file is:

  1. declare the project to be a Java project
  2. add the camunda BPM Maven repository
  3. define a testRuntime dependency configuration with the required dependencies required to run the tests.

That's what I call succinctness.

And now comes the moment of truth... drumroll please...

rafa@miles: ~/tmp/deps-with-gradle$ gradle dependencies
:dependencies
------------------------------------------------------------
Root project
------------------------------------------------------------
[...]
testCompile - Compile classpath for source set 'test'.
No dependencies
testRuntime - Runtime classpath for source set 'test'.
testRuntime - Runtime classpath for source set 'test'.
[...]
+--- junit:junit:4.8 -> 4.9
| \--- org.hamcrest:hamcrest-core:1.1
\--- org.camunda.bpm.incubation:camunda-bpm-fluent-assertions:0.4-SNAPSHOT
+--- org.camunda.bpm.incubation:camunda-bpm-fluent-engine-api:0.4-SNAPSHOT
| \--- org.camunda.bpm:camunda-engine:7.0.0-alpha3
| +--- org.apache.commons:commons-email:1.2
| | +--- javax.mail:mail:1.4.1
| | | \--- javax.activation:activation:1.1
| | \--- javax.activation:activation:1.1
| +--- commons-lang:commons-lang:2.4
| +--- org.mybatis:mybatis:3.1.1
| +--- org.springframework:spring-beans:3.1.2.RELEASE
| | \--- org.springframework:spring-core:3.1.2.RELEASE
| | +--- org.springframework:spring-asm:3.1.2.RELEASE
| | \--- commons-logging:commons-logging:1.1.1
| \--- joda-time:joda-time:2.1
+--- org.camunda.bpm:camunda-engine:7.0.0-alpha3 (*)
+--- junit:junit:4.9 (*)
+--- org.easytesting:fest-assert-core:2.0M9
| \--- org.easytesting:fest-util:1.2.4
\--- org.mockito:mockito-all:1.9.5
(*) - dependencies omitted (listed previously)
BUILD SUCCESSFUL
Total time: 35.428 secs

Wow! That was neat! Note the two lines dealing with conflict resolution! The first one is the explicitly stated dependency on JUnit 4.8 (junit:junit:4.8 -> 4.9) and the second is the transitive dependency (junit:junit:4.9 (*)). Gradle definitely passed the 10-minute test with flying colors!

Can I handle the Maven truth?

Developer: Maven, if a library declares a dependency on JUnit [4.9] which conflicts with an explicitly declared dependency to JUnit 4.8 in my project's pom.xml, why do you still resolve the conflict choosing JUnit 4.8?! At least you should give me a warning!

Maven: You want answers?

Developer: I think I'm entitled to.

Maven: You want answers?

Developer: I want the truth!

Maven: You can't handle the truth!

[pauses]

Maven: Son, we live in a world full of software projects that have dependencies, and those dependencies have to be resolved. Who's gonna do it? You, manually? You, Gradle? I have a greater responsibility than you can possibly fathom. You weep for your unresolved dependency and you curse Maven. You have that luxury. You have the luxury of not knowing what I know, that resolving that dependency my way, while tragic, probably saved projects. And my existence, while grotesque and incomprehensible to you, saves projects! You don't want the truth, because deep down in IT basements you don't talk about at release parties, you want me on your project. You need me on your project. We use words like <dependecy>, <version>, <scope>. We use these words as the backbone of a life spent building software. You use them as a punchline. I have neither the CPU cycles nor the disk space to explain myself to someone who develops and releases software under the blanket of the default configuration that I provide, and then questions the manner in which I provide it! I would rather you just said "thank you", and went on your way. Otherwise, I suggest you pick up a browser, and start manually downloading and managing all those dependencies. Either way, I don't give a damn what you think you are entitled to!

― From "A Few Good Build Tools"

Postscript

Please, do not read this post as mere Maven bashing! I definitely believe that Maven has done A LOT for us (thanks to Sebastian Dietrich for the link!).

Anyway, thank you Maven! I go on my way now.