Maven for Java Projects: From pom.xml to Build Lifecycle
Executive Summary
Maven for Java projects starts with a pom.xml. It declares the project’s coordinates: groupId, artifactId, and version, the triple by which every artifact is known. It also lists dependencies by the same coordinates and a set of build settings. Maven resolves dependencies transitively from remote repositories, Maven Central by default. It caches them in a local repository under your home directory. As a result, it guarantees that two machines building the same pom get the same classpath.
The build is a fixed lifecycle of phases: validate, compile, test, package, verify, install, deploy. Each phase runs the previous ones, so mvn package compiles, tests, and jars in one command. Also, every phase does its work through plugins, such as the compiler plugin, surefire for tests, and the jar plugin. The pom can configure all of them. Dependency scopes control where each library appears. Compile is for the classpath, test is for tests only, and provided is for the JDK or container to supply. A few habits separate clean builds from cursed ones. Use fixed versions, never LATEST or SNAPSHOT in released code, and declare scopes honestly. Also, run dependency:tree for conflicts, and use the lifecycle as designed instead of custom scripts fighting it.
The Problem Maven Solves
Compile a one-file program by hand and the setup article’s javac is perfect. Add a second file and you manage the order. Add a library and you manage the classpath, then a test framework and a jar for delivery. Soon the commands become a script, and the script becomes folklore. Eventually, the next developer inherits weather instead of a build. Maven replaces the folklore with two contracts:
// before Maven, the build knowledge lives nowhere durable:
javac -cp "lib/jackson-core-2.15.0.jar;lib/jackson-annotations-2.15.0.jar;..." \
-d out $(find src -name "*.java")
jar --create --file app.jar -C out .
// after Maven, the build knowledge lives in pom.xml, and:
mvn package // compiles, runs tests, builds the jar. Same on every machine.
Convention over configuration is the second half of the trick. Maven assumes src/main/java for sources, src/test/java for tests, and target for output. Beyond that, it asks almost nothing from you. The modules article’s jar packaging, the manifest entries, and the module path concerns all become plugin configuration. That configuration lives in one file instead of shell commands in a README nobody updates.
pom.xml: Coordinates, Dependencies, and One Java 21 Decision
A complete, honest pom for this course’s projects, annotated:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- identity: how every artifact, yours included, is addressed -->
<groupId>in.imraan.library</groupId> <!-- org/site: reversed domain -->
<artifactId>catalog</artifactId> <!-- the project's name -->
<version>1.0.0</version> <!-- fixed: never LATEST -->
<packaging>jar</packaging> <!-- jar, the default -->
<properties>
<maven.compiler.release>21</maven.compiler.release> <!-- Java 21, once -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version> <!-- compile scope: on both
classpaths -->
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope> <!-- tests only: never shipped -->
</dependency>
</dependencies>
</project>
Read the dependency logic carefully, because it is the tool’s core value. Maven takes those coordinates and resolves the full transitive graph from Maven Central. Then it downloads each jar once into ~/.m2/repository and composes the exact classpath from it. Add a dependency that needs ten others, jackson-databind needs core and annotations, and you declare one, not eleven. Declare a library two other libraries also use, and Maven picks one version by a nearest-wins rule. That rule is occasionally wrong and always inspectable:
mvn dependency:tree // the whole resolved graph: who pulls what, at what version
The Lifecycle: Phases That Run in Order
The build lifecycle is a fixed sequence, and the phase you name runs everything before it:
| Command | What actually happens | Use it when |
|---|---|---|
| mvn validate | Checks the project structure | Rarely typed directly |
| mvn compile | Compiles sources to target/classes | Quick sanity check |
| mvn test | Compile, then run tests via surefire | The JUnit article’s moment |
| mvn package | All above, then jar into target/ | The everyday command |
| mvn verify | All above, plus integration checks | When configured with failsafe |
| mvn install | All above, plus copy into the local ~/.m2 repo | Sharing artifacts between your projects |
| mvn clean package | Deletes target, then rebuilds from scratch | When a stale build is suspected |
The design insight is the prefix rule. There is no “build everything except tests” flag culture here. Instead, you name the destination phase, and the pipeline is implied. Also, clean is the honest default when results look impossible. After all, stale classes in target produce bugs that no source file contains.
Plugins: How Every Phase Actually Works
Phases do no work themselves. Instead, each delegates to plugins, and the default pom gets sensible ones for free. The compiler plugin reads maven.compiler.release, surefire runs JUnit tests, and the jar plugin builds target/catalog-1.0.0.jar. Configuration is additive, one block at a time:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Shade earns the example because it solves the classic deployment question. A plain jar does not contain its dependencies, so java -jar app.jar fails to find Jackson. Shade merges the dependency graph into one runnable jar with a Main-Class manifest. That is the artifact shape the link checker and every CLI tool in this course would ship as. Profiles, optional build blocks activated by -P or environment, cover the smaller variations, such as database URLs per environment. They are the honest answer to “same build, different settings” questions.
Maven for Java Projects in Practice: The First Ten Minutes
mvn archetype:generate \ // scaffold a project
-DgroupId=in.imraan.demo -DartifactId=demo \
-DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
cd demo
mvn clean package // compile, test, jar
java -cp target/demo-1.0-SNAPSHOT.jar in.imraan.demo.App // run the result
IntelliJ reads the pom directly, so the IDE and the command line agree on the classpath by construction. Later, the CI article runs exactly mvn clean package on a build server, the same command you ran locally. That is the reproducibility promise delivered end to end.
How Real Systems Do This
Maven and Gradle together own Java’s build landscape, and Maven remains the enterprise default. Enterprises have enormous dependency graphs and slow-moving corporate proxies. Their builds must be identical across thousands of developers and hundreds of CI jobs. The conventions you learned are load-bearing there. For example, dependencyManagement blocks pin versions for whole portfolios. Internal Nexus or Artifactory repositories proxy Central behind corporate firewalls, and CI cuts releases from exactly one pom.
The conflict story I tell comes from the logging stack. It is the most common Maven failure in the wild. A service threw at startup with a NoSuchMethodError deep inside SLF4J binding code. That is the signature of two incompatible versions of the same library on one classpath. Each came from a different branch of the dependency graph. Three of our libraries depended on different logging adapter versions, and Maven’s nearest-wins had picked a stale one. Meanwhile, nothing in the code was wrong.
One command settled it: mvn dependency:tree, which showed the two branches and their versions plainly. Then one dependencyManagement entry pinned the single correct version for the whole project. I keep repeating one lesson to teams. When the classpath misbehaves, do not debug code. Instead, print the tree, because the classpath is code you did not write but are running.
Decision Framework
- More than a handful of files, or any third-party dependency? A real build tool, starting here: the classpath is too important to live in a script.
- Enterprise environment, team conventions, deep IDE support everywhere? Maven, the safe default.
- Highly customized build logic, Kotlin-based configuration, or a team already fluent in it? The Gradle article, next, is the modern alternative.
- Single-file experiment? java Main.java still exists, and the build tool’s ceremony buys nothing at that size.
- Dependency conflict? dependency:tree first, then pin with dependencyManagement, never by adding the same library twice at different depths.
- Need a runnable jar? Configure shade or assembly at package, and confirm with java -jar, not by inspection.
- Environment-specific settings? Profiles, and keep the differences small: configuration belongs in the application, not in fifteen pom variants.
When NOT to Use This
- Do not fight the conventions. The src/main/java and target folders exist so that every tool, IDE, CI, and Maven plugin, agrees. In contrast, custom source layouts cost you compatibility at every step.
- Do not use LATEST or SNAPSHOT versions in released dependencies. Otherwise, builds stop being reproducible, and a dependency can change underneath you overnight.
- Do not commit jars or lib/ folders. The pom is the source of truth, and vendored binaries defeat resolution, scanning, and licensing review.
- Do not build -DskipTests as a habit. The flag exists for genuine emergencies. Otherwise, a habit of it turns the test phase into theater.
- Do not add plugins speculatively. After all, every plugin is a dependency of the build itself, and the default bindings cover most projects completely.
Common Mistakes
- Omitting the Java version. Without maven.compiler.release, the build compiles against whatever JDK Maven finds. As a result, the artifact’s target is a dice roll.
- Wrong scopes. A test-scoped library referenced from main code fails at compile. However, a compile-scoped test framework ships in your artifact. Either way, the pom lied.
- Depending on transitive dependencies directly. Your code compiles today. However, the day a direct dependency upgrades its internals, your build breaks with no change in your pom.
- Debugging classpath weirdness by reading code. Instead, print dependency:tree, because the effective classpath is a graph decision, not a folder listing.
- Trusting a stale target: when output contradicts source, mvn clean before anything else, and the mystery usually evaporates.
- Version ranges and SNAPSHOT releases: unreproducible builds, the exact problem Maven exists to prevent.
- Putting secrets in pom.xml profiles. The pom is committed, so values that must stay secret belong in environment variables, not the build file.
Key Takeaways
- Maven is a project descriptor, a dependency resolver, and a build lifecycle in one. The pom.xml declares, and Maven resolves and builds, identically on every machine.
- Coordinates are the universal addressing scheme, groupId, artifactId, version, and your artifacts join the graph under the same triple.
- Dependency resolution is transitive and nearest-wins: declare one library, inherit its graph, and inspect conflicts with dependency:tree.
- Scopes are honesty declarations: compile ships, test stays home, provided arrives from the container.
- The lifecycle is a fixed prefix sequence: mvn package means compile, test, package, in order. Also, phases do their work through plugins you can configure.
- maven.compiler.release pins Java 21 once, and the shade plugin produces the runnable jar CLI tools ship as.
- Fixed versions, honest scopes, and the conventional layout are the reproducibility contract: deviate knowingly or not at all.
FAQ
What is Maven in Java?
A build tool and dependency manager. The pom.xml declares the project’s coordinates, its dependencies, and its build settings. Then Maven resolves libraries from Maven Central. It compiles, tests, and packages the result with one command, identically on every machine.
What is pom.xml?
The Project Object Model, the XML file at a Maven project’s root. It holds the project’s groupId, artifactId, version, properties like the Java release, and the dependency list. It is the single source of truth for what the project is and what it needs.
What are the Maven build phases?
The default lifecycle runs validate, compile, test, package, verify, install, deploy. Each phase executes everything before it, so mvn package compiles, tests, and jars in one command. clean runs before them when you ask for a from-scratch build.
What is a SNAPSHOT version in Maven?
A version under development, like 1.0.0-SNAPSHOT. Maven treats it as mutable and may re-resolve it from the repository at any time. Released code must depend on fixed versions, because a SNAPSHOT dependency makes the build unreproducible.
Maven or Gradle for a new Java project?
Maven is the safe default: XML conventions, universal IDE and enterprise support, and an enormous ecosystem of plugins. Gradle offers a Kotlin DSL, faster incremental builds, and easier custom logic. Both resolve the same dependency graphs, and a team fluent in either ships well with it.
Conclusion
You now own the machinery that production Java rests on, the core of Maven for Java projects. Coordinates make artifacts addressable, and transitive resolution composes the classpath for you. Meanwhile, a lifecycle builds in honest phases, and plugins handle the last mile, like the runnable shaded jar. From here on, every project in this course carries a pom.xml, and the command is always mvn clean package.
The next article covers Gradle, the other build tool you will absolutely meet. It solves the same problems with a Kotlin DSL, a task graph instead of fixed phases, and the build cache. It also gives honest guidance on choosing between the two.
Declare the project once, and the builds repeat forever. That is the whole promise, kept.
Last updated on 26 September 2026.
