Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 23 of 41

Lesson 23 · Tools and Craft

Maven: What It Does and How to Use It

Learn what a build tool does and how Maven does it: the standard project layout, the pom.xml, dependencies and scopes, transitive dependencies, the build lifecycle, plugins, and the common failures, all checked against a real project.

On this page

Key takeaways

  • A build tool compiles your code, fetches libraries, runs tests and packages the result, the same way on every machine. Maven does this from a declarative pom.xml and a standard directory layout.
  • Dependencies are identified by groupId, artifactId and version. Maven downloads them, and the libraries they need (transitive dependencies), from a repository into a local cache.
  • The scope of a dependency says when it is on the classpath: compile (default), runtime, test or provided.
  • The lifecycle is a fixed sequence of phases (validate, compile, test, package, verify, install, deploy). Running a phase runs every phase before it, and plugins do the actual work.
  • A plain jar contains only your classes, not your dependencies, so running it can fail with NoClassDefFoundError. Frameworks such as Spring Boot solve this with an executable jar that bundles dependencies.

Up to now every program fitted in one file, and you compiled it with javac. Real applications have hundreds of files, depend on dozens of libraries, need tests to run before every release, and must build identically on your laptop, your colleague's laptop and a build server. Doing that by hand does not scale.

A build tool automates it. In the Java world the two main ones are Maven and Gradle. Maven is the most widely used in enterprise Java and is what the Spring documentation examples and this course use, so it is the one to learn first.

What you will learn

#
  • What problems a build tool solves.
  • The standard Maven project layout and the pom.xml.
  • How dependencies, scopes and transitive dependencies work.
  • The build lifecycle and what each command does.
  • Why a jar sometimes fails to run, and how to read common Maven errors.

What a build tool does

#

Job

Without a build tool

With Maven

Find the source files and compile them

A long javac command

Convention: everything in src/main/java

Get libraries

Download jars by hand, copy them, keep them updated

Declare coordinates, Maven downloads them

Get the libraries' own libraries

Discover them one failure at a time

Resolved automatically (transitive dependencies)

Run tests

A manual step that gets skipped

Part of the standard build

Package the result

Hand-built jar command

mvn package

Same result everywhere

"Works on my machine"

One pom.xml describes the build

Maven's central ideas are convention over configuration (if you follow the standard layout, you configure almost nothing) and a declarative model (you describe what the project is in the pom.xml, not the step-by-step commands).

Older style. Before Maven, Java projects often used Apache Ant, where you wrote every build step yourself, or kept libraries in a lib/ folder under version control. You may meet both in very old projects. Gradle, the other modern tool, uses a Groovy or Kotlin script rather than XML. The concepts in this article (dependencies, scopes, lifecycle) carry over to it.

The standard project layout

#
Code
greeter/├── pom.xml└── src/    ├── main/    │   ├── java/          production code    │   └── resources/     production files: configuration, templates    └── test/        ├── java/          test code        └── resources/     test files

The build writes its output to a target/ directory (compiled classes, test reports, the packaged jar). Never edit it, never commit it (article 24), and delete it safely with mvn clean.

Package names follow the directories: a class in the package com.example.greeter lives in src/main/java/com/example/greeter/. By convention the package name starts with the reverse of a domain you control, which keeps names globally unique.

The pom.xml

#

The POM (Project Object Model) is the single file that describes the project. Here is the complete one for a small, real project that was built and tested for this article:

