Maven (Part 12) - Introduction to Dependency Mechanism
Maven (Part 12) - Introduction to Dependency Mechanism

Maven (Part 12) - Introduction to Dependency Mechanism

in
  1. Transitive Dependencies
  2. Dependency Scopes
  3. Dependency Management
    1. Importing Dependencies
    2. BOM

Dependency management is one of the core functionalities of Maven. Managing dependencies for a single project is straightforward. It’s also possible to manage dependencies for multi-module projects and applications consisting of hundreds of modules. Maven helps define, create, and maintain reproducible builds with clearly defined classpaths and library versions.

Transitive Dependencies

Maven eliminates the need to discover and specify your own dependencies by automatically including transitive dependencies.

This functionality is achieved by reading project files of dependencies from specified remote repositories. Generally, all dependencies of these projects will be used in your project, and any dependencies inherited from the parent project or dependencies of dependencies will also be included.

There’s no limit to the depth of dependency collection. Issues only arise when circular dependencies are detected.

Due to transitive dependencies, the dependency graph can quickly grow large. Therefore, there are additional features to limit the scope of dependencies:

  • Dependency Mediation: This determines which version of a dependency to use when encountering multiple versions. Maven selects the “nearest definition,” meaning it uses the version of the dependency closest to your project in the dependency tree. You can ensure versions by explicitly declaring them in the project’s POM. Note that if two dependency versions are at the same depth in the dependency tree, the version declared first takes precedence.
    • “Nearest definition” means the version used will be the one closest to your project in the dependency tree. Consider this dependency tree:
  A
  ├── B
  │   └── C
  │       └── D 2.0
  └── E
      └── D 1.0

If the dependencies of A, B, and C are defined as A -> B -> C -> D:2.0 and A -> E -> D:1.0, then D:1.0 will be used when building A because the path from A to D via E is shorter. You can explicitly add a dependency on D:2.0 in A to force its usage, as shown below:

  A
  ├── B
  │   └── C
  │       └── D 2.0
  ├── E
  │   └── D 1.0
  │
  └── D 2.0
  • Dependency Management: This allows project authors to directly specify the version to use when encountering a transitive dependency or a dependency without a specified version. In the example from the previous section, a dependency was directly added to A even though A does not directly use that dependency. Instead, A can include D in its dependency management section and directly control the version used when referencing D.

  • Dependency Scope: This allows you to include dependencies suitable for the current build stage. This will be detailed further below.

  • Excluded Dependencies: If project X -> Y -> Z, the owner of project X can explicitly exclude project Z as a dependency using the “exclusion” element.

  • Optional Dependencies: If project Y -> Z, the owner of project Y can mark project Z as an optional dependency using the “optional” element. When project X depends on project Y, X will only depend on Y and not on Y’s optional dependency Z. (Considering optional dependencies as “default exclusions” can be helpful.)

While transitive dependencies can implicitly include the required dependencies, it’s still a good practice to explicitly specify dependencies that your source code directly uses. This best practice proves its value, especially when project dependencies change.

For example, suppose project A specifies a dependency on another project B, and project B specifies a dependency on project C. If you directly use components from project C without specifying project C in project A, a sudden update/removal of the dependency from project B to project C could lead to build failures.

Another reason for directly specifying dependencies is to provide better documentation for the project: simply by reading the project’s POM file or running mvn dependency:tree, you can gather more information.

Maven also provides the dependency:analyze plugin goal for analyzing dependencies, which helps make this best practice easier to implement.

Dependency Scopes

Dependency scopes are used to limit the transitivity of dependencies and determine when dependencies are included in the classpath.

There are six scopes:

  • compile: This is the default scope used when no scope is specified. Compile dependencies are available in all classpaths of the project (compile, test, runtime). Additionally, these dependencies are also propagated to dependent projects.

  • provided: This is similar to compile, but indicates that you expect the JDK or a container to provide the dependency at runtime. For example, when building web applications for Java Enterprise Edition, you would set the scope of dependencies on the Servlet API and related Java EE APIs to provided because the web container provides these classes. Dependencies with this scope are added to the classpath for compilation and testing but not for runtime. It does not have transitivity.

  • runtime: This scope indicates dependencies that are not required for compilation but are required at runtime. Maven includes dependencies with this scope in runtime and test classpaths but not in the compile classpath.

  • test: This scope indicates dependencies that are not required for normal use of the application and are only available during test compilation and execution. This scope does not have transitivity. It’s commonly used for testing libraries like JUnit and Mockito. If a non-testing library (such as Apache Commons IO) is used in unit tests (src/test/java) but not in the main code (src/main/java), this scope can also be used.

  • system: This scope is similar to provided, but you must explicitly provide the JAR with this scope. (You can specify the path to the dependency JAR via the dependency’s systemPath element.) The artifact is always available and not looked up in the repository.

  • import: This scope is only supported for POM-type dependencies in the <dependencyManagement> section. It indicates that the dependency will be replaced by the effective dependency list in the <dependencyManagement> section of the importing project’s POM. Since the dependency has been replaced, dependencies with the import scope do not actually participate in limiting the transitivity of dependencies.

