RizTech Academy logo
RizTech Academy
Build Tools and TestingLesson 1 of 430 min

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:

  1. Fetches dependencies, and their dependencies, and their dependencies.
  2. Compiles with the right source level and classpath.
  3. Runs tests and fails the build when they fail.
  4. Packages a jar.
  5. Gives everyone the same build. mvn test behaves 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 NoSuchMethodError at runtime, and the fix is dependency:tree plus 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

  1. Create a project. A pom.xml with the coordinates, Java 21 and UTF-8, plus the standard directory layout. Put one class with a main in it and run mvn compile.

  2. Add a dependency and use it. Add Jackson, serialise a record, run mvn test. Then delete the <version> and read the error.

  3. Build both jars. mvn package without the shade plugin, run it with java -jar, and read the failure. Add the shade plugin, package again, and compare the two file sizes.

  4. Break a phase. Write a test that fails, run mvn package, and confirm no jar is produced. Then run mvn package -DskipTests and confirm one is — and write one sentence on when that flag is acceptable.

  5. Read the tree. Run mvn dependency:tree and account for every line. Which are direct, which transitive, and which are test scope?

  6. Harder — add the wrapper and a second module. Run mvn wrapper:wrapper and commit the result. Then split the project into two Maven modules — a core with the domain classes and a cli depending 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