XML
<?xml version="1.0" encoding="UTF-8"?><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>com.example</groupId>  <artifactId>greeter</artifactId>  <version>1.0.0-SNAPSHOT</version>  <packaging>jar</packaging>  <properties>    <maven.compiler.release>21</maven.compiler.release>    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>    <slf4j.version>2.0.18</slf4j.version>    <junit.version>6.1.3</junit.version>  </properties>  <dependencies>    <!-- compile scope (the default): needed to compile and to run -->    <dependency>      <groupId>org.slf4j</groupId>      <artifactId>slf4j-api</artifactId>      <version>${slf4j.version}</version>    </dependency>    <!-- runtime scope: not needed to compile, but needed when the program runs -->    <dependency>      <groupId>org.slf4j</groupId>      <artifactId>slf4j-simple</artifactId>      <version>${slf4j.version}</version>      <scope>runtime</scope>    </dependency>    <!-- test scope: only on the classpath when compiling and running tests -->    <dependency>      <groupId>org.junit.jupiter</groupId>      <artifactId>junit-jupiter</artifactId>      <version>${junit.version}</version>      <scope>test</scope>    </dependency>  </dependencies>  <build>    <plugins>      <plugin>        <groupId>org.apache.maven.plugins</groupId>        <artifactId>maven-surefire-plugin</artifactId>        <version>3.5.6</version>      </plugin>      <plugin>        <groupId>org.apache.maven.plugins</groupId>        <artifactId>maven-jar-plugin</artifactId>        <version>3.4.2</version>        <configuration>          <archive>            <manifest>              <mainClass>com.example.greeter.Main</mainClass>            </manifest>          </archive>        </configuration>      </plugin>    </plugins>  </build></project>

The parts, in order:

  • Coordinates. groupId, artifactId and version uniquely identify an artifact (a library or application). Together they are written com.example:greeter:1.0.0-SNAPSHOT. Every library you use is identified the same way.
  • SNAPSHOT in a version means "work in progress, may change". A release version such as 1.0.0 is fixed forever once published.
  • packaging is the type of artifact: jar (the default), or war or pom for other cases.
  • properties are named values you can reuse as ${name}. Keeping versions in properties means you change a version in one place. maven.compiler.release tells the compiler which Java version to target. The release option (rather than the older separate source and target settings) also checks that you only use APIs that exist in that version.
  • dependencies lists the libraries you use, covered next.
  • build/plugins configures the tools that do the work. Maven's default bindings supply compile, resources and other plugins with default versions (this build used compiler plugin 3.13.0, resources 3.3.1 and clean 3.2.0 on Maven 3.9.12), and pinning versions of plugins you configure makes builds reproducible.

The source files are ordinary Java. This is the class under test:

Java
// File: src/main/java/com/example/greeter/Greeter.javapackage com.example.greeter;import org.slf4j.Logger;import org.slf4j.LoggerFactory;public class Greeter {    private static final Logger log = LoggerFactory.getLogger(Greeter.class);    public String greet(String name) {        if (name == null || name.isBlank()) {            throw new IllegalArgumentException("name must not be blank");        }        log.info("Greeting {}", name);        return "Hello, " + name.strip() + "!";    }}

The Main class is the entry point:

Java
// File: src/main/java/com/example/greeter/Main.javapackage com.example.greeter;public class Main {    public static void main(String[] args) {        String name = args.length > 0 ? args[0] : "world";        System.out.println(new Greeter().greet(name));    }}

And the test, in src/test/java, using JUnit (the next articles go deeper):

Java
// File: src/test/java/com/example/greeter/GreeterTest.javapackage com.example.greeter;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import org.junit.jupiter.api.Test;class GreeterTest {    private final Greeter greeter = new Greeter();    @Test    void greetsByName() {        assertEquals("Hello, Ada!", greeter.greet("Ada"));    }    @Test    void stripsSurroundingSpaces() {        assertEquals("Hello, Ada!", greeter.greet("  Ada "));    }    @Test    void rejectsBlankName() {        assertThrows(IllegalArgumentException.class, () -> greeter.greet(" "));    }}

Dependencies

#

You tell Maven what you need with coordinates, and it finds the jar. Libraries live in repositories. The default remote one is Maven Central. Maven keeps a local repository (a cache, by default in a .m2/repository folder in your user directory) so each version is downloaded once and reused by all your projects.

You can search for the coordinates of a library on Maven Central's website (search.maven.org), or copy them from the library's documentation.

Scopes

#

A scope says in which situations a dependency is on the classpath. Maven's documentation defines them like this:

Scope

Meaning

On the classpath for

compile (default)

Needed everywhere, and passed on to projects that depend on yours

compile, run, test

runtime

Not needed to compile, needed to run

run, test

test

Only for compiling and running tests (JUnit, Mockito)

test only

provided

Supplied by the JDK or the container at runtime (for example the Servlet API in a server)

compile and test, not packaged

import

Only used with pom dependencies inside dependencyManagement, to import version lists

not a classpath scope

In the project above, slf4j-api is compile because the code calls it, slf4j-simple is runtime because it is one implementation chosen at run time and the code does not call it directly, and JUnit is test so it never ends up in your production jar. Choosing the narrowest scope keeps production artifacts small and avoids shipping test libraries.

(A scope called system also exists, which points at a jar by file path. The documentation marks it as not recommended, so avoid it.)

Transitive dependencies and the dependency tree

#

A library usually depends on other libraries. Maven reads each library's own POM and brings those along, to any depth. These are transitive dependencies. To see everything that is on your classpath, run mvn dependency:tree. This is the real output for the project above (with Maven's own banner lines removed):

Code
com.example:greeter:jar:1.0.0-SNAPSHOT+- org.slf4j:slf4j-api:jar:2.0.18:compile+- org.slf4j:slf4j-simple:jar:2.0.18:runtime\- org.junit.jupiter:junit-jupiter:jar:6.1.3:test   +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test   |  +- org.opentest4j:opentest4j:jar:1.3.0:test   |  +- org.junit.platform:junit-platform-commons:jar:6.1.3:test   |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test   |  \- org.jspecify:jspecify:jar:1.0.0:test   +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test   \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test      \- org.junit.platform:junit-platform-engine:jar:6.1.3:test

You declared three dependencies, and Maven resolved eleven artifacts. The scope carried down to the transitive ones: everything under JUnit is test scope. The tree is the first place to look when a library behaves strangely, because you may be using a different version of something than you expected.

Version conflicts

#

If two of your dependencies need different versions of the same library, Maven picks one using nearest definition: the version whose path from your project is shortest wins. The Maven documentation illustrates this with a project A that reaches D 1.0 through E (A → E → D) and D 2.0 through B and C (A → B → C → D): D 1.0 wins because its path is shorter. You can override the choice by declaring the version you want directly in your own POM. To remove a transitive dependency you do not want, use an <exclusion> inside the dependency that brings it in.

dependencyManagement and BOMs

#

In a larger project, many libraries must be kept at versions that work together. dependencyManagement is a section that declares versions in one place, so dependencies elsewhere can omit the <version>. A BOM (bill of materials) is a POM that is just such a list of versions, imported with scope import. Spring Boot uses this idea: its starter parent POM manages the versions of many commonly used libraries, which is why the Spring Boot examples later in this course list dependencies without version numbers.

The build lifecycle

#

Maven has three built-in lifecycles: default (build and deploy), clean (remove old output) and site (documentation). The default lifecycle is a fixed list of phases:

Phase

What it does

validate

Checks the project is correct and the information is available

compile

Compiles the source code

test

Runs the unit tests against the compiled code

package

Packages the compiled code, for example into a jar

verify

Runs checks on the results, such as integration tests and quality gates

install

Copies the package into your local repository so other local projects can use it

deploy

Copies the package to a remote repository to share it

The rule to remember: running a phase runs every earlier phase first. mvn package runs validate, compile, test and package. That is why running the build also runs your tests, and why a failing test stops the build before a jar is produced.

The actual work is done by plugin goals bound to phases. A goal is a specific task, such as compiler:compile, and a phase with no goals bound to it does nothing. The documentation also notes that you can run a goal that is not bound to a phase directly on the command line, as with mvn dependency:tree.

The commands you will use daily

#

Command

What it does

mvn clean

Deletes the target/ directory

mvn compile

Compiles main code

mvn test

Compiles and runs tests

mvn package

Builds the jar (after running tests)

mvn verify

Everything up to verification

mvn clean verify

A clean, full build: the standard command for CI

mvn install

Also copies the artifact into the local repository

mvn dependency:tree

