Chapter 06Lesson 02~165 minutes

Maven Dependencies, Scopes, Transitive Resolution, Exclusions, Optional Dependencies, and Classpaths: Guided Hands-On Workflow and Core Operations

Build and inspect a disposable Maven dependency graph with compile, runtime, test, and provided scopes, a transitive exclusion with a direct replacement, and a locally installed library whose optional dependency does not propagate to consumers.

dependency:treeBuild ClasspathExclusionsOptional DependenciesLocal Install

Learning objectives

  • Create a disposable producer/consumer Maven scenario using the verified wrapper and an isolated local repository.
  • Inspect compile, runtime, and test classpaths with Maven Dependency Plugin 3.11.0.
  • Exclude one transitive dependency and replace it with an intentional direct declaration.
  • Install a small library whose optional dependency remains in producer metadata but does not propagate to the consumer.
  • Demonstrate and repair the optional-feature boundary with observable dependency-tree and runtime evidence.
Current baseline — verified 2026-08-23. The production teaching path uses Maven 3.9.16, Maven Wrapper 3.3.4, Maven Dependency Plugin 3.11.0, JDK 21, and Java 17 bytecode/API targeting. All labs use a disposable project directory plus -Dmaven.repo.local=<lab>/.lab-m2/repository; normal ~/.m2 state is never deleted. Network access is used only for immutable public dependencies. A warm-cache replay is optional; the conceptual path remains understandable from the supplied expected evidence.
Cross-platform note: command blocks labeled POSIX shell use ./mvnw, grep, find, and : classpath separators. On Windows use mvnw.cmd, PowerShell equivalents such as Select-String/Get-ChildItem, and ; as the Java classpath separator. The Maven model and scope semantics are the same.

1. Scenario and preflight

You maintain a small application plus an internal library. The application needs Apache Commons Text, a newer Commons Lang version, H2 only at runtime, a Servlet API supplied by a container, JUnit only for tests, and an internal digest feature whose Commons Codec dependency is optional. Your job is to make every edge observable before deciding whether the model is correct.

Safety boundary: use the verified Chapter 04 wrapper files only. The lab writes to .lab-m2/repository and local target/ directories. It does not deploy remotely, modify user/global settings, or handle credentials.

2. Create the disposable directory tree

The wrapper lives at the lab root. Maven’s -f option selects the producer or consumer POM while both share the isolated repository.

mkdir -p maven-scope-lab/{optional-feature-lib,scope-app}
cd maven-scope-lab
mkdir -p .lab-m2/repository

# Copy the already verified Chapter 04 wrapper files into this disposable root:
#   mvnw  mvnw.cmd  .mvn/wrapper/maven-wrapper.properties
# Keep Maven 3.9.16 and its verified distribution checksum unchanged.

REPO="$PWD/.lab-m2/repository"
./mvnw --version | tee maven-version.txt
java -version 2> java-version.txt
printf 'isolated repo: %s
' "$REPO"

3. Build the optional-feature producer

The producer contains two classes. FeatureCore has no external runtime requirement. DigestFeature uses Commons Codec. Marking Codec optional means the producer can compile and install normally, but consumers of the producer do not inherit Codec automatically.

<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>dev.academy</groupId>
  <artifactId>optional-feature-lib</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>commons-codec</groupId>
      <artifactId>commons-codec</artifactId>
      <version>1.17.1</version>
      <optional>true</optional>
    </dependency>
  </dependencies>
</project>
package dev.academy.feature;

public final class FeatureCore {
    private FeatureCore() {}
    public static String basic() { return "feature-core"; }
}
package dev.academy.feature;

import org.apache.commons.codec.digest.DigestUtils;

public final class DigestFeature {
    private DigestFeature() {}
    public static String sha256(String value) {
        return DigestUtils.sha256Hex(value);
    }
}
mkdir -p optional-feature-lib/src/main/java/dev/academy/feature
# Save the files shown above, then:
./mvnw -Dmaven.repo.local="$REPO" -f optional-feature-lib/pom.xml clean install

# Inspect what install wrote. This is local repository state, not remote publishing.
find "$REPO/dev/academy/optional-feature-lib/1.0.0" -maxdepth 1 -type f -print

grep -n -A6 -B2 '<optional>true</optional>'   "$REPO/dev/academy/optional-feature-lib/1.0.0/optional-feature-lib-1.0.0.pom"

Expected observation: the isolated repository contains the library JAR and POM. The installed POM retains the optional marker. That metadata is what a consumer later reads.

4. Author the consumer with four ordinary scopes plus an exclusion

The consumer models four different deployment/test contracts. Commons Text is compile; H2 is runtime because the code loads its driver reflectively; the Servlet API is provided because an external servlet container would supply it; JUnit is test-only. The Commons Text → Commons Lang transitive edge is excluded, then Commons Lang 3.17.0 is declared directly because the application itself imports StringUtils.