The table below shows how each scope (except import) affects transitive dependencies. If a dependency is set to the scope in the left column, then the dependency between it and the scope in the top row will result in a dependency in the main project with the scope listed at the intersection. If no scope is listed, it means the dependency is omitted.

  compile provided runtime test
compile compile(*) - runtime -
provided provided - provided -
runtime runtime - runtime -
test test - test -

Note: This should

be the runtime scope, so all compile dependencies must be explicitly listed. However, if the library you depend on extends classes from another library, both libraries must be available at compile time. Therefore, compile-time dependencies, even with transitivity, are still compile scope.

Dependency Management

The dependency management section is a mechanism for centrally managing dependency information. When a group of projects inherit from a common parent project, all dependency-related information can be placed in a common POM, simplifying artifact references in child POMs. This mechanism can be well illustrated with a few examples. The following two POMs extend the same parent:

Project A:

<project>
  ...
  <dependencies>
    <dependency>
      <groupId>group-a</groupId>
      <artifactId>artifact-a</artifactId>
      <version>1.0</version>
      <exclusions>
        <exclusion>
          <groupId>group-c</groupId>
          <artifactId>excluded-artifact</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>group-a</groupId>
      <artifactId>artifact-b</artifactId>
      <version>1.0</version>
      <type>bar</type>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

Project B:

<project>
  ...
  <dependencies>
    <dependency>
      <groupId>group-c</groupId>
      <artifactId>artifact-b</artifactId>
      <version>1.0</version>
      <type>war</type>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>group-a</groupId>
      <artifactId>artifact-b</artifactId>
      <version>1.0</version>
      <type>bar</type>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

These two sample POMs have a common dependency, and each POM has a non-trivial dependency. This information can be placed in the parent POM like this:

<project>
  ...
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>group-a</groupId>
        <artifactId>artifact-a</artifactId>
        <version>1.0</version>

        <exclusions>
          <exclusion>
            <groupId>group-c</groupId>
            <artifactId>excluded-artifact</artifactId>
          </exclusion>
        </exclusions>

      </dependency>

      <dependency>
        <groupId>group-c</groupId>
        <artifactId>artifact-b</artifactId>
        <version>1.0</version>
        <type>war</type>
        <scope>runtime</scope>
      </dependency>

      <dependency>
        <groupId>group-a</groupId>
        <artifactId>artifact-b</artifactId>
        <version>1.0</version>
        <type>bar</type>
        <scope>runtime</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

This way, the two child POMs are simplified:

<project>
  ...
  <dependencies>
    <dependency>
      <groupId>group-a</groupId>
      <artifactId>artifact-a</artifactId>
    </dependency>

    <dependency>
      <groupId>group-a</groupId>
      <artifactId>artifact-b</artifactId>
      <!-- This is not a jar dependency, so we must specify type. -->
      <type>bar</type>
    </dependency>
  </dependencies>
</project>
<project>
  ...
  <dependencies>
    <dependency>
      <groupId>group-c</groupId>
      <artifactId>artifact-b</artifactId>
      <!-- This is not a jar dependency, so we must specify type. -->
      <type>war</type>
    </dependency>

    <dependency>
      <groupId>group-a</groupId>
      <artifactId>artifact-b</artifactId>
      <!-- This is not a jar dependency, so we must specify type. -->
      <type>bar</type>
    </dependency>
  </dependencies>
</project>

Note: In two of the dependency references, we must specify the <type/> element. This is because the minimal information set that matches dependency references in the dependency management (dependencyManagement) section is actually {groupId, artifactId, type, classifier}. In many cases, these dependencies reference artifacts with no classifier. Since the default value for the type field is jar and the default classifier is empty, we can abbreviate the identity set to {groupId, artifactId}.

The second very important use of the dependency management section is to control the project versions used for transitive dependencies. Let’s illustrate this with an example:

Project A:

<project>
 <modelVersion>4.0.0</modelVersion>
 <groupId>maven</groupId>
 <artifactId>A</artifactId>
 <packaging>pom</packaging>
 <name>A</name>
 <version>1.0</version>
 <dependencyManagement>
   <dependencies>
     <dependency>
       <groupId>test</groupId>
       <artifactId>a</artifactId>
       <version>1.2</version>
     </dependency>
     <dependency>
       <groupId>test</groupId>
       <artifactId>b</artifactId>
       <version>1.0</version>
       <scope>compile</scope>
     </dependency>
     <dependency>
       <groupId>test</groupId>
       <artifactId>c</artifactId>
       <version>1.0</version>
       <scope>compile</scope>
     </dependency>
     <dependency>
       <groupId>test</groupId>
       <artifactId>d</artifactId>
       <version>1.2</version>
     </dependency>
   </dependencies>
 </dependencyManagement>
