Maven: dependencies, lifecycle and the POM
Everything so far has been single files run from a terminal or an IDE. A real project has dependencies to fetch, a layout other tools expect, tests that run on every build, and an artefact to hand to somebody.
That is a build tool. In Java it is Maven or Gradle. This course uses Maven, because it is what most Indian enterprise projects and every Spring tutorial use, and because its XML is verbose but never surprising. Gradle is common in Android and in newer projects; once you understand what a build tool does, switching is a day's work.
What a build tool actually does
Five things, and you have been doing four of them by hand:
- Fetches dependencies, and their dependencies, and their dependencies.
- Compiles with the right source level and classpath.
- Runs tests and fails the build when they fail.
- Packages a jar.
- Gives everyone the same build.
mvn testbehaves identically on your laptop, a colleague's, and the CI server.
Point 5 is the one that matters most and is easiest to undervalue until the first "works on my machine".
The POM
One file, pom.xml, at the project root.
<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.riztech</groupId>
<artifactId>tiffin-tracker</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
groupId + artifactId + version is the coordinate, and it identifies
this project to the rest of the world exactly as it identifies Jackson to you.
groupId is conventionally a domain you control, reversed.
maven.compiler.release is the property to set, not source and target.
It is the --release flag from module 1, and it also stops you calling library
methods newer than your target — which source/target do not.
Set the source encoding too. Without it Maven uses the platform default and warns on every build.
The standard layout
Maven does not ask where your code is. It knows:
tiffin-tracker/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/ your code
│ │ └── resources/ files packaged into the jar
│ └── test/
│ ├── java/ your tests
│ └── resources/ test-only files
└── target/ everything Maven generates — never commit this
Convention over configuration. You can change these paths and you should not: every Java developer, IDE and CI system expects this layout, and matching it is free.
target/ goes in .gitignore. Committing build output is a classic first
mistake.
Dependencies and the repository
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
Maven downloads that from Maven Central into ~/.m2/repository and puts it on
the classpath. The first build of a project is slow; afterwards it is cached and
offline builds work.
Dependencies bring their own:
com.riztech:tiffin-tracker:jar:1.0.0
+- com.fasterxml.jackson.core:jackson-databind:jar:2.17.2:compile
| +- com.fasterxml.jackson.core:jackson-annotations:jar:2.17.2:compile
| \- com.fasterxml.jackson.core:jackson-core:jar:2.17.2:compile
+- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.17.2:compile
\- org.junit.jupiter:junit-jupiter:jar:5.10.3:test
+- org.junit.jupiter:junit-jupiter-api:jar:5.10.3:test
| +- org.junit.platform:junit-platform-commons:jar:1.10.3:test
+- org.junit.jupiter:junit-jupiter-params:jar:5.10.3:test
\- org.junit.jupiter:junit-jupiter-engine:jar:5.10.3:test
\- org.junit.platform:junit-platform-engine:jar:1.10.3:test
That is mvn dependency:tree, and it is the command for "where did that jar
come from". Two declared dependencies pulled in nine.
Scopes
| Scope | Available | Packaged | Use for |
|---|---|---|---|
compile |
Everywhere | Yes | The default |
test |
Tests only | No | JUnit, test helpers |
provided |
Compile and test | No | Something the runtime supplies |
runtime |
Runtime and test | Yes | JDBC drivers |
Put test libraries in test scope. Otherwise JUnit ships inside your
production jar, and worse, production code can accidentally import it.
The lifecycle
mvn <phase> runs that phase and every phase before it.
| Phase | Does |
|---|---|
validate |
Checks the project is sane |
compile |
src/main/java to target/classes |
test |
Compiles and runs src/test/java |
package |
Builds the jar |
verify |
Integration tests and checks |
install |
Copies the jar into ~/.m2 for other local projects |
deploy |
Publishes to a remote repository |
Plus clean, which is a separate lifecycle and deletes target/.
mvn test also compiles. mvn package also tests. That is why a failing test
stops a package, which is the point.
The commands you will actually type:
mvn clean test rebuild from scratch and run the tests
mvn package build the jar
mvn clean install install into the local repository
mvn dependency:tree where did that jar come from
mvn -o test offline, using only the cache
mvn -q test quiet; only warnings, errors and test output
mvn test -Dtest=MoneyTest one test class
mvn clean install is the reflex for "it is behaving strangely". Usually it is
stale output in target/.
Plugins
Phases do nothing by themselves; plugins bound to them do the work. compiler,
surefire (tests) and jar are bound by default. Pin their versions:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
Always pin plugin versions. Without one, Maven picks a default that varies between Maven versions, so your build is not reproducible and the failure appears on somebody else's machine.
Building a runnable jar
A plain mvn package jar contains only your classes. Run it and you get
NoClassDefFoundError for Jackson, because the dependencies are not inside.
The shade plugin builds a "fat jar" with everything:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<finalName>tiffin-tracker</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.riztech.tiffin.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
Building jar: target/tiffin-tracker-1.0.0.jar
Replacing target/tiffin-tracker.jar with target/tiffin-tracker-1.0.0-shaded.jar
BUILD SUCCESS
-rw-r--r-- 19234 target/tiffin-tracker-1.0.0.jar your classes only
-rw-r--r-- 2447994 target/tiffin-tracker.jar with Jackson inside
19 KB against 2.4 MB. Then java -jar target/tiffin-tracker.jar works anywhere
there is a JDK.
The Maven wrapper
mvn wrapper:wrapper
This adds mvnw, mvnw.cmd and a small config file to the project. Anyone
cloning it runs ./mvnw test and gets the exact Maven version the project
expects, downloaded automatically — no "install Maven first" step.
Commit the wrapper. It is the single easiest thing you can do for whoever clones your project next, including you in a year.
What Maven is not for
- It is not a scripting language. Logic in a POM — profiles switching behaviour, Ant tasks embedded in phases — becomes unmaintainable quickly. If your build needs real logic, that is an argument for Gradle.
- It will not fix a bad dependency graph. Two versions of the same library on
the classpath produces
NoSuchMethodErrorat runtime, and the fix isdependency:treeplus an exclusion, not a plugin. - It does not make your code correct. A green build with three tests is a green build with three tests.
Check your work
What three things identify a project? groupId, artifactId and version —
its coordinate.
Why maven.compiler.release rather than source and target? release
also prevents calling library methods newer than your target; the other two only
set the language level and class file version.
Where do your code and tests go? src/main/java and src/test/java. Do not
change it.
What should be in .gitignore? target/.
What does mvn package run before packaging? Everything up to and including
test, so a failing test stops the build.
Why put JUnit in test scope? So it is not packaged into the production jar
and cannot be imported by production code.
Why pin plugin versions? Without a version, Maven picks a default that varies between Maven versions, so the build is not reproducible.
Why does a plain jar fail with NoClassDefFoundError? It contains only your
classes. Dependencies are not inside it — use the shade plugin for a runnable
fat jar.
What does the Maven wrapper give you? Anyone cloning the project runs
./mvnw and gets the right Maven version automatically, with nothing to install.
Practice 3, the two jars. target/tiffin-tracker-1.0.0.jar is 19 KB and
throws NoClassDefFoundError for a Jackson class as soon as it needs one.
target/tiffin-tracker.jar from the shade plugin is 2.4 MB and runs. The size
difference is the dependency tree, made concrete.
Practice 5, the dependency tree. Two declared dependencies produced nine
entries. jackson-databind brings jackson-core and jackson-annotations;
junit-jupiter is an aggregate bringing -api, -params and -engine, which
between them bring the platform jars. Everything under junit-jupiter is marked
:test, which is scope doing its job — none of it is in your jar.
Practice
-
Create a project. A
pom.xmlwith the coordinates, Java 21 and UTF-8, plus the standard directory layout. Put one class with amainin it and runmvn compile. -
Add a dependency and use it. Add Jackson, serialise a record, run
mvn test. Then delete the<version>and read the error. -
Build both jars.
mvn packagewithout the shade plugin, run it withjava -jar, and read the failure. Add the shade plugin, package again, and compare the two file sizes. -
Break a phase. Write a test that fails, run
mvn package, and confirm no jar is produced. Then runmvn package -DskipTestsand confirm one is — and write one sentence on when that flag is acceptable. -
Read the tree. Run
mvn dependency:treeand account for every line. Which are direct, which transitive, and which aretestscope? -
Harder — add the wrapper and a second module. Run
mvn wrapper:wrapperand commit the result. Then split the project into two Maven modules — acorewith the domain classes and aclidepending on it — with a parent POM. Build both with one command. This is how every project larger than a weekend is laid out, and doing it once removes the mystery.
Next: JUnit 5, and tests that tell you what broke rather than that something did.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship