Java · lesson 39 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Maven — Java Build and Dependency Management
Learn Maven — the industry-standard Java build tool for managing dependencies, project structure, build lifecycle, and producing deployable artifacts.
By SkillByExample · Updated
Maven builds Java projects from a declarative pom.xml: it downloads dependencies from Maven
Central, compiles, runs tests, and packages a jar, always in the same order and the same folder
layout. Every command below was run with Apache Maven 3.9.11 on JDK 21. Maven’s full output is
long, so several commands are piped through grep to keep the lines that matter; times and dates
in the remaining lines vary from run to run.
$ mvn -version
Apache Maven 3.9.11 (3e54c93a704957b63ee3494413a2b544fd3d825b)
Maven home: /opt/maven
Java version: 21.0.12.1, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64
Default locale: en_US, platform encoding: UTF-8
OS name: "linux", version: "6.18.44-fc-v77", arch: "amd64", family: "unix"
A minimal project
Maven expects production code in src/main/java and tests in src/test/java. The POM names the
project with GAV coordinates (groupId, artifactId, version) and lists its dependencies.
<!-- File: pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
// File: src/main/java/com/example/App.java
package com.example;
import com.google.common.base.Splitter;
import java.util.List;
public class App {
static List<String> tags(String csv) {
return Splitter.on(',').trimResults().omitEmptyStrings().splitToList(csv);
}
public static void main(String[] args) {
System.out.println(tags(args.length > 0 ? args[0] : "java, maven,, build "));
}
}
// File: src/test/java/com/example/AppTest.java
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import org.junit.jupiter.api.Test;
class AppTest {
@Test void trimsAndSkipsEmpty() {
assertEquals(List.of("java", "maven", "build"), App.tags(" java, maven,, build "));
}
}
The build lifecycle
Phases run in a fixed order, and asking for one runs every phase before it: package means
validate, compile, test, then package.
$ mvn -B -ntp package | grep -E '^\[INFO\] (--- |Tests run:|Building jar|BUILD)'
[INFO] --- resources:3.3.1:resources (default-resources) @ my-app ---
[INFO] --- compiler:3.13.0:compile (default-compile) @ my-app ---
[INFO] --- resources:3.3.1:testResources (default-testResources) @ my-app ---
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ my-app ---
[INFO] --- surefire:3.2.5:test (default-test) @ my-app ---
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.066 s -- in com.example.AppTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:3.4.1:jar (default-jar) @ my-app ---
[INFO] Building jar: /tmp/demo5/target/my-app-1.0.0.jar
[INFO] BUILD SUCCESS
Each --- line is a plugin goal bound to a phase: compiler:compile for compile,
surefire:test for test, jar:jar for package. -B (batch mode) and -ntp (no transfer
progress) keep output plain in scripts and CI.
| Command | Does |
|---|---|
mvn compile | compile src/main/java into target/classes |
mvn test | compile, then run tests |
mvn package | … then build the jar in target/ |
mvn install | … then copy it into ~/.m2/repository for other local projects |
mvn clean | delete target/ |
mvn package -DskipTests | package without running tests |
A failing test stops the build
// File: src/test/java/com/example/AppTest.java
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import org.junit.jupiter.api.Test;
class AppTest {
@Test void trimsAndSkipsEmpty() {
assertEquals(List.of("java", "maven"), App.tags(" java, maven,, build "));
}
}
$ mvn -B -ntp clean package | grep -E '^\[(ERROR|INFO)\] (Tests run:|BUILD|Failures:| AppTest)'
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.084 s <<< FAILURE! -- in com.example.AppTest
[ERROR] Failures:
[ERROR] AppTest.trimsAndSkipsEmpty:10 expected: <[java, maven]> but was: <[java, maven, build]>
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
$ ls target/*.jar
ls: cannot access 'target/*.jar': No such file or directory
Surefire reports the test name, line and both values. clean deleted the old target/ first,
and because test comes before package, no new jar was built. Restore the test expectation
before continuing.
Running the jar: the thin-jar problem
// File: src/test/java/com/example/AppTest.java
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import org.junit.jupiter.api.Test;
class AppTest {
@Test void trimsAndSkipsEmpty() {
assertEquals(List.of("java", "maven", "build"), App.tags(" java, maven,, build "));
}
}
$ mvn -B -ntp -q package
$ java -jar target/my-app-1.0.0.jar
no main manifest attribute, in target/my-app-1.0.0.jar
$ java -cp target/my-app-1.0.0.jar com.example.App
Exception in thread "main" java.lang.NoClassDefFoundError: com/google/common/base/Splitter
at com.example.App.tags(App.java:9)
at com.example.App.main(App.java:13)
Caused by: java.lang.ClassNotFoundException: com.google.common.base.Splitter
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
... 2 more
The default jar holds only your classes and no Main-Class entry. Naming the class gets
further, but Guava isn’t inside the jar, so the first use of Splitter fails at runtime — it
compiled fine because Maven put Guava on the compile classpath.
A runnable fat jar with the Shade plugin
The Shade plugin copies all runtime dependencies into one jar and writes the main class into its
manifest. Plugins are configured in <build>.
<!-- File: pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
$ mvn -B -ntp -q package
$ java -jar target/my-app-1.0.0.jar "a, b ,,c"
[a, b, c]
$ ls -l target/*.jar | awk '{print $5, $9}'
3061382 target/my-app-1.0.0.jar
2532 target/original-my-app-1.0.0.jar
Now java -jar works anywhere with a JRE. The size shows what happened: about 2.5 KB of your
classes became a 3 MB jar with Guava inside. Shade keeps the thin jar as original-…jar. Spring
Boot’s plugin produces a similar self-contained jar in its own layout.
Scopes and the dependency tree
A dependency’s scope decides which classpaths it joins and whether it ships.
| Scope | Compile main | Run | Tests | Packaged by Shade |
|---|---|---|---|---|
compile (default) | yes | yes | yes | yes |
provided | yes | no — the server supplies it | yes | no |
runtime | no | yes | yes | yes |
test | no | no | yes | no |
dependency:tree shows everything Maven resolved, including transitive dependencies:
$ mvn -B -ntp dependency:tree | grep -E '^\[INFO\] ([+|\\ ]|com.example)'
[INFO] from pom.xml
[INFO] com.example:my-app:jar:1.0.0
[INFO] +- com.google.guava:guava:jar:33.4.8-jre:compile
[INFO] | +- com.google.guava:failureaccess:jar:1.0.3:compile
[INFO] | +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO] | +- org.jspecify:jspecify:jar:1.0.0:compile
[INFO] | +- com.google.errorprone:error_prone_annotations:jar:2.36.0:compile
[INFO] | \- com.google.j2objc:j2objc-annotations:jar:3.0.0:compile
[INFO] \- org.junit.jupiter:junit-jupiter:jar:5.12.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:5.12.2:test
[INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | +- org.junit.platform:junit-platform-commons:jar:1.12.2:test
[INFO] | \- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:5.12.2:test
[INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:5.12.2:test
[INFO] \- org.junit.platform:junit-platform-engine:jar:1.12.2:test
Two declared dependencies became fourteen artifacts. Each line ends with its scope — the JUnit
subtree inherits test. When two paths bring different versions of the same library, Maven picks
the one nearest the root; use <exclusions> or <dependencyManagement> to override that choice.
Test scope is invisible to main code
// File: src/main/java/com/example/Oops.java
package com.example;
import org.junit.jupiter.api.Assertions;
public class Oops {
static void check(int n) { Assertions.assertTrue(n > 0); }
}
$ mvn -B -ntp -q compile 2>&1 | grep -E 'ERROR.*(Oops.java|BUILD)' | head -3
[ERROR] /tmp/demo5/src/main/java/com/example/Oops.java:[4,29] package org.junit.jupiter.api does not exist
[ERROR] /tmp/demo5/src/main/java/com/example/Oops.java:[7,32] cannot find symbol
[ERROR] /tmp/demo5/src/main/java/com/example/Oops.java:[4,29] package org.junit.jupiter.api does not exist
$ rm src/main/java/com/example/Oops.java
JUnit is on the test classpath only, so production code that imports it doesn’t compile. That keeps test libraries out of what you ship.
Multi-module builds
A parent POM with <packaging>pom</packaging> lists modules and can pin versions for all of them
in <dependencyManagement>. Maven builds modules in dependency order (the “reactor”).
<!-- File: shop/pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.shop</groupId>
<artifactId>shop-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<properties><maven.compiler.release>21</maven.compiler.release></properties>
<modules>
<module>app</module>
<module>core</module>
</modules>
</project>
<!-- File: shop/core/pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example.shop</groupId>
<artifactId>shop-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>core</artifactId>
</project>
// File: shop/core/src/main/java/shop/core/Prices.java
package shop.core;
public class Prices {
public static String format(long cents) { return String.format("$%d.%02d", cents / 100, cents % 100); }
}
<!-- File: shop/app/pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example.shop</groupId>
<artifactId>shop-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>app</artifactId>
<dependencies>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>core</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>
// File: shop/app/src/main/java/shop/app/Main.java
package shop.app;
import shop.core.Prices;
public class Main {
public static void main(String[] args) { System.out.println("Total: " + Prices.format(2599)); }
}
$ cd shop && mvn -B -ntp package | grep -E 'Reactor Build Order|^\[INFO\] (shop-parent|core|app) |BUILD'
[INFO] Reactor Build Order:
[INFO] shop-parent [pom]
[INFO] core [jar]
[INFO] app [jar]
[INFO] shop-parent ........................................ SUCCESS [ 0.003 s]
[INFO] core ............................................... SUCCESS [ 1.553 s]
[INFO] app ................................................ SUCCESS [ 0.145 s]
[INFO] BUILD SUCCESS
$ java -cp shop/core/target/core-1.0.0.jar:shop/app/target/app-1.0.0.jar shop.app.Main
Total: $25.99
app depends on core, so the reactor builds core first even though the parent lists app
first: Maven sorts modules by their dependencies, not by declaration order. Child POMs inherit the group, version and compiler release from the parent.
Practice
- Package without running tests, and confirm from the output that Surefire skipped them.
Solution
// File: src/test/java/com/example/AppTest.java
package com.example;
import org.junit.jupiter.api.Test;
class AppTest {
@Test void alwaysFails() { throw new AssertionError("would fail the build"); }
}
$ mvn -B -ntp package -DskipTests | grep -E 'Tests are skipped|BUILD'
[INFO] Tests are skipped.
[INFO] BUILD SUCCESS
-DskipTests still compiles the tests but doesn’t run them, so the failing test can’t stop the
build; -Dmaven.test.skip=true skips compiling them too. Both hide real failures, so use them
sparingly.
- Add Apache Commons Lang (
org.apache.commons:commons-lang3:3.17.0) and useStringUtils.capitalizeinAppon each tag. Rebuild the fat jar and run it.
Solution
<!-- File: pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.17.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
// File: src/main/java/com/example/App.java
package com.example;
import com.google.common.base.Splitter;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
public class App {
static List<String> tags(String csv) {
return Splitter.on(',').trimResults().omitEmptyStrings().splitToList(csv)
.stream().map(StringUtils::capitalize).toList();
}
public static void main(String[] args) {
System.out.println(tags(args.length > 0 ? args[0] : "java, maven,, build "));
}
}
$ rm -rf src/test
$ mvn -B -ntp -q package
$ java -jar target/my-app-1.0.0.jar "java, maven,, build "
[Java, Maven, Build]
The test was removed along with the JUnit dependency, since the old expectation no longer holds. The new library is bundled into the fat jar automatically — no change to the Shade configuration.
- Show the effective value of
maven.compiler.releaseand the jar plugin version Maven chose, usinghelp:evaluateandhelp:effective-pom.
Solution
$ mvn -B -ntp -q help:evaluate -Dexpression=maven.compiler.release -DforceStdout; echo
21
$ mvn -B -ntp help:effective-pom | grep -A1 '<artifactId>maven-jar-plugin</artifactId>' | head -2
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version>
The effective POM is your POM merged with Maven’s super-POM, which is where default plugin
versions like maven-jar-plugin 3.4.1 come from. Pin plugin versions in <build> so a Maven
upgrade can’t change your build.