</project>

Project B:

<project>
  <parent>
    <artifactId>A</artifactId>
    <groupId>maven</groupId>
    <version>1.0</version>
  </parent>
  <modelVersion>4.0.0</modelVersion>
  <groupId>maven</groupId>
  <artifactId>B</artifactId>
  <packaging>pom</packaging>
  <name>B</name>
  <version>1.0</version>
 
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>test</groupId>
        <artifactId>d</artifactId>
        <version>1.0</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
 
  <dependencies>
    <dependency>
      <groupId>test</groupId>
      <artifactId>a</artifactId>
      <version>1.0</version>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>test</groupId>
      <artifactId>c</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

When Maven runs on Project B, versions 1.0 of a, b, c, and d will be used, regardless of what versions are specified in their POMs.

  • Both a and c are declared as dependencies of the project, so version 1.0 is used due to dependency mediation. Since they are directly specified with a runtime scope, both have a runtime scope.
  • b is defined in the parent’s dependency management section. Since for transitive dependencies, dependency management takes precedence over dependency mediation, if version 1.0 is referenced in the POMs of a or c, version 1.0 will be chosen.
  • Lastly, since d is specified in Project B’s dependency management section, if d is a dependency (or transitive dependency) of a or c, version 1.0 will be chosen as well, again because dependency management takes precedence over dependency mediation, and the declaration in the current POM takes precedence over its parent’s declaration.

Reference information for the <dependencyManagement> tag can be found in the Project Descriptor Reference.

Importing Dependencies

The examples in the previous section demonstrated how to specify dependencies through inheritance. However, in large projects, it may not always be feasible as a project can only inherit from one parent project. To address this, projects can import dependencies from other projects. This can be achieved by declaring POM dependencies with <scope>import</scope>.

Project B:

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>maven</groupId>
  <artifactId>B</artifactId>
  <packaging>pom</packaging>
  <name>B</name>
  <version>1.0</version>
 
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>maven</groupId>
        <artifactId>A</artifactId>
        <version>1.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>test</groupId>
        <artifactId>d</artifactId>
        <version>1.0</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
 
  <dependencies>
    <dependency>
      <groupId>test</groupId>
      <artifactId>a</artifactId>
      <version>1.0</version>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>test</groupId>
      <artifactId>c</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

Assuming A is the POM defined in the previous example, the end result is the same. All dependency management relationships from A will be incorporated into B, except for d, as it’s defined in this POM.

Project X:

<project>
 <modelVersion>4.0.0</modelVersion>
 <groupId>maven</groupId>
 <artifactId>X</artifactId>
 <packaging>pom</packaging>
 <name>X</name>
 <version>1.0</version>
 
 <dependencyManagement>
   <dependencies>
     <dependency>
       <groupId>test</groupId>
       <artifactId>a</artifactId>
       <version>1.1</version>
     </dependency>
     <dependency>
       <groupId>test</groupId>
       <artifactId>b</artifactId>
       <version>1.0</version>
       <scope>compile</scope>
     </dependency>
   </dependencies>
 </dependencyManagement>
</project>

Project Y:

<project>
 <modelVersion>4.0.0</modelVersion>
 <groupId>maven</groupId>
 <artifactId>Y</artifactId>
 <packaging>pom</packaging>
 <name>Y</name>
 <version>1.0</version>
 
 <dependencyManagement>
   <dependencies>
     <dependency>
       <groupId>test</groupId>
       <artifactId>a</artifactId>
       <version>1.2</version>
     </dependency>
     <dependency>
       <groupId>test</groupId>
       <artifactId>c</artifactId>
       <version>1.0</version>
       <scope>compile</scope>
     </dependency>
   </dependencies>
 </dependencyManagement>
</project>

Project Z:

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>maven</groupId>
  <artifactId>Z</artifactId>
  <packaging>pom</packaging>
  <name>Z</name>
  <version>1.0</version>
 
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>maven</groupId>
        <artifactId>X</artifactId>
        <version>1.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>maven</groupId>
        <artifactId>Y</artifactId>
        <version>1.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

In the above examples, Z imports managed dependencies from X and Y. Both X and Y contain dependency a, but since X is declared first and a is not declared in Z’s dependency management, version 1.1 will be used instead of 1.2.

This process is recursive. For instance, if X imports another POM Q, then when processing Z, all dependency management defined in Q will be found within X.

BOM