Shows the dependency tree

mvn -B -ntp ...

Batch mode and no download progress: tidier logs on a build server

Here is the real output of mvn clean verify for the project above (shortened by removing the long absolute directory paths and blank lines):

Code
[INFO] Building greeter 1.0.0-SNAPSHOT[INFO] --- clean:3.2.0:clean (default-clean) @ greeter ---[INFO] --- resources:3.3.1:resources (default-resources) @ greeter ---[INFO] --- compiler:3.13.0:compile (default-compile) @ greeter ---[INFO] Compiling 2 source files with javac [debug release 21] to target\classes[INFO] --- resources:3.3.1:testResources (default-testResources) @ greeter ---[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ greeter ---[INFO] Compiling 1 source file with javac [debug release 21] to target\test-classes[INFO] --- surefire:3.5.6:test (default-test) @ greeter ---[INFO] Running com.example.greeter.GreeterTest[main] INFO com.example.greeter.Greeter - Greeting Ada[main] INFO com.example.greeter.Greeter - Greeting   Ada [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.167 s -- in com.example.greeter.GreeterTest[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0[INFO] --- jar:3.4.2:jar (default-jar) @ greeter ---[INFO] Building jar: ...\target\greeter-1.0.0-SNAPSHOT.jar[INFO] BUILD SUCCESS

Read it as a map of the lifecycle: each --- plugin:version:goal (id) @ project --- line is one goal running. Notice the order: resources, compile, test-compile, test, jar. The two Greeting lines are the logging from the Greeter class, written by the slf4j-simple runtime dependency, visible because it was on the test classpath. The compiler line shows release 21, from the property in the POM. (Times and paths on your machine will differ.)

Running the result: the thin jar problem

#

The build produced target/greeter-1.0.0-SNAPSHOT.jar. We told the jar plugin the main class, so let us run it:

Code
$ java -jar target/greeter-1.0.0-SNAPSHOT.jarException in thread "main" java.lang.NoClassDefFoundError: org/slf4j/LoggerFactory	at com.example.greeter.Greeter.<clinit>(Greeter.java:7)	at com.example.greeter.Main.main(Main.java:6)Caused by: java.lang.ClassNotFoundException: org.slf4j.LoggerFactory

This is a real failure, and an instructive one. Using what you learned in the last article: the first line names the problem, the first frame in our code is Greeter.<clinit> (the static initialiser, which runs the LoggerFactory.getLogger line), and the Caused by shows the class was not found. The cause: the jar contains only your own classes, not your dependencies. At run time the JVM needs the dependencies on the classpath too.

You can supply the classpath yourself. Maven can print it for you (mvn dependency:build-classpath), and then:

Code
$ java -cp "target/classes;<the dependency jars>" com.example.greeter.Main Ada[main] INFO com.example.greeter.Greeter - Greeting AdaHello, Ada!

(On Windows the classpath entries are separated by ;, on Linux and macOS by :. The <the dependency jars> stands for the output of the command above, which is long.)

For real applications there are two common solutions:

  • A fat jar (or uber jar), which bundles your classes and all dependencies into one file. Maven plugins such as the Shade plugin can build one.
  • A framework-built executable jar. This is what Spring Boot does with its Maven plugin: mvn package produces a jar that runs with plain java -jar. Later articles use it.

This matters for interviews too: "why does java -jar say NoClassDefFoundError when it compiled fine?" is a classic question, and the answer is the classpath at compile time versus run time.

Common problems

#

Symptom

Likely cause

What to do

Could not resolve dependencies

Wrong coordinates or version, or no network access to the repository

Check the exact groupId, artifactId and version, then the network and any proxy settings

invalid target release: 21 or release version 21 not supported

The JDK running Maven is older than the release you asked for

Run mvn -v to see which JDK Maven uses, and set JAVA_HOME

package org.x does not exist

The dependency is missing, or in test scope while used in main code

Add it, or change the scope

NoClassDefFoundError at run time

A dependency was on the compile classpath but not the run classpath

See the thin jar section above

No tests were executed or tests not found

The test class is not named or placed as expected (by default the surefire plugin looks for names such as *Test)

Check the name and that it is under src/test/java

A library behaves like a different version

A version conflict

Read mvn dependency:tree, declare the version you want

A stale build gives odd results

Old classes in target/

mvn clean verify

Habits that save time

#
  • Always build with clean when something seems odd.
  • Keep versions in properties, or in a BOM, not scattered through the file.
  • Use the narrowest scope: test for test libraries, runtime for implementations.
  • Do not skip tests to make a build pass. The flag -DskipTests exists, but if you need it regularly the real fix is the failing test.
  • Add a dependency only when you need it. Each one brings transitive dependencies, upgrade work and security exposure. Check what it pulls in with dependency:tree.
  • Prefer the Maven Wrapper (mvnw) in team projects: a small script, committed to the repository, that downloads the Maven version the project expects so everyone builds the same way. Spring Initializr projects include it.
  • Use an IDE's Maven integration (IntelliJ IDEA and VS Code both import pom.xml directly), but know the command line, because CI servers use it.

Common mistakes

#
  • Treating a Maven error as noise. The first [ERROR] line usually states the cause.
  • Editing files in target/, or committing it.
  • Putting every dependency in compile scope.
  • Copying jar files into the project instead of declaring dependencies.
  • Using a SNAPSHOT or an unpinned version in a release, so the build changes without warning.
  • Mixing two versions of the same library and not reading the dependency tree.
  • Confusing install and deploy: install copies to your own machine, deploy publishes to a remote repository.

Exercises

#

Exercise 1

Write the coordinates

#

You want to use a library whose coordinates are group org.example, artifact calc, version 3.2.1, only in tests. Write the dependency block.

Exercise 2: Choose the scope

#

Choose compile, runtime, test or provided for: (a) JUnit, (b) a PostgreSQL JDBC driver that your code never imports directly but needs when it runs, (c) a library whose classes your code imports and calls, (d) the Servlet API in an application that is deployed to a server that already supplies it.

Exercise 3: Predict the phases

#

Which phases run for mvn package? Would the tests run? What if a test fails?

Exercise 4: Diagnose

#

mvn compile succeeds, but java -jar app.jar fails with NoClassDefFoundError: com/google/gson/Gson. Explain and name two ways to fix it.

Exercise 5: Read the tree

#

In a dependency tree you find lib-x:1.0 under your dependency A, but the tree you expected has lib-x:2.0 which you need. How does Maven choose, and how do you get version 2.0?

Show solutionHide solution
XML
<dependency>  <groupId>org.example</groupId>  <artifactId>calc</artifactId>  <version>3.2.1</version>  <scope>test</scope></dependency>

Exercise 2

Show solutionHide solution

(a) test. (b) runtime: it is needed when the program runs, not to compile, because the code uses the standard JDBC interfaces. (c) compile. (d) provided: the server supplies it, so it must not be packaged.

Exercise 3

Show solutionHide solution

validate, compile, test and package, because running a phase runs all earlier phases. The tests do run (in the test phase). If a test fails, the build stops there and no jar is produced.

Exercise 4

Show solutionHide solution

The code compiled because the Gson jar was on the compile classpath, but the jar produced by mvn package contains only the project's own classes, so at run time the JVM cannot find Gson. Fixes: build a fat jar that bundles the dependencies (for example with the Shade plugin, or with Spring Boot's Maven plugin for a Spring Boot application), or supply the classpath explicitly at run time with java -cp including the dependency jars.

Exercise 5

Show solutionHide solution

Maven resolves conflicts by nearest definition: the version closest to your project in the dependency tree wins. If it picked 1.0 and you need 2.0, declare lib-x version 2.0 directly in your own POM (a direct declaration is the nearest), or manage it in dependencyManagement. Then check mvn dependency:tree again, and run the tests, because the library that asked for 1.0 may not be compatible with 2.0.

Where to go next

#

Sources & further reading

  1. maven.apache.org
  2. maven.apache.org
  3. maven.apache.org
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java