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.
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.
-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.
./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.
.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?
It is a classpath threshold representing what belongs on the
runtime classpath, not a literal filter for only dependencies
whose POM scope text is runtime.
The consumer tree does not contain Commons Codec even though the installed library POM names it. Is the local install corrupt?
No. The producer marked Codec optional. That edge remains usable by the producer but does not propagate automatically to consumers.
Why add Commons Lang directly after excluding the Commons Text → Lang edge?
Because the application imports Lang APIs itself. A direct declaration makes the application’s requirement and selected version explicit instead of depending on an implementation detail of Commons Text.
Why is H2 a defensible runtime-scope example here?
The main source does not compile against H2 types; it loads the driver reflectively for execution. Runtime scope therefore matches the actual compile/runtime requirement.
What is the safe response if the isolated repository might be hiding a problem?
Create a second fresh isolated repository and compare resolution/build evidence. Do not blindly delete the normal user repository.
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.
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.
- Maven — Introduction to the Dependency Mechanism
- Maven — Optional Dependencies and Dependency Exclusions
- Maven — Dependency and Repository Model
- Maven — POM Reference
- Maven Dependency Plugin 3.11.0 — Usage
- Maven Dependency Plugin 3.11.0 — Plugin Details
- Maven Dependency Plugin — dependency:tree
- Maven Dependency Plugin — dependency:build-classpath
- Maven Releases History
- Apache Maven Wrapper
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.