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
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
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.
For
Learners who can compile and run single files and now need to build real, multi-file projects with libraries and tests.
Level
Before you start
Packages and the compile-and-run cycle (articles 1 and 4). A JDK 21 or newer and Maven 3.9 or newer installed. This article was verified with Maven 3.9.12.
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 | Convention: everything in |
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 |
|
Same result everywhere | "Works on my machine" | One |
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
#greeter/├── pom.xml└── src/ ├── main/ │ ├── java/ production code │ └── resources/ production files: configuration, templates └── test/ ├── java/ test code └── resources/ test filesThe 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 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,artifactIdandversionuniquely identify an artifact (a library or application). Together they are writtencom.example:greeter:1.0.0-SNAPSHOT. Every library you use is identified the same way. SNAPSHOTin a version means "work in progress, may change". A release version such as1.0.0is fixed forever once published.packagingis the type of artifact:jar(the default), orwarorpomfor other cases.propertiesare named values you can reuse as${name}. Keeping versions in properties means you change a version in one place.maven.compiler.releasetells the compiler which Java version to target. Thereleaseoption (rather than the older separate source and target settings) also checks that you only use APIs that exist in that version.dependencieslists the libraries you use, covered next.build/pluginsconfigures 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:
// 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:
// 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):
// 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 |
|---|---|---|
| Needed everywhere, and passed on to projects that depend on yours | compile, run, test |
| Not needed to compile, needed to run | run, test |
| Only for compiling and running tests (JUnit, Mockito) | test only |
| Supplied by the JDK or the container at runtime (for example the Servlet API in a server) | compile and test, not packaged |
| Only used with | 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):
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:testYou 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 |
|---|---|
| Checks the project is correct and the information is available |
| Compiles the source code |
| Runs the unit tests against the compiled code |
| Packages the compiled code, for example into a jar |
| Runs checks on the results, such as integration tests and quality gates |
| Copies the package into your local repository so other local projects can use it |
| 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 |
|---|---|
| Deletes the |
| Compiles main code |
| Compiles and runs tests |
| Builds the jar (after running tests) |
| Everything up to verification |
| A clean, full build: the standard command for CI |
| Also copies the artifact into the local repository |
| Shows the dependency tree |
| 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):
[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 SUCCESSRead 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:
$ 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.LoggerFactoryThis 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:
$ 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 packageproduces a jar that runs with plainjava -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 |
|---|---|---|
| Wrong coordinates or version, or no network access to the repository | Check the exact |
| The JDK running Maven is older than the release you asked for | Run |
| The dependency is missing, or in | Add it, or change the scope |
| A dependency was on the compile classpath but not the run classpath | See the thin jar section above |
| The test class is not named or placed as expected (by default the surefire plugin looks for names such as | Check the name and that it is under |
A library behaves like a different version | A version conflict | Read |
A stale build gives odd results | Old classes in |
|
Habits that save time
#- Always build with
cleanwhen something seems odd. - Keep versions in properties, or in a BOM, not scattered through the file.
- Use the narrowest scope:
testfor test libraries,runtimefor implementations. - Do not skip tests to make a build pass. The flag
-DskipTestsexists, 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.xmldirectly), 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
compilescope. - Copying jar files into the project instead of declaring dependencies.
- Using a
SNAPSHOTor 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
installanddeploy:installcopies to your own machine,deploypublishes 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
<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
#- Next: Git for Java Projects covers version control, so everything you build is safe and shareable.
- Soon after: Testing with JUnit and Mockito builds on the JUnit setup shown here.
- When you reach Spring: Spring Boot Foundations shows starters and the Spring Boot parent POM, the BOM idea in practice.
Sources & further reading
- Technology
- Software Engineering
- #build tools
- #dependencies
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #Maven
- #Spring Boot
- #SQL
Connected lessons
Linked from 6 guides
Other pages in the library that point here.
Keep reading
More in Technology →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