<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>dev.academy</groupId>
  <artifactId>scope-app</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>dev.academy</groupId>
      <artifactId>optional-feature-lib</artifactId>
      <version>1.0.0</version>
    </dependency>

    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-text</artifactId>
      <version>1.10.0</version>
      <exclusions>
        <exclusion>
          <groupId>org.apache.commons</groupId>
          <artifactId>commons-lang3</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.17.0</version>
    </dependency>

    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>2.3.232</version>
      <scope>runtime</scope>
    </dependency>

    <dependency>
      <groupId>jakarta.servlet</groupId>
      <artifactId>jakarta.servlet-api</artifactId>
      <version>6.1.0</version>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>
package dev.academy;

import dev.academy.feature.FeatureCore;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringSubstitutor;
import java.util.Map;

public final class App {
    private App() {}
    public static String message(String name) {
        String safe = StringUtils.defaultIfBlank(name, "learner");
        return StringSubstitutor.replace("hello ${name} / ${feature}",
            Map.of("name", safe, "feature", FeatureCore.basic()));
    }
    public static void main(String[] args) {
        System.out.println(message(args.length == 0 ? null : args[0]));
    }
}
package dev.academy;

public final class DbProbe {
    private DbProbe() {}
    public static void main(String[] args) throws Exception {
        Class.forName("org.h2.Driver");
        System.out.println("h2-runtime-present");
    }
}
package dev.academy;

import jakarta.servlet.http.HttpServlet;

public final class ContainerOnlyEndpoint extends HttpServlet {
    private static final long serialVersionUID = 1L;
}
package dev.academy;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class AppTest {
    @Test
    void rendersStableMessage() {
        assertEquals("hello learner / feature-core", App.message(""));
    }
}

5. Resolve once and capture the graph

Build with the isolated repository, then capture a machine-readable or text dependency tree. The first run is a cold-ish resolution for this lab; the next run can reuse only this lab’s cache.

mkdir -p scope-app/src/main/java/dev/academy scope-app/src/test/java/dev/academy

./mvnw -Dmaven.repo.local="$REPO" -f scope-app/pom.xml clean test

./mvnw -Dmaven.repo.local="$REPO" -f scope-app/pom.xml   org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree   -DoutputFile=target/dependency-tree.txt

cat scope-app/target/dependency-tree.txt
dev.academy:scope-app:jar:1.0.0
+- dev.academy:optional-feature-lib:jar:1.0.0:compile
+- org.apache.commons:commons-text:jar:1.10.0:compile
+- org.apache.commons:commons-lang3:jar:3.17.0:compile
+- com.h2database:h2:jar:2.3.232:runtime
+- jakarta.servlet:jakarta.servlet-api:jar:6.1.0:provided
\- org.junit.jupiter:junit-jupiter:jar:5.13.4:test
   \- ... JUnit transitive test components ...

Not expected as a transitive consumer dependency:
  commons-codec:commons-codec:1.17.1
Not expected through commons-text after the exclusion:
  org.apache.commons:commons-lang3:3.12.0

The exact tree can include additional transitive test/runtime nodes, but two invariants matter: Commons Codec does not transit from the optional edge, and the excluded Commons Lang request from Commons Text does not survive that path.

6. Project the same graph into three classpaths

The Dependency Plugin’s includeScope parameter is a classpath threshold. In current plugin behavior, compile yields compile/provided/system; runtime yields compile/runtime; test yields all dependencies. That lets us compare the actual artifact paths Maven would make visible in each context.

DEP=org.apache.maven.plugins:maven-dependency-plugin:3.11.0

./mvnw -Dmaven.repo.local="$REPO" -f scope-app/pom.xml   "$DEP":build-classpath -DincludeScope=compile   -Dmdep.outputFile=target/compile.cp

./mvnw -Dmaven.repo.local="$REPO" -f scope-app/pom.xml   "$DEP":build-classpath -DincludeScope=runtime   -Dmdep.outputFile=target/runtime.cp

./mvnw -Dmaven.repo.local="$REPO" -f scope-app/pom.xml   "$DEP":build-classpath -DincludeScope=test   -Dmdep.outputFile=target/test.cp

printf '%s
' '--- compile ---'; tr ':' '
' < scope-app/target/compile.cp
printf '%s
' '--- runtime ---'; tr ':' '
' < scope-app/target/runtime.cp
printf '%s
' '--- test ---'; tr ':' '
' < scope-app/target/test.cp
Artifact Compile view Runtime view Test view Reason
Commons Text/Lang + optional-feature-lib Yes Yes Yes Compile dependencies flow to all ordinary project classpaths.
H2 No Yes Yes Runtime scope intentionally avoids main compilation.
Servlet API Yes No Yes Provided is available to compile/test but external at runtime.
JUnit No No Yes Test scope is isolated to test compilation/execution.
Commons Codec optional edge No No No The producer marked it optional, so it does not propagate to the consumer.

