Common Misconceptions

Due to smooth transitions from Maven2 into Maven3 (and soon Maven4), and the fact that Maven2 plugins kept working with Maven3, maybe even without change, there were some misconceptions crept in as well. Despite the marvel of “compatibility”, Maven3 resolution differs quite much from Maven2, and the sole reason is actual improvement in area of resolution, it became much more precise (and, due that lost some “bad” habits present in Maven2). Here, we will try to enumerate some of the most common misconceptions.

Misconception No1: How Resolver Works

(Simplified)

The most typical use case for Resolver is to “resolve transitively” dependencies. Resolver, to achieve this, internally (but these are exposed via API as distinguished API calls as well) performs 3 steps: “collect”, “transform” and “resolve”.

The “collect” step is first, where it builds the “dirty tree” (dirty graph) of artifacts. It is important to remark, that in “collect” step, while the graph is being built, Maven uses only POMs. Hence, if collecting an Artifact that was never downloaded to your local repository, it will download the POMs only. Using POMs resolver is able to build current “node” of graph, but also figure outgoing vertices and adjacent nodes of current node and so on. Which dependency is chosen to continue with from the current node POM is decided by various criteria (configured).

The “transform” step transforms the “dirty graph”: this is where conflict resolution happens. It is here when resolver applies various rules to resolve conflicting versions, conflicting scopes, and so on. Here, if “verbose tree” is asked for, conflict resolution does not remove graph nodes, merely marks the conflicts and the conflict “winner”. Thus, “verbose tree” cannot be resolved.

Finally, the “resolve” step runs, when the (transformed) graph node artifacts are being resolved, basically ensuring (and downloading if needed) their correspondent files (i.e. JAR files) are present in local repository.

It is important to state, that in “collect” step happens the selection of nodes by various criteria, among other by the configured scope filters. And here we come to the notion of “runtime graph” vs “test graph”.

In resolver, maybe un-intuitively, the “scope filter” is usually used (but does not have to, this is just how it IS used in Maven Core, probably for historical reasons) as “what should be omitted”. The default session filter in Maven is set up as this:

  new ScopeDependencySelector("test", "provided")

This means, that “current dependency node” dependencies in “test” and “provided” scope will be simply omitted from the graph. In other words, this filter builds the “downstream runtime classpath” of supplied artifact (i.e. “what is needed by the artifact at runtime when I depend on it”).

Note: these are NOT “Maven related” notions yet, there is nowhere Maven in picture here, and these are not the classpath used by Compiler or Surefire plugins, merely just a showcase how Resolver works.

Misconception No2: “Test graph” Is Superset Of “Runtime graph”

Wrong. As can be seen from above, for runtime graph we leave out “test” scoped dependencies. It was true in Maven2, where test graph really was superset of runtime, this does not stand anymore in Maven3. And this have interesting consequences. Let me show an example:

(Note: very same scenario, as explained below for Guice+Guava would work for Jackson Databind+Core, etc.)

Assume your project is using Google Guice, so you have declared it as a dependency:

      <dependency>
        <groupId>com.google.inject</groupId>
        <artifactId>guice</artifactId>
        <version>${guiceVersion}</version>
      </dependency>

All fine and dandy. At the same time, you want to avoid any use of Guava. We all know Guava is a direct dependency of Guice. This is fine, since as we know, the best practice is to declare all dependencies your code compiles against. By not having Guava here, analyse tools will report if code touches Guava as “undeclared dependency”.

But let's go one step further: turns out, to set up your unit tests, you do need Guava. So what now? Nothing, just add it as a test dependency, so your POM looks like this:

      <dependency>
        <groupId>com.google.inject</groupId>
        <artifactId>guice</artifactId>
        <version>${guiceVersion}</version>
      </dependency>
      <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>${guavaVersion}</version>
        <scope>test</scope>
      </dependency>

The dependency:tree plugin for this project outputs this verbose tree:

[INFO] --- dependency:3.6.1:tree (default-cli) @ DEMO-PROJECT ---
[INFO] DEMO-PROJECT
[INFO] +- com.google.inject:guice:jar:6.0.0:compile
[INFO] |  +- javax.inject:javax.inject:jar:1:compile
[INFO] |  +- jakarta.inject:jakarta.inject-api:jar:2.0.1:compile
[INFO] |  +- aopalliance:aopalliance:jar:1.0:compile
[INFO] |  \- (com.google.guava:guava:jar:31.0.1-jre:compile - omitted for duplicate)
[INFO] \- com.google.guava:guava:jar:31.0.1-jre:test (scope not updated to compile)
[INFO]    +- com.google.guava:failureaccess:jar:1.0.1:test
[INFO]    +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:test
[INFO]    +- com.google.code.findbugs:jsr305:jar:3.0.2:test
[INFO]    +- org.checkerframework:checker-qual:jar:3.12.0:test
[INFO]    +- com.google.errorprone:error_prone_annotations:jar:2.7.1:test
[INFO]    \- com.google.j2objc:j2objc-annotations:jar:1.3:test

And is right, this IS the “test graph” of the project and contains a conflict as noted by “omitted for duplicate” and “scope not updated to compile” remarks next to Guava nodes.

So this setup results that:

  • when you compile, it ensures Guava is NOT on compile classpath, so you cannot even touch it (by mistake)
  • when test-compile and test-execute runs, Guava will be present on classpath, as expected

So far good, but what happens when this library is consumed downstream by someone? When it becomes used as a library? Nothing, all works as expected!

When a downstream dependency declares dependency on this project, the downstream project will get this graph (from the node that is your library):

[INFO] --- dependency:3.6.1:tree (default-cli) @ DOWNSTREAM-PROJECT ---
[INFO] DOWNSTREAM-PROJECT
[INFO] \- DEMO-PROJECT:compile
[INFO]    \- com.google.inject:guice:jar:6.0.0:compile
[INFO]       +- javax.inject:javax.inject:jar:1:compile
[INFO]       +- jakarta.inject:jakarta.inject-api:jar:2.0.1:compile
[INFO]       +- aopalliance:aopalliance:jar:1.0:compile
[INFO]       \- com.google.guava:guava:jar:31.0.1-jre:compile
[INFO]          +- com.google.guava:failureaccess:jar:1.0.1:compile
[INFO]          +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO]          +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
[INFO]          +- org.checkerframework:checker-qual:jar:3.12.0:compile
[INFO]          +- com.google.errorprone:error_prone_annotations:jar:2.7.1:compile
[INFO]          \- com.google.j2objc:j2objc-annotations:jar:1.3:compile

So what happens here? First, revisit “How Resolver Works”, there you will see that for “runtime graph” of the dependency the “test” and “provided” scopes of the dependency artifact are not even considered. They are simply omitted. Not skipped, but completely omitted, like they do not even exists. Hence, in the graph there is no conflict happening (as “test” Guava is completely omitted during “collect” step). Hence, everything goes as expected.

Important Consequences

One, maybe not so obvious consequence can be explained with use of maven-assembly-plugin. Let assume you want to assemble your module “runtime” dependencies.

If you do it from “within” of the project, for example in package phase, your packaging will be incomplete: Guava will be missing! But if you do it from “outside” of the project (i.e. subsequent module of the build, or downstream dependency), the assembly will contain Guava as well.

This is a Maven Assembly plugin bug, somewhat explained in MRESOLVER-391. In short, Maven Assembly plugin considers “project test graph”, and then “cherry-picks runtime scoped nodes” from it, which, as we can see in this case, is wrong. You need to build different graphs for “runtime” and “test” classpath, unlike as it was true in Maven2. For Assembly plugin, the problem is that as Mojo, it requests “test graph”, then it reads configuration (assembly descriptor, and this is the point where it learns about required scopes), and then it “filters” the resolved “test graph” for runtime scopes. And it is wrong, as Guava is in test scope. Instead, the plugin should read the configuration first, and ask Resolver for “runtime graph” and filter that. In turn, this problem does not stand with maven-war-plugin, as the “war” Mojo asks resolution of “compile+runtime” scope. Of course, WAR use case is much simpler than Assembly use case is, as former always packages same scope, while Assembly receives a complex configuration and exposes much more complex “modus operandi”.