Importing is most effective when defining “libraries” containing artifacts that are typically part of multi-project builds. It’s common for a project to use one or more artifacts from these libraries. However, it can be challenging to ensure that the versions of artifacts used by projects remain in sync with those distributed in the library. The following pattern illustrates how to create a “Bill of Materials” (BOM) for use by other projects.

The root of the project is the BOM POM. It defines the versions of all artifacts that will be created in the library. Other projects wishing to use the library should import this POM into their POM’s dependency management section.

<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>com.test</groupId>
  <artifactId>bom</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>
  <properties>
    <project1Version>1.0.0</project1Version>
    <project2Version>1.0.0</project2Version>
  </properties>
 
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.test</groupId>
        <artifactId>project1</artifactId>
        <version>${project1Version}</version>
      </dependency>
      <dependency>
        <groupId>com.test</groupId>
        <artifactId>project2</artifactId>
        <version>${project2Version}</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
 
  <modules>
    <module>parent</module>
  </modules>
</project>

The parent submodule treats the BOM POM as its parent project. This is a standard multi-module POM.

<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>com.test</groupId>
    <version>1.0.0</version>
    <artifactId>bom</artifactId>
  </parent>
 
  <groupId>com.test</groupId>
  <artifactId>parent</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>
 
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>log4j</groupId>
        <artifactId>log4j</artifactId>
        <version>1.2.12</version>
      </dependency>
      <dependency>
        <groupId>commons-logging</groupId>
        <artifactId>commons-logging</artifactId>
        <version>1.1.1</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <modules>
    <module>project1</module>
    <module>project2</module>
  </modules>
</project>

Next are the actual project POMs:

<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>com.test</groupId>
    <version>1.0.0</version>
    <artifactId>parent</artifactId>
  </parent>
  <groupId>com.test</groupId>
  <artifactId>project1</artifactId>
  <version>${project1Version}</version>
  <packaging>jar</packaging>
 
  <dependencies>
    <dependency>
      <groupId>log4j</groupId>
      <artifactId>log4j</artifactId>
    </dependency>
  </dependencies>
</project>
 
<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>com.test</groupId>
    <version>1.0.0</version>
    <artifactId>parent</artifactId>
  </parent>
  <groupId>com.test</groupId>
  <artifactId>project2</artifactId>
  <version>${project2Version}</version>
  <packaging>jar</packaging>
 
  <dependencies>
    <dependency>
      <groupId>commons-logging</groupId>
      <artifactId>commons-logging</artifactId>
    </dependency>
  </dependencies>
</project>

The following project demonstrates how to use the library in another project without specifying the versions of the dependent projects:

<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>com.test</groupId>
  <artifactId>use</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>
 
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.test</groupId>
        <artifactId>bom</artifactId>
        <version>1.0.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.test</groupId>
      <artifactId>project1</artifactId>
    </dependency>
    <dependency>
      <groupId>com.test</groupId>
      <artifactId>project2</artifactId>
    </dependency>
  </dependencies>
</project>

Finally, when creating import dependencies in your project, please pay attention to the following:

  • Do not attempt to import a POM defined in a submodule of the current POM. Doing so will cause build failures because it cannot locate the POM.
  • Do not declare the imported POM as a parent of the target POM (or grandparent, etc.). This will not resolve cyclic issues and will throw exceptions.
  • When referencing artifacts with transitive dependencies in a POM, the project needs to specify the versions of these artifacts as managed dependencies. Failure to do so will result in build failures because the artifacts may not have specified versions. (In any case, this should be considered a best practice as it prevents the versions of artifacts from changing from one build to another).

Starting from Maven 4.0, a new specific BOM wrapping has been introduced. It allows the definition of BOMs that are not used as parents in projects utilizing the newer 4.1.0 model, while remaining fully compatible with Maven 3.x clients and projects. During installation/deployment, this BOM wrapping utilizes the build/consumer POM functionality in Maven 4 to transform into the more common POM wrapping. Thus, it is fully compatible with Maven 3.x.

<project xmlns="http://maven.apache.org/POM/4.1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 http://maven.apache.org/xsd/maven-4.1.0.xsd">
  <parent>
    <groupId>com.test</groupId>
    <version>1.0.0</version>
    <artifactId>parent</artifactId>
  </parent>
  <groupId>com.test</groupId>
  <artifactId>bom</artifactId>
  <version>1.0.0</version>
  <packaging>bom</packaging>
  <properties>
    <project1Version>1.0.0</project1Version>
    <project2Version>1.0.0</project2Version>
  </properties>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.test</groupId>
        <artifactId>project1</artifactId>
        <version>${project1Version}</version>
      </dependency>
      <dependency>
        <groupId>com.test</groupId>
        <artifactId>project2</artifactId>
        <version>${project2Version}</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
 </project>