Skip to main content
Java · lesson 39 of 66
Open the Java page →
Java intermediate Lesson 39 of 66

Maven — 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.

CommandDoes
mvn compilecompile src/main/java into target/classes
mvn testcompile, then run tests
mvn package… then build the jar in target/
mvn install… then copy it into ~/.m2/repository for other local projects
mvn cleandelete target/
mvn package -DskipTestspackage 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.

ScopeCompile mainRunTestsPackaged by Shade
compile (default)yesyesyesyes
providedyesno — the server supplies ityesno
runtimenoyesyesyes
testnonoyesno

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

  1. 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.

  1. Add Apache Commons Lang (org.apache.commons:commons-lang3:3.17.0) and use StringUtils.capitalize in App on 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.

  1. Show the effective value of maven.compiler.release and the jar plugin version Maven chose, using help:evaluate and help: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.

Frequently Asked Questions

What does Maven actually do?
Maven automates three things: dependency management (downloading JARs from Maven Central and managing their transitive deps), the build lifecycle (compile → test → package → deploy in a defined order), and project structure convention (src/main/java, src/test/java) so every Java project looks the same.
What is the difference between Maven and Gradle?
Both are build tools. Maven uses XML (pom.xml) and a rigid convention-over-configuration model. Gradle uses a Groovy/Kotlin DSL and is more flexible and faster (incremental builds, build caching). Maven is more common in enterprise Java; Gradle is the default for Android and growing in backend.
What is a SNAPSHOT version?
A SNAPSHOT version (e.g. 1.0-SNAPSHOT) means the artifact is still in development and may change. Maven checks remote repositories for newer SNAPSHOT builds, by default once a day. A release version (e.g. 1.0.0) is immutable — once published, it never changes.
Why does java -jar say 'no main manifest attribute'?
The jar Maven builds by default contains only your classes and no Main-Class entry in its manifest, so java -jar doesn't know what to run. Configure the main class (for example with the maven-shade-plugin's ManifestResourceTransformer or the maven-jar-plugin's manifest settings) and, if you have dependencies, bundle them into the jar too.