7. Verify runtime-only and normal compile dependencies independently

Do not infer classpath behavior from the POM alone. Run a class that uses normal compile dependencies and another that reflectively loads the runtime-only H2 driver.

CP="scope-app/target/classes:$(cat scope-app/target/runtime.cp)"
java -cp "$CP" dev.academy.App Academy
java -cp "$CP" dev.academy.DbProbe
hello Academy / feature-core
h2-runtime-present

The H2 class never had to be visible to javac because DbProbe names it as a string and loads it at runtime. That is a legitimate example of runtime scope rather than a trick to suppress a compile error.

8. Prove what optional means at the consumer boundary

Add OptionalDemo to the consumer. It compiles because the public method signature in optional-feature-lib does not expose Commons Codec types. At runtime, however, invoking the optional implementation requires Codec.

package dev.academy;

import dev.academy.feature.DigestFeature;

public final class OptionalDemo {
    private OptionalDemo() {}
    public static void main(String[] args) {
        System.out.println(DigestFeature.sha256("academy"));
    }
}
./mvnw -Dmaven.repo.local="$REPO" -f scope-app/pom.xml clean package
CP="scope-app/target/classes:$(cat scope-app/target/runtime.cp)"

set +e
java -cp "$CP" dev.academy.OptionalDemo 2>&1 | tee scope-app/target/optional-failure.txt
status=${PIPESTATUS[0]}
set -e
printf 'exit=%s
' "$status"

# Expect a NoClassDefFoundError / ClassNotFoundException involving
# org/apache/commons/codec/... because the optional edge did not transit.

Repair the contract, not the cache: if the application chooses the digest feature, add a direct commons-codec:commons-codec:1.17.1 dependency to scope-app, rebuild the runtime classpath, and rerun. If most consumers need the feature, redesigning the library into a dedicated feature module can be clearer than forcing every consumer to understand an optional edge.

9. Warm-cache replay without trusting the cache as source truth

Run the same test/tree commands again against the same isolated repository and observe less download activity. The graph and classpath membership should remain the same for immutable coordinates. Then, if you need a clean-room comparison, use another empty lab repository rather than deleting your normal Maven cache.

./mvnw -Dmaven.repo.local="$REPO" -f scope-app/pom.xml test

FRESH="$PWD/.lab-m2-fresh/repository"
./mvnw -Dmaven.repo.local="$FRESH" -f scope-app/pom.xml test

# Compare graph evidence after both builds. Do not delete ~/.m2/repository.

10. Challenge — choose the control, do not copy a sequence

Your application imports a Commons Lang API directly, but today it receives Lang only through Commons Text. You also need a newer Lang version than Commons Text requests. Which control best expresses the application’s contract?

Target reasoning: declare Commons Lang directly at the required version. If the transitive path would otherwise introduce a different version and you want to make the path explicit, exclude that path from Commons Text. Do not mark Lang runtime merely to “change precedence,” and do not delete the local repository.

Knowledge check

Why does includeScope=runtime in the Dependency Plugin show compile dependencies too?

The consumer tree does not contain Commons Codec even though the installed library POM names it. Is the local install corrupt?

Why add Commons Lang directly after excluding the Commons Text → Lang edge?

Why is H2 a defensible runtime-scope example here?

What is the safe response if the isolated repository might be hiding a problem?

Summary

You built a producer/consumer dependency graph and proved each contract independently: compile dependencies appear in ordinary runtime/test views, H2 is runtime-only, Servlet API is provided, JUnit is test-only, the Commons Text transitive Lang edge is excluded and replaced directly, and Commons Codec remains optional across the producer/consumer boundary. The graph and the classpath files—not assumptions—are the evidence.

Next lesson

Choose scopes and coupling deliberately

Lesson 3 turns the mechanics into design decisions: when to declare directly, when to exclude, when optional dependencies help, and when a separate feature module is clearer.

Official references and version notes

Version-sensitive statements in this lesson were checked against Apache Maven primary documentation on 2026-08-23. Required labs use Maven 3.9.16 through Maven Wrapper 3.3.4, Maven Dependency Plugin 3.11.0 for graph/classpath inspection, JDK 21 to run Maven, and Java 17 as the application release target. Maven 4.0.0-rc-6 remains preview-stage and is not required here.

The classpath examples use Dependency Plugin 3.11.0. Its includeScope semantics are classpath-threshold semantics: runtime includes compile + runtime; compile includes compile + provided + system; test includes all.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.