During their Devoxx UK talk, Ixchel Ruiz, developer advocate at JFrog, and Andres Almiray, principal product manager at Oracle, presented multiple "maven puzzlers" and their potential solutions to escape the Apache Maven "dependencies hell". Their presentation touched on direct, transitive, parent POMs and even importing Bill Of Materials (BOMs).
Ruiz opened the presentation by musing on the value of tooling:
Ruiz: Me, as a developer, I am feeling good when my tools are doing what they are supposed to be doing.
The "magic happens" when the "tool is good" and you understand it. As the title states, the presentation was about build tools, more specifically Apache Maven.
According to JFrog Artifactory statistics and developer productivity surveys from JRebel or JetBrains, Maven is still the main Java build tool used by developers with 68% respectively 73% market share.
Almiray, who was a long-time Gradle supporter, stated that even Gradle or sbt rely on Maven in one or another. So, even if they do things in their own way, they still need to understand the POM format to resolve their dependencies. At points, they will not work as expected - or you would expect them to behave in a classical Maven way.
The presenters segued into the puzzlers by asking the audience what would be the first thing they would do on a fresh cloned maven project. Almiray encouraged the audience to replace the instinctual "mvn clean install" with |mvn verify", except for situations when the project depends on remote projects. He argued that in the Maven lifecycle, verify is the step right before install and it validates the project by building it and running the tests. While the install just copies the result of the build (compilation and packaging) from one part of the filesystem (where the build happens) to another part, where there is the repository.
Next, the presentation became a conversation through an interactive questionnaire to which the audience responded in real-time. As they introduced the puzzlers, they stated that challenges will occur when you will have a dependency with the same coordinates (groupId, artificatId) but different versions.
The "warm-up questions" identified the versions of Maven used and the way of installing it, and also if the audience used the Maven daemon. In response to the choices of the audience, the two suggested using SDKMAN (which will allow using different versions of Java even in two different terminal windows), the Maven daemon for improved speed and the new versions of Maven 3.9.x as a way to move towards the new disruptive version 4.x.
In the puzzlers section of the presentation, the two presenters used the Google Guava dependency as an example, not because "we hate the Guava, but because it is commonly known". They provided multiple scenarios, and puzzlers, followed by explanations, suggestions for improvements and solutions. They grouped the situations into three sections: single POM file dependencies, dependencies with parent POM and importing BOM.
Single POM file dependency management
The first situation looked at a simple project that declared two different versions of Guava one after the other, asking which one will be resolved. The response of the audience was almost equally divided among the two versions or having a build error. Even if it might seem like a simple rule to be applied, given that currently there are multiple combinations (of versions and plugins) things can become more complicated. For instance, this situation will be a build error in Maven 4.x but just a warning in 3.x.
The response of this puzzler is version 28.0 of Guava will be resolved, but because it is not the higher version number, Maven will always take the last dependency declared. More than that, Maven doesn't comprehend versions in any way, because it treats these as strings. To ensure that these situations do not happen, Ruiz and Almiray recommended using the "ban duplicate POM versions rule" of the Maven enforcer plugin until Maven 4.x is generally available. This will be painful the first time you use it, as using the rule will generate a build error, forcing you to choose the appropriate version for your project.
The next puzzler adapted the initial one slightly, by replacing the second dependency with a transitive one that brings a higher version. Even if the second one is higher and is the last one defined in the POM file, the direct "dependency always wins". Almiray mentioned that Robert Scholte, the former chair of the Apache Maven project - underlined that the tool doesn't understand semantic versioning but looks at "only the position in the graph". He further stated, that having different versions of the same dependency in your graph might occasionally break your application. To avoid this situation you can use the "dependency convergence rule" of the Maven enforcer plugin. It will ensure that you always have the same version. And if you would like to enforce semantic versioning as well, you can use the "require upper bounds deps" enforcer rule which will point that there is a newer version available in your graph. Combining the two rules will emphasise when you have two versions of the same dependency and will point out there is a newer one available.
The next situation introduced the dependency management concept which works like a lookup table. Whenever Maven is building the graph, it will search the given table and if there is a matching dependency (the same artifact and group IDs) it will choose the version that is defined there. The first example had just the dependency management block and the transitive dependency coming via Google Truth while the second example added a direct dependency to the mix as well. In the first situation, the version defined in the dependency management block will "win", while in the second one, the direct dependency will win, as "direct dependencies, regardless of where in the graph they are, always win".
The following one used two dependencies that both bring the same transitive dependency at the same distance from the root ("that is very important"). And the same situation was slightly altered by bringing the dependency management block into play. The audience applied the rule for direct dependencies that says that the last one defined in the graph will "win", but in the case of transitive dependency it is the other way around and the first, closest transitive dependency defined by the first library will "win". So in this case, as they were at the same distance the transitive dependency brought by Guice won (Guava 30.1). When the dependency block came into play, the version defined by it "won".
Parent POM file dependencies
During this section of the talk, the two presenters added another complexity to the dependency management story: the parent POM. Each POM can have a parent POM as well, which will provide a different context for dependency management. Parenthood is defined at the child level, the parent doesn't know anything about the children that are inheriting it. Each POM has a parent one; if none is defined explicitly, the parent will be implicitly the super POM. The reason why Maven can build your project with just basic plugins added is that the super POM contains all the needed ones.
Almiray: it's easy to reason about a single POM file, but of course what happens when you have more than one POM file?
Ruiz: Of course...then things get more interesting
The next examples defined a direct dependency in the parent, a transitive dependency in the child in the first case. In the second case, a dependency management block is added as well. As the parent POM is imported in the child POM just "as it is", we can fall back to the previous situations where we had a single POM with direct dependencies, transitive dependencies and dependency block. Like always the "direct dependency wins". The sole difference is that the effective POM will import also the content of the parent POM. So, depending on the position of the dependency in the effective POM it will be resolved to one or another.
BOM dependencies
The last of the concepts used in the presentation is the Bill Of Materials (BOM) and it should not be confused with Software Bills Of Material (SBOM), the "security thingy". There is no official definition or classification of the types of BOMs you will encounter, but according to Almiray you can split them into the following categories:
- The library BOM - defines projects that are related to a single library. For example, the JUnit or Jackson BOM defines everything related to JUnit and nothing more
- The stack BOM - Spring or Quarkus BOM bring all dependencies of various projects required for the given project to function. Each BOM contains a combination of versions that work best together
Ruiz: even if don't use it, you consume it. If you use Spring Boot or Quarkus you import just one thing which brings more dependencies...
Regardless of the type you use, they will be consumed in the same way: via a dependency management block (Ruiz claims that's the reason that they exist). In order to consume a BOM, besides the artifactID and groupID you need to add also the POM type, otherwise, Maven will try to resolve it as a JAR file. The POM files are composed of just metadata.
The next puzzlers were variations of the ones presented before. They included a parent and a child POM and the effective POM had two transitive dependencies, a dependency management block in the parent with one dependency and another one that imports a BOM (shown on the left). The next puzzler (not shown) added a direct dependency to the mix. The effective POM file will refer to the dependency block defined in the parent and the chosen version of Guava will be the one defined there. So, in the case of the first puzzler, it resolved to: 28.0-jre. In the second case, even though the two presented emphasised during the whole duration of the presentation that "direct dependencies always win", now they amended their statement by adding that "...with the exception of the situation a BOM file is imported, then everything else is ignored". So, in that case, the version from the dependency management block is used: 26.0-jre.
The next situation (shown on the left) has a dependency management block defined by the parent and in the child POM. The one in child, contains a BOM import but also dependency. In this case, as the version is closer to the source of the usage, it will override the one in the parent. So, the dependency defined in the dependency block of the child POM will be the one that will be used: 26.0-jre. In another situation -- a dependency block in the parent, a dependency block importing a BOM in the child, together with a transitive dependency and a direct dependency too -- the direct dependency will be the one that will win: 28.0-jre.
In the closing part of the presentation, Ruiz and Almiray extracted the key takeaways from their presentation. She mentioned how frustrating it is that the resolution of dependencies is so dependent on the position of definitions inside the POM files. They re-emphasized the importance of using the Maven Enforcer plugin to ensure that there are clear rules defined and followed. In this way, a new entry in a different position of the dependency definition file will not change the output of the build process.
Almiray: if there is one thing to take from this presentation it is to use the Maven enforcer plugin and break your build today, for all the right reasons...
Ruiz's closing remark is a reminder of the dependency resolution rules Maven uses: direct dependencies always win. Transitive dependencies will select the first and closest one to find. Dependency management resolution will be used as a catalogue. In the case of BOM files, you use them even if you don't know it. Almiray ends by stating that exclusions can be used as a last resort and that dependency management blocks are nice as long as you build with Maven. But if you have different consumers, you will need to transform all different types of dependency blocks into explicit dependencies. Maven flatten can do that for you. A case that you should do this is if the consumer is Gradle, which doesn't support anything but direct dependencies.