Apache Maven is twenty years old and still builds a large share of the JVM world. Most developers use it daily without a clear model of what it does: they copy a POM, type mvn clean install, and reach for Stack Overflow when a NoSuchMethodError shows up in production. That works until it doesn't. Maven is small once you see its three moving parts: a fixed lifecycle of phases, plugins whose goals are bound to those phases, and a dependency resolver with one simple and occasionally surprising rule for picking versions.
This article builds that model from first principles, walks through a multi-project build and a real version-conflict diagnosis, and ends with the guardrails, CI settings and Maven 4 status you need to run Maven well.
Lifecycles, phases and plugin goals
Maven has three built-in lifecycles: clean, default and site. Each is an ordered list of phases. The default lifecycle's main phases are validate, compile, test, package, verify, install and deploy, with finer phases such as process-resources, generate-sources and integration-test in between. Asking for a phase runs every earlier phase in that lifecycle, so mvn package compiles and tests first.
A phase does nothing by itself. Work happens in plugin goals, written plugin:goal, that are bound to phases. The packaging type supplies default bindings: for jar packaging, compiler:compile runs at compile, surefire:test at test, jar:jar at package and install:install at install. You add bindings with an <execution> block, and you can run a goal directly without a lifecycle, as in mvn dependency:tree.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${failsafe.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal> <!-- default phase: integration-test -->
<goal>verify</goal> <!-- default phase: verify; fails the build -->
</goals>
</execution>
</executions>
</plugin>This split explains a common CI mistake. Failsafe runs integration tests at integration-test and only fails the build at verify, so that post-integration-test can tear down containers first. Running mvn integration-test skips that verdict and goes green on failures. mvn verify is the right CI command. install also copies artifacts into ~/.m2/repository, which CI rarely needs and which pollutes cached repositories with locally built snapshots.
The POM: coordinates, inheritance and aggregation
Every project is identified by coordinates: groupId, artifactId and version (GAV), plus a packaging and optional classifier. The POM you write is merged with its parents and the built-in super POM into the effective POM that Maven actually executes. mvn help:effective-pom prints it, and it is the first thing to read when a setting seems to be ignored.
Two relationships are easy to confuse. Inheritance is declared by a child's <parent> and copies configuration down: properties, dependencyManagement, pluginManagement, repositories. Aggregation is declared by a parent's <modules> list and tells the reactor which projects to build together. A typical repository does both from one root POM, but they are independent: a corporate parent POM inherited by hundreds of repositories aggregates nothing, and an aggregator need not be anyone's parent.
The *Management sections are where versions belong. A <dependencyManagement> entry does not add a dependency; it says "if this artifact appears anywhere in the graph, directly or transitively, use this version and scope." Child POMs then declare dependencies without versions. pluginManagement does the same for plugins. Always pin plugin versions there, because an unpinned plugin resolves to whatever the Maven distribution defaults to and changes when someone upgrades Maven.
Versions of the subprojects themselves are the other recurring pain. Maven 3.5 and later accept the CI-friendly properties ${revision}, ${sha1} and ${changelist} in <version>, so a pipeline can run mvn -Drevision=1.4.0 verify without rewriting a dozen POMs. On Maven 3 the deployed POMs still contain the unresolved placeholder unless you add the flatten-maven-plugin; consumers then fail to resolve your parent. Maven 4 resolves these variables natively when it writes the consumer POM, which is one of its more practical improvements.
Dependency resolution: nearest wins
Maven resolves a single version for each groupId:artifactId across the whole graph. The rule is nearest definition wins: the version closest to your project in the dependency tree is chosen, and if two are at the same depth, the one declared first in the POM wins. A version you declare directly, at depth 1, therefore beats anything transitive. A managed version in dependencyManagement overrides mediation entirely.
Note what the rule does not consider: which version is newer. If your project depends on library A, which depends on jackson-databind:2.17.x at depth 3, and on library B, which declares jackson-databind:2.12.x at depth 2, Maven picks 2.12 silently. Gradle by default picks the highest version instead (see the Gradle article for its rules), which is why the same dependencies can produce different classpaths in the two tools.
| Scope | Compile classpath | Test classpath | Runtime / packaged | Transitive |
|---|---|---|---|---|
compile (default) | yes | yes | yes | yes, as compile |
provided | yes | yes | no; container supplies it | no |
runtime | no | yes | yes | yes, as runtime |
test | no | yes | no | no |
import | only in dependencyManagement for pom type: pulls in a BOM |
BOMs (bills of materials) are POMs containing only a dependencyManagement section. Importing one with <type>pom</type><scope>import</scope> applies a whole family of aligned versions at once, which is how Spring Boot, Jackson, Netty and the AWS SDK keep their dozens of modules consistent. Imports are processed in declaration order and the first BOM to manage an artifact wins, so put the BOM you trust most first.
Worked example: diagnosing a NoSuchMethodError
A service builds and passes unit tests, then fails in staging with java.lang.NoSuchMethodError: 'com.fasterxml.jackson.databind.cfg.DatatypeFeatures ...'. That error almost always means the code was compiled against one version of a class and runs against another. Start by asking Maven where the artifact came from:
$ mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
[INFO] com.example:orders-service:jar:1.4.0-SNAPSHOT
[INFO] \- com.vendor:legacy-client:jar:3.2.1:compile
[INFO] \- com.fasterxml.jackson.core:jackson-databind:jar:2.12.7:compileThe vendor client declares 2.12 at depth 2; Spring Boot's starters bring 2.17 at depth 3 and lose. The fix is to make the decision explicit rather than rely on tree shape: import the Jackson BOM (or the Spring Boot BOM, which manages Jackson) in the root POM's dependencyManagement, ahead of other imports.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>Then make the class of bug impossible to reintroduce. The maven-enforcer-plugin rule dependencyConvergence fails the build when two paths request different versions of the same artifact, and requireUpperBoundDeps fails when the resolved version is lower than one some dependency asked for, which is exactly the 2.12-beats-2.17 case. Convergence is strict and noisy on large graphs; upper-bound checking catches the dangerous direction with fewer false alarms. Finally, check the old client still works on 2.17: forcing a version up can break the dependency that asked for the old one, and only tests prove otherwise.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${enforcer.version}</version>
<executions>
<execution>
<id>enforce</id>
<goals><goal>enforce</goal></goals> <!-- default phase: validate -->
<configuration>
<rules>
<requireMavenVersion><version>[3.9,)</version></requireMavenVersion>
<requireJavaVersion><version>[17,)</version></requireJavaVersion>
<requireUpperBoundDeps/>
<banDuplicatePomDependencyVersions/>
</rules>
</configuration>
</execution>
</executions>
</plugin>Because the enforcer runs at validate, a violation fails the build in seconds, before any compilation. When a rule fires on a transitive conflict you cannot fix upstream, resolve it with a managed version or an <exclusion> on the offending dependency, and leave a comment naming the artifact and the reason. Exclusions without comments become archaeology within a year, and nobody dares remove them.
The reactor and multi-project builds
In a multi-project build the reactor reads every listed module, sorts them so dependencies build before dependants, and runs the requested phases in each. When service depends on core, the reactor hands service the freshly built core classes, not a stale copy from ~/.m2. That is why a root mvn verify works without install. The flags that make big reactors usable:
| Flag | Meaning | Typical use |
|---|---|---|
-pl service | build only the listed projects | work on one module |
-am | also make: add what the listed projects depend on | -pl service -am for a correct partial build |
-amd | also make dependants | check who you broke after changing core |
-rf :core | resume from a project | restart after a failure |
-T 1C | parallel build, one thread per CPU core | large reactors on CI |
-o / -U | offline / force snapshot update | flights; stale snapshot debugging |
Parallel builds are safe only if every plugin is thread-safe; Maven prints a warning naming plugins that are not marked as such. Treat that warning as a bug in your build, not noise.
Operating Maven: wrapper, mirrors, CI and Maven 4
Three files make builds the same on every machine. The Maven Wrapper (mvn wrapper:wrapper) commits mvnw, mvnw.cmd and .mvn/wrapper/maven-wrapper.properties, so everyone runs the pinned Maven version. .mvn/maven.config holds default command-line flags and .mvn/jvm.config holds JVM options for Maven itself. For reproducible artifacts, set project.build.outputTimestamp to a fixed date; the standard archiving plugins use it to normalise timestamps and entry order in JARs.
Repositories need the same care. Use a settings.xml <mirror> with <mirrorOf>*</mirrorOf> that points at your internal proxy, so builds keep working when a public repository is slow and you control what enters. Since Maven 3.8.1, plain-HTTP repositories are blocked by default; fix the URL rather than unblocking it. In CI, cache ~/.m2/repository keyed on a hash of the POMs, never run install into that cache, and use -B (batch mode) with --no-transfer-progress for readable logs. Packaging the result into images is covered in Java containerization, and test configuration in JUnit 5.
Maven 4. When checked for this article, Maven 4.0.0 was still at release-candidate stage. It needs Java 17 to run Maven itself (you can still compile for older targets), introduces model version 4.1.0 for the build POM while publishing a stripped 4.0.0 consumer POM to repositories, renames <modules> to <subprojects> (the old element still works), adds before: and after: phases, and ships mvnup to help migrate. Try it on a branch with mvnup; keep production on the 3.9 line until 4.0 is generally available and your plugins are verified.
Failure modes and trade-offs
- Mediation surprises.
NoSuchMethodError,NoClassDefFoundErrororAbstractMethodErrorat run time. Diagnose withdependency:tree -Dincludes=...; fix with managed versions; prevent withrequireUpperBoundDeps. - Duplicate classes under different coordinates. Relocated artifacts (for example
javax.*versusjakarta.*packaging) both land on the classpath because Maven only deduplicates identicalgroupId:artifactId. Exclude the old one. The module system turns split packages into hard errors, which helps here. - SNAPSHOT drift. A build passes today and fails tomorrow because a snapshot dependency changed. Release builds must not depend on snapshots; the enforcer rule
requireReleaseDepschecks this. - Unpinned plugins. Upgrading Maven silently changes compiler or surefire behaviour. Pin every plugin in
pluginManagement. - Profile sprawl. Profiles activated by OS or environment make the effective POM differ between laptops and CI. Keep them few, and inspect
help:effective-pom -P...when in doubt.
The trade-off against Gradle is real but narrower than the debate suggests. Maven's fixed lifecycle and declarative XML make every project look alike, which helps large organisations and tool vendors; the cost is that custom logic needs a plugin, and incremental and cached builds are weaker out of the box. Frameworks such as Spring Boot support both equally.
What to do next
- Run
mvn help:effective-pomon your main project and read where each plugin version comes from. - Pin every plugin version in a root
pluginManagementand add the Maven Wrapper. - Move all dependency versions into
dependencyManagement, importing framework BOMs first. - Add the enforcer with
requireUpperBoundDeps,requireMavenVersionandrequireReleaseDepsfor release builds. - Change CI to
mvn -B verify, cache~/.m2/repositoryby POM hash, and route downloads through a mirror. - Set
project.build.outputTimestampand build twice to confirm identical JAR checksums. - Try
mvnupand Maven 4 on a branch, and note which plugins need upgrades.