Mastering Maven: Dependency Basics

March 13, 2020 | 5 minute read
Text Size 100%:

Welcome back to the Mastering Maven series. In this post we'll explore dependency resolution and its effects on a single project. As you may have seen before, dependencies must be defined inside the <dependencies> block. Each dependency must declare its GAV coordinates as minimum settings, a <scope> element may be declared to instruct Maven where to place the resolved dependency in terms of classpath configuration, however dependencies will be placed under the compile scope if no such element is explicitly defined.

Maven defines 6 scopes: compile, runtime, provided, system, test, and import. Maven defines the behavior for each scope as following (copied verbatim from the dependency management page)

  • compile This is the default scope, used if none is specified. Compile dependencies are available in all classpaths of a project. Furthermore, those dependencies are propagated to dependent projects.
  • provided This is much like compile, but indicates you expect the JDK or a container to provide the dependency at runtime. For example, when building a web application for the Java Enterprise Edition, you would set the dependency on the Servlet API and related Java EE APIs to scope provided because the web container provides those classes. This scope is only available on the compilation and test classpath, and is not transitive.
  • runtime This scope indicates that the dependency is not required for compilation, but is for execution. It is in the runtime and test classpaths, but not the compile classpath.
  • test This scope indicates that the dependency is not required for normal use of the application, and is only available for the test compilation and execution phases. This scope is not transitive.
  • system This scope is similar to provided except that you have to provide the JAR which contains it explicitly. The artifact is always available and is not looked up in a repository.
  • import (only available in Maven 2.0.9 or later) This scope is only supported on a dependency of type pom in the section. It indicates the dependency to be replaced with the effective list of dependencies in the specified POM's section. Since they are replaced, dependencies with a scope of import do not actually participate in limiting the transitivity of a dependency.
At the bottom of the scope hierarchy we find compile, used to define dependencies needed for compilation. Next we've got provided which also defines dependencies available for compilation. Dependencies defined by compile and provided must be resolved against a repository and they are also visible for runtime execution; the difference strives that provided should not expose its dependencies to the final packaging as they are expected to be provided by the hosting environment (hence the name), such as an application server. The third one is system that behaves like provided except that it does not require a repository for resolution, rather you must define a path (preferably a relative path to the root of the project) that points to the artifact's location. Dependencies placed this scope are also visible at runtime but you must make sure they are reachable by their path. We move on to runtime, which as the name implies, are dependencies required during execution. Finally we find test, which builds on top of the previous 4 scopes, defining those dependencies required for both compilation and execution of tests.

 

Let's have a look at these scopes in action, shall we? The following POM file defines 4 dependencies, 2 of which are placed in the compile scope (1 implicit and 1 explicit), one dependency in the runtime scope, and one dependency in the test scope.

We can obtain a report of all dependencies used by the build by invoking the mvn dependency:list command, like so

As you can see the list contains all dependencies we defined, listed by scope. We can appreciate that the commons-lang3 dependency is indeed placed in the compile scope even though we didn't specify a scope for it, neat! But hold on, the list displays 5 elements even though we defined 4, what's going on here? Dependencies may have additional dependencies as well, which can be brought into the build. These are known as transitive dependencies, in our case the hamcrest-core dependency was added by one of the other 4 dependencies, but which one? The mystery can be solved by invoking the mvn dependency:tree command

This command lists all dependencies using a tree structure instead of a flat list as we saw before. This tree structure shows dependencies as they are resolved, including transitive dependencies, and now we can answer the question of who brought hamcrest-core into the build: it was in fact junit.

Moving on to the next scope, provided, you typically find this scope used on projects that expect dependencies to exist on the classpath of the hosting environment, such as an Application Server. However, the side effect of a provided dependency is that it's required for compilation but it might be omitted at runtime if the hosting environment does not provide said dependency. It is due to this "loophole" that libraries that rely on annotation processors decided to latch onto this scope, and as such you'll find dependencies such as Lombok, Dagger, AutoService, and other popular annotation-based libraries being applied on this scope.

Next we have the system scope, which I must say it's widely regarded as a scope you must avoid at all costs, a suggestion I strongly agree with, except for one particular use case: if you know what you're doing and you're aware of the consequences. Here's the thing with this scope, dependencies placed under system are resolved from the filesystem and never from a repository as other dependencies are. Builds become environment-specific especially if the dependency path is absolute instead of relative to the project. There's also the matter of missing metadata, as these dependencies do not have a companion POM file thus any transitive dependencies must be defined explicitly as well. There's one use case in my mind (and take this with a grain of salt) that makes it valid to use this scope: you're developing a feature that requires some experimental dependency that is not yet available from a repository, this feature is in flux and may or may not see the light of day, basically you may throw away the whole thing at some point and do not want to "pollute" your local/remote repositories with a dependency that may need to be removed.

Now for the final scope which has become more important in that last years with the rise of libraries and frameworks that require a specific set of dependencies to work, frameworks such as Spring Boot, Helidon, Micronaut, Quarkus to name a few; this scope deserves a post of its own as it leads into a concept known as Bill of Materials (or BOM for short) that requires further explanations.

Source code available at 04-dependencies.

Andres Almiray


Previous Post

Each for Equal: Celebrating International Women's Day

Bob Rhubart | 3 min read

Next Post


Working with HTTP in Oracle Functions using the Fn Project Python FDK

Cameron Senese | 6 min read