Junior 14 min · March 15, 2026

Maven — Guava 32 Transitive Conflict

NoSuchMethodError on Guava 32.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Maven uses GAV coordinates (groupId:artifactId:version) to identify every dependency
  • Transitive dependencies are automatically resolved — nearest-wins determines the version when conflicts exist
  • DependencyManagement sets default versions for child modules without adding dependencies (BOMs follow the same pattern)
  • Scopes (compile, test, provided, runtime) control classpath inclusion and packaging
  • Production failure: a transitive dependency with a different version causes NoSuchMethodError at runtime, often only in prod due to differing classpaths
  • Biggest mistake: assuming nearest-wins picks the 'best' version — it picks the shortest path, which can be older and incompatible
Plain-English First

Maven is a build tool that manages your Java project's dependencies — the external libraries your code needs. Instead of manually downloading JARs and putting them in a lib folder, you declare what you need in a pom.xml file and Maven downloads and manages them for you. It's like ordering ingredients for a recipe: you write the shopping list, Maven goes to the store, checks for compatible versions, handles the sub-ingredients (transitive dependencies), and puts everything in your kitchen (local repository). The shopping list is your pom.xml, the store is Maven Central, and your kitchen is ~/.m2/repository.

Every non-trivial Java project depends on external libraries — Spring, Jackson, JUnit, Lombok, and dozens more. Before Maven, developers manually downloaded JARs, dropped them into a lib folder, and manually resolved version conflicts. Maven (and Gradle after it) replaced that chaos with a declarative, reproducible build system. This guide explains how Maven dependency management actually works — scopes, transitive dependencies, conflict resolution, BOMs, multi-module projects, Maven Wrapper, security scanning, and the Maven lifecycle — so you can debug build problems and structure projects correctly.

I've seen teams lose days to dependency hell: a transitive dependency pulled in an incompatible version of Guava, which caused a NoSuchMethodError at runtime that only manifested in production because the test environment had a different classpath. Understanding Maven's dependency resolution isn't academic — it's the difference between a clean build and a 3 AM debugging session.

What is Maven and What Problem Does It Solve?

Maven is a build automation and project management tool for Java projects. It solves three related problems: dependency management (declaring and downloading the libraries your project needs), build lifecycle (compiling, testing, packaging, and deploying in a standard sequence), and project structure (a conventional directory layout that any Maven-aware tool understands).

Maven uses a central repository — Maven Central (repo1.maven.org) — where virtually all open-source Java libraries publish their JARs and their own dependency declarations. When you add a dependency to your pom.xml, Maven downloads the JAR from Central and caches it in your local repository (~/.m2/repository). On the next build, it uses the cached version.

Every artifact in Maven is identified by a GAV coordinate: groupId (the organisation or package namespace), artifactId (the specific library), and version. You declare what you need; Maven resolves where to get it.

A quick note on the pom.xml structure: the <project> element is the root. Inside it, you have <modelVersion> (always 4.0.0), your project's GAV coordinates, <dependencies> for libraries, <dependencyManagement> for version governance, <properties> for centralising values, <build> for plugins and configuration, and <modules> for multi-module projects. Understanding this structure is the foundation for everything else in this guide.

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<project>
    <modelVersion>4.0.0</modelVersion>

    <groupId>io.thecodeforge</groupId>
    <artifactId>user-service</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>
Production Insight
Without a build tool, teams waste hours copying JARs and fighting different versions.
Maven enforces a standard directory layout and lifecycle — no more 'works on my machine' due to different build scripts.
Rule: if a new hire can't build your project in under 10 minutes, your build system is broken.
Key Takeaway
Maven centralises dependency declaration and download.
Standard pom.xml structure prevents custom build scripts.
GAV coordinates are the universal identifier — get them right or your build fails.

SNAPSHOT vs Release Versions — The Difference That Breaks Reproducibility

You'll notice the version in the pom.xml above is 1.0.0-SNAPSHOT. That -SNAPSHOT suffix is not a placeholder or a comment — it has specific, important behavior that every Maven developer must understand.

A SNAPSHOT version is a version under active development. When Maven encounters a SNAPSHOT dependency, it checks the remote repository for a newer version on every build (by default, once per day). This means the artifact can change between builds. A release version (no -SNAPSHOT suffix) is immutable — once published to a repository, it never changes. Maven caches it locally and never checks for updates.

The danger of SNAPSHOT dependencies: If your production build depends on a SNAPSHOT version of another team's library, your build is non-reproducible. Monday's build might pull version 2.3.1-SNAPSHOT from Tuesday at 10 AM, while Wednesday's build pulls 2.3.1-SNAPSHOT from Wednesday at 3 PM — same version string, different contents. This is how subtle, intermittent bugs creep in. I once debugged a flaky integration test for two days before discovering that a SNAPSHOT dependency had been updated between CI runs, changing the serialization format of a DTO.

When to use SNAPSHOT: During active development of your own modules. When your team is building module A and module B together, and module B depends on module A, both use SNAPSHOT during development. When you're ready to release, you strip the -SNAPSHOT, tag in Git, deploy to the release repository, and bump to the next SNAPSHOT.

When to NEVER use SNAPSHOT: In production deployments. In any released artifact. In any dependency declared in a library that others consume. If you publish a library to Maven Central with a SNAPSHOT dependency, consumers will get unpredictable builds.

The release process: Maven has a release plugin that automates this. mvn release:prepare strips the -SNAPSHOT, commits, tags in Git, bumps to the next version, and adds -SNAPSHOT to the new version. mvn release:perform builds and deploys the tagged version. This is the standard workflow for releasing Maven artifacts.

Update policies: By default, Maven checks for SNAPSHOT updates once per day. You can change this in settings.xml with <updatePolicy>always</updatePolicy> (check every build), <updatePolicy>daily</updatePolicy> (default), <updatePolicy>interval:X</updatePolicy> (every X minutes), or <updatePolicy>never</updatePolicy> (only use cached). Use -U on the command line to force an update check for one build. Use -o (offline) to skip all remote checks.

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- A SNAPSHOT version — under active development, changes frequently -->
<version>1.0.0-SNAPSHOT</version>

<!-- A release version — immutable once published -->
<version>1.0.0</version>

<!-- Using a SNAPSHOT dependency (only during development!) -->
<dependency>
    <groupId>io.thecodeforge</groupId>
    <artifactId>common-lib</artifactId>
    <version>2.3.1-SNAPSHOT</version>
</dependency>

<!-- Snapshot repository configuration in settings.xml -->
<repositories>
    <repository>
        <id>snapshots</id>
        <url>https://nexus.company.com/repository/maven-snapshots/</url>
        <snapshots>
            <enabled>true</enabled>
            <updatePolicy>daily</updatePolicy>
        </snapshots>
        <releases>
            <enabled>false</enabled>
        </releases>
    </repository>
</repositories>
Forge Warning: SNAPSHOT Dependencies Break Build Reproducibility
If your CI build passes on Monday and fails on Tuesday with no code changes, check your SNAPSHOT dependencies. Someone on another team may have pushed a breaking change to a shared SNAPSHOT artifact. The fix: either pin to release versions or use a repository manager that proxies Maven Central and blocks internal groupIds from resolving to public ones (to prevent dependency confusion). There is no excuse not to use the Maven Wrapper in 2026.
Production Insight
A SNAPSHOT dependency in a release artifact means consumers get unpredictable builds.
In a shared development environment, a SNAPSHOT update can break CI without code changes in your repo.
Rule: never ship a release artifact with SNAPSHOT dependencies — enforce this with maven-enforcer-plugin.
Key Takeaway
SNAPSHOT versions change on each build — great for dev, deadly for releases.
Always use release versions for published artifacts.
The maven-release-plugin automates the snapshot-to-release transition.

The Maven Wrapper — Guaranteeing Consistent Builds

Every developer on a team might have a different version of Maven installed. Compiling with Maven 3.8.6 might work fine, but running with Maven 3.9.6 on CI could break your build due to API changes or plugin incompatibilities. This is a classic 'works on my machine' problem.

The Maven Wrapper solves this. It ships your project with a script (mvnw for Unix-like, mvnw.cmd for Windows) that downloads and runs a specific Maven version declared in .mvn/wrapper/maven-wrapper.properties. This ensures every developer, and every CI server, uses the exact same Maven version for your project, guaranteeing consistent builds.

How to add it: Run mvn wrapper:wrapper in your project root. This generates the scripts and the wrapper JAR. You must commit the scripts and the .mvn directory to Git.

Using it: From then on, always use ./mvnw (or mvnw.cmd on Windows) instead of just mvn. This ensures you're always using the wrapper's pinned Maven version.

Configuration: The .mvn/maven.config file lets you pass arguments to every Maven invocation (e.g., -T 1C for parallel builds, -Dstyle.color=always to force colors in CI). The .mvn/jvm.config file lets you pass JVM arguments to the Maven process itself (e.g., -Xmx4g for increased heap size).

The .mvn directory: The .mvn/ directory is also where you put Maven configuration files like .mvn/maven.config (command-line arguments applied to every build, e.g., -T 1C for parallel builds) and .mvn/jvm.config (JVM arguments for the Maven process itself, e.g., -Xmx2g). These are also committed to Git, ensuring consistent build behavior across all environments.

terminal_commands.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Generate the Maven Wrapper for your project
mvn wrapper:wrapper -Dmaven=3.9.6

# This creates:
# ./mvnw                  (Unix shell script)
# ./mvnw.cmd              (Windows batch script)
# .mvn/wrapper/maven-wrapper.jar
# .mvn/wrapper/maven-wrapper.properties

# Use mvnw instead of mvn from now on
./mvnw clean install
./mvnw test
./mvnw dependency:tree

# .mvn/maven.config — applies to every build
# Contents: -T 1C -Dstyle.color=always

# .mvn/jvm.config — JVM args for Maven itself
# Contents: -Xmx2g -XX:+UseG1GC
Forge Tip: Commit mvnw to Git, Always
The mvnw script, mvnw.cmd, and the entire .mvn/wrapper/ directory should be committed to your repository. This is not optional — it's the entire point. Any developer who clones the repo gets the correct Maven version automatically. Add .mvn/wrapper/maven-wrapper.jar to your .gitignore only if you prefer the wrapper to download itself on first use (slower first build, smaller repo).
Production Insight
Without a wrapper, a Maven version difference can break builds silently — Maven 3.9 changed plugin defaults.
CI and developer machines often have different Maven versions, causing 'works on CI' failures.
Rule: commit the wrapper and enforce it with a CI check that fails if 'mvn' is used instead of './mvnw'.
Key Takeaway
Maven Wrapper pins the Maven version for every build.
Commit mvnw, mvnw.cmd, and .mvn/ to version control.
Use .mvn/maven.config and .mvn/jvm.config for project-wide build defaults.

Transitive Dependencies — What Maven Resolves For You

When you add spring-boot-starter-web to your pom.xml, you are adding one entry. But spring-boot-starter-web itself depends on spring-webmvc, which depends on spring-context, which depends on spring-core, which depends on spring-jcl. Maven resolves this entire tree automatically.

These are transitive dependencies — libraries your dependency depends on. Maven downloads the full closure of dependencies needed at runtime. For a typical Spring Boot application, that means 50-100+ JARs managed automatically.

The risk: transitive dependencies can include versions that conflict with each other or with your own declarations. Maven uses nearest-wins to resolve conflicts: the dependency closest to the root of the tree (lowest depth) wins. If you declare a version explicitly in your own pom.xml, your version wins over any transitive version.

The real-world gotcha: nearest-wins doesn't mean 'best version wins.' It means 'shortest path wins.' If library A pulls in Guava 31.0 at depth 2, and library B pulls in Guava 32.0 at depth 3, you get Guava 31.0 — the older version. If Guava 32.0 has a method that library B calls, you get a NoSuchMethodError at runtime. This is the single most common source of cryptic runtime errors in Maven projects. The fix is to declare the version explicitly in your own pom.xml or in dependencyManagement, which always wins (depth 1).

Exclusions: Use <exclusions> to drop a transitive dependency you don't need. Common scenarios: switching from Tomcat to Jetty (exclude spring-boot-starter-tomcat, add spring-boot-starter-jetty), removing a logging bridge (exclude commons-logging when using SLF4J), or dropping a vulnerable transitive library.

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!-- Explicit version declaration always wins over transitive versions -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.16.0</version>
</dependency>

<!-- Excluding a transitive dependency -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

<!-- Excluding a specific transitive library to avoid conflicts -->
<dependency>
    <groupId>org.apache.hadoop</groupId>
    <artifactId>hadoop-common</artifactId>
    <version>3.3.6</version>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
        </exclusion>
    </exclusions>
</dependency>
Forge Tip: Run dependency:tree Before Adding Any Library
Before adding a new dependency, run mvn dependency:tree and check what it brings in. I've seen a single utility library pull in 40 transitive dependencies including an older version of Spring, a conflicting logging framework, and an XML parser with a known CVE. Know what you're importing before it's in your classpath.
Production Insight
A single dependency can bring in dozens of transitive jars — some with CVEs.
Nearest-wins chooses the shallowest version, not the newest; this causes silent NoSuchMethodError in prod.
Rule: after adding any dependency, run mvn dependency:tree and review the full tree. Use dependencyManagement to lock critical versions.
Key Takeaway
Transitive dependencies are automatically resolved and can cause version conflicts.
Nearest-wins selects the version closest to the root, not the highest version.
Always check the dependency tree before releasing — use dependencyManagement to enforce versions.

Dependency Scopes — compile, test, provided, runtime, system

Every dependency has a scope that controls when it is on the classpath and whether it is included in the final packaged artifact.

  • compile (default): The dependency is on the classpath at compile time, test time, and runtime. It is packaged in your JAR/WAR. This is the right scope for spring-boot-starter-web, jackson-databind, and most application libraries.
  • test: Only on the classpath during test compilation and execution. Not packaged. Use for JUnit, Mockito, AssertJ, H2 (in-memory test database). If a test-scoped dependency ends up in your production bundle, something is wrong — it bloats the artifact and may introduce classpath conflicts.
  • provided: On the classpath at compile time but not packaged — the runtime container provides it. Use for servlet-api in WAR deployments where Tomcat provides the servlet API. In modern Spring Boot fat-JAR deployments, you rarely need this because Spring Boot embeds the container.
  • runtime: Not needed at compile time but needed at execution. The classic example is a JDBC driver — your code compiles against java.sql.Driver (the interface), but the MySQL driver JAR is only needed at runtime. Another example: Jackson annotations are compile-scoped, but the Jackson databind runtime is runtime-scoped.
  • system: Similar to provided but points to a JAR on the local filesystem via <systemPath>. This is deprecated and should never be used in new code. It's non-portable — the path is machine-specific. If you encounter it in legacy code, migrate by running mvn install:install-file to install the JAR into your local repository, then use a normal compile scope. Better yet, deploy it to your company's Nexus/Artifactory.

Scope inheritance rules: A compile-scoped dependency propagates transitively as compile. A test-scoped dependency never propagates transitively. A provided-scoped dependency propagates as provided. A runtime-scoped dependency propagates as runtime.

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>jakarta.servlet</groupId>
        <artifactId>jakarta.servlet-api</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>
Production Insight
Using 'test' scope for production-needed libraries results in ClassNotFoundException at runtime.
'provided' scope causes failures when the container doesn't have the expected version — common in modern Spring Boot where embedded containers are used.
Rule: mark every test framework as 'test', every JDBC driver as 'runtime', and avoid 'system' scope entirely.
Key Takeaway
Scope controls classpath inclusion and packaging.
'test' is for test frameworks only — never leaked to production.
'provided' is for container-provided libraries (rare in fat JARs).
'runtime' for drivers and implementations not needed at compile time.

Optional Dependencies — The Table Row You Never Explained

Your comparison table includes 'optional' but the article never explains it. Let's fix that.

An optional dependency is a dependency that your library can use but doesn't require. When you mark a dependency as <optional>true</optional>, it is NOT propagated to consumers. Projects that depend on your library won't automatically get that dependency — they have to declare it explicitly if they need it.

The use case: Your library supports multiple database drivers (PostgreSQL, MySQL, H2) but doesn't require any specific one. You mark each driver as optional. Consumers add the one they need. Without optional, a consumer who adds your library would get ALL database drivers pulled in transitively, even if they only use PostgreSQL.

The gotcha: Optional is NOT a scope. It's a flag that can be applied to any dependency regardless of scope. A compile-scoped dependency marked optional is still on your classpath during compilation — it just doesn't propagate to consumers. A common mistake is thinking optional means 'not needed at runtime' — it doesn't. It means 'not forced on consumers.'

When to use it: Library authors who support multiple backends. Framework authors who provide extensions. Any situation where a dependency is one of several possible alternatives rather than a requirement.

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- Optional: consumers must declare this if they need it -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>

<!-- Consumer's pom.xml: must explicitly declare the driver they need -->
<dependency>
    <groupId>io.thecodeforge</groupId>
    <artifactId>my-library</artifactId>
    <version>1.0.0</version>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
Production Insight
A library published with optional dependencies means consumers must manually add them — if they forget, runtime errors occur.
Optional is often misused to 'hide' heavy dependencies, leading to confusing ClassNotFoundExceptions for consumers.
Rule: use optional only when you genuinely support alternative implementations; for all other cases, use runtime or compile scope and let consumers exclude if needed.
Key Takeaway
Optional dependencies are not transitive to consumers.
They are NOT about runtime inclusion — they control what propagates.
Use for alternative implementations (e.g., multiple DB drivers).

The Maven Build Lifecycle — What Happens When You Run mvn install

Maven defines a standard build lifecycle: a sequence of phases executed in order. The most important lifecycle is default (build and package your project). Running any phase triggers all preceding phases automatically.

Key phases in the default lifecycle (in order): validate (check project is correct), compile (compile source code into target/classes), test-compile (compile test source), test (run unit tests; build fails if tests fail), package (create the distributable artifact, e.g., JAR/WAR in target/), verify (run integration tests), install (copy artifact to local ~/.m2/repository), deploy (copy artifact to remote repository for sharing).

mvn clean removes the target/ directory. mvn clean install is the most common command: it cleans stale files, compiles, tests, packages, and installs to local repo. Use -DskipTests or -Dmaven.test.skip=true to skip tests during packaging (but only when necessary — do not make this a habit).

The build directory structure: After mvn package, your target/ directory contains: target/classes (compiled main source), target/test-classes (compiled test source), target/surefire-reports (test results), target/my-app-1.0.0.jar (the packaged artifact). The JAR contains only your compiled classes — dependencies are NOT included unless you use the shade or assembly plugin (or Spring Boot's repackage goal for fat JARs).

terminal_commands.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# Compile the project
mvn compile

# Run unit tests
mvn test

# Package into JAR (runs compile + test first)
mvn package

# Install to local repo (~/.m2)
mvn clean install

# Skip tests (use sparingly)
mvn clean install -DskipTests

# Show the full dependency tree
mvn dependency:tree

# Show conflicting dependency versions
mvn dependency:tree -Dverbose

# Check for available updates
mvn versions:display-dependency-updates

# Run the Spring Boot application
mvn spring-boot:run

# Build a specific module in a multi-module project
mvn clean install -pl user-service -am

# Build with parallel threads (1 thread per CPU core)
mvn clean install -T 1C

# Force update of SNAPSHOT dependencies
mvn clean install -U

# Build offline (use only cached artifacts)
mvn clean install -o

# Purge local repository and re-download
mvn dependency:purge-local-repository
Production Insight
Running 'mvn test' skips integration tests — use 'mvn verify' in CI to run them.
'Skipping tests with -DskipTests in production builds is a red flag — it's often a sign of flaky tests that need fixing.
Rule: always run mvn clean install (or verify) in CI; never rely on partial lifecycles.
Key Takeaway
The lifecycle is sequential: validate -> compile -> test -> package -> install -> deploy.
Running a later phase triggers all earlier phases.
'mvn clean install' is the standard full build command.

Properties and Version Management — Centralising Version Numbers

Hard-coding version numbers across a pom.xml makes upgrades painful. Use Maven properties to centralise versions in one place. Define a property in <properties>, reference it with ${property.name}.

Spring Boot's parent POM defines hundreds of version properties (jackson.version, logback.version, slf4j.version, etc.) that you can override simply by redefining the property. This is how you upgrade a transitive library version without an exclusion.

For multi-module projects, define all version properties in the root parent POM. Child modules never hardcode versions — they use properties inherited from the parent or versions managed in the parent's dependencyManagement section.

The override trick: If Spring Boot manages Jackson at version 2.15.3 but you need 2.16.0, you don't need an exclusion or a direct version declaration. Just add <jackson.version>2.16.0</jackson.version> to your <properties>. Spring Boot's dependencyManagement references the ${jackson.version} property, so your override propagates to all Jackson artifacts automatically.

Custom properties for plugins: Properties aren't just for dependency versions. Use them for plugin configuration too — Java version, encoding, skip flags, etc.

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<properties>
    <java.version>21</java.version>
    <maven.compiler.source>${java.version}</maven.compiler.source>
    <maven.compiler.target>${java.version}</maven.compiler.target>

    <mapstruct.version>1.5.5.Final</mapstruct.version>
    <lombok.version>1.18.30</lombok.version>
    <testcontainers.version>1.19.3</testcontainers.version>

    <!-- Override Spring Boot's managed Jackson version -->
    <jackson.version>2.16.0</jackson.version>

    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${mapstruct.version}</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
        <optional>true</optional>
    </dependency>
</dependencies>
Production Insight
Hardcoded versions lead to drift — one dev updates a dependency but misses a copy.
Overriding Spring Boot properties (like jackson.version) is a clean way to patch CVEs without touching every dependency.
Rule: define all versions in <properties> in the parent POM; never repeat a version literal.
Key Takeaway
Maven properties centralise version numbers.
Spring Boot's managed versions can be overridden by redefining the property.
Use properties for plugin configurations too.

Maven Plugins — The Build Engine You Configure But Rarely See

Maven's behavior is defined by plugins. Every lifecycle phase is bound to a plugin goal. You don't need to configure most of them — Maven's default bindings handle compilation, testing, and packaging. But understanding the key plugins helps you debug build problems and customise behavior.

maven-compiler-plugin: Compiles Java source code. Configures the Java version (source and target), annotation processor paths (for Lombok, MapStruct), and compiler arguments. In Java 21+ projects, you'll configure this to set the release flag instead of source/target.

maven-surefire-plugin: Runs unit tests during the test phase. Configures test inclusion/exclusion patterns, parallel execution, JVM arguments for tests, and test failure behavior. If your tests aren't running, check the surefire configuration.

maven-failsafe-plugin: Runs integration tests during the verify phase. Separate from surefire — integration tests are typically named *IT.java and run after packaging. This separation lets you run unit tests quickly during development (mvn test) and integration tests only in CI (mvn verify).

maven-jar-plugin: Creates the JAR file. Configures the MANIFEST.MF — Main-Class for executable JARs, Class-Path for dependency references. In Spring Boot projects, the spring-boot-maven-plugin overrides this to create fat JARs.

spring-boot-maven-plugin: Creates executable fat JARs that embed Tomcat/Jetty and all dependencies. The repackage goal takes the standard JAR and repackages it with a custom classloader structure.

maven-enforcer-plugin: Enforces rules on your build. Common rules: requireMinimumMavenVersion, banDuplicateClasses, requireJavaVersion, bannedDependencies. I add this to every project — it catches problems before they reach production.

maven-versions-plugin: Manages dependency versions. display-dependency-updates checks for newer versions. use-latest-releases auto-updates (use with caution).

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <release>21</release>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                        <version>${lombok.version}</version>
                    </path>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${mapstruct.version}</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <configuration>
                <includes>
                    <include>**/*Test.java</include>
                    <include>**/*Tests.java</include>
                </includes>
                <excludes>
                    <exclude>**/*IT.java</exclude>
                </excludes>
            </configuration>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <configuration>
                <includes>
                    <include>**/*IT.java</include>
                </includes>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>integration-test</goal>
                        <goal>verify</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-enforcer-plugin</artifactId>
            <executions>
                <execution>
                    <id>enforce</id>
                    <goals><goal>enforce</goal></goals>
                    <configuration>
                        <rules>
                            <requireMavenVersion>
                                <version>[3.9.0,)</version>
                            </requireMavenVersion>
                            <requireJavaVersion>
                                <version>[21,)</version>
                            </requireJavaVersion>
                            <banDuplicateClasses>
                                <findAllDuplicates>true</findAllDuplicates>
                            </banDuplicateClasses>
                        </rules>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>
Forge Tip: Use Enforcer Plugin in Every Project
The maven-enforcer-plugin catches problems that otherwise surface as cryptic runtime errors. banDuplicateClasses finds JARs that contain the same class (a common source of NoSuchMethodError). requireMavenVersion ensures all developers and CI use compatible Maven versions. Add it to your parent POM so all child modules inherit it.
Production Insight
The maven-enforcer-plugin can ban vulnerable dependency versions (log4j < 2.17) globally.
Wrong plugin configuration (e.g., missing annotation processor paths) can cause silent compilation failures.
Rule: use enforcer to ban duplicate classes and enforce Maven/Java version; use failsafe for integration tests, not surefire.
Key Takeaway
Maven plugins bind to lifecycle phases.
Surefire runs unit tests; failsafe runs integration tests.
Enforcer catches common build problems early.

Maven Profiles — Conditional Configuration for Different Environments

Profiles let you define conditional configuration that activates based on environment, JDK version, OS, or explicit command-line selection. They're Maven's answer to 'I need different settings for dev vs CI vs production.'

How profiles work: A profile is defined in <profiles> and contains any subset of the POM — dependencies, plugins, properties, repositories. When the profile is active, its contents are merged into the main POM. When inactive, they're ignored.

Activation methods: Command-line: mvn install -Pdev activates the 'dev' profile. JDK version: <activation><jdk>[21,)</jdk></activation> activates on JDK 21+. OS: <activation><os><family>unix</family></os></activation> activates on Linux/macOS. Property: <activation><property><name>env</name><value>ci</value></property></activation> when -Denv=ci is passed. * File exists: <activation><file><exists>docker-compose.yml</exists></file></activation>.

Common use cases: Different database drivers for dev (H2) vs production (PostgreSQL). Enabling/disabling features. Different repository configurations for different networks.

The gotcha: Profiles defined in your pom.xml are not inherited by child modules unless they're in the parent POM's <profiles> section. Activation is per-module — activating a profile on the parent doesn't automatically activate it on children unless you use -P on the command line, which activates it project-wide.

pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<profiles>
    <profile>
        <id>dev</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <dependencies>
            <dependency>
                <groupId>com.h2database</groupId>
                <artifactId>h2</artifactId>
                <scope>runtime</scope>
            </dependency>
        </dependencies>
        <properties>
            <db.url>jdbc:h2:mem:testdb</db.url>
            <logging.level>DEBUG</logging.level>
        </properties>
    </profile>

    <profile>
        <id>prod</id>
        <dependencies>
            <dependency>
                <groupId>org.postgresql</groupId>
                <artifactId>postgresql</artifactId>
                <scope>runtime</scope>
            </dependency>
        </dependencies>
        <properties>
            <db.url>jdbc:postgresql://prod-db:5432/users</db.url>
            <logging.level>WARN</logging.level>
        </properties>
    </profile>

    <profile>
        <id>ci</id>
        <properties>
            <skip.formatting>true</skip.formatting>
            <surefire.forkCount>2</surefire.forkCount>
        </properties>
    </profile>
</profiles>
Production Insight
A profile activated by default can be accidentally left active in production builds.
Child modules may not inherit profile activation — leading to missing dependencies in submodules.
Rule: use explicit -P activation in CI scripts; avoid activeByDefault for production profiles.
Key Takeaway
Profiles enable conditional configuration for different environments.
Activation can be by command line, JDK, OS, or property.
Avoid activeByDefault for environment-specific profiles.

Maven BOM (Bill of Materials) — Centralized Dependency Version Management

When you have multiple modules that share common dependencies, version drift becomes a real pain. A BOM (Bill of Materials) is a special POM that centralizes dependency version declarations without adding any dependencies to the classpath. It's a catalog of versions that child modules reference using <scope>import</scope> in <dependencyManagement>.

How it works: A BOM POM has a <dependencyManagement> section with <dependencies> and their versions. Other projects import this BOM into their own <dependencyManagement> using <type>pom</type><scope>import</scope>. Once imported, they can declare dependencies from the BOM without specifying versions — the version comes from the BOM.

Spring Boot's BOM: When you use spring-boot-starter-parent, it already imports spring-boot-dependencies BOM. That BOM manages over 200 dependency versions (Spring Framework, Jackson, Hibernate, etc.). Override any version via a property.

Creating your own BOM: For a company with microservices, a BOM in a shared repository ensures every service uses the same version of Guava, Jackson, Logback, etc. Just release a BOM POM and have all projects import it.

Gotcha: A BOM's <dependencyManagement> is not inherited transitively. If BOM A imports BOM B, projects that import only BOM A do NOT automatically get BOM B's managed versions. You must explicitly import both BOMs if you need both sets of versions.

bom-parent/pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!-- BOM POM: company-bom/pom.xml -->
<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>io.thecodeforge</groupId>
    <artifactId>company-bom</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.google.guava</groupId>
                <artifactId>guava</artifactId>
                <version>32.1.3-jre</version>
            </dependency>
            <dependency>
                <groupId>com.fasterxml.jackson</groupId>
                <artifactId>jackson-bom</artifactId>
                <version>2.16.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

<!-- Consumer multi-module parent pom.xml -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.thecodeforge</groupId>
            <artifactId>company-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
Forge Tip: BOM Versions Should Be Immutable
Once you release a BOM, never modify it. If you need to update a dependency version, release a new BOM version. Consumer projects pin a specific BOM version. This guarantees reproducible builds across all projects using that BOM.
Production Insight
Without a BOM, each microservice may use different versions of the same library, causing subtle classpath conflicts in shared environments.
A BOM with a single source of truth eliminates 'works in service A but not service B' issues.
Rule: every organization with more than 2 Java projects should have a shared BOM — it's the cheapest insurance against version drift.
Key Takeaway
BOMs centralize dependency versions without adding dependencies.
Import BOMs using <type>pom</type><scope>import</scope> in <dependencyManagement>.
Never change a published BOM — always release a new version.

Maven Settings.xml — Private Repositories, Mirrors, and Authentication

Maven's settings.xml (located in ~/.m2/settings.xml or in the project's .mvn/ directory) configures global build behavior. The two most common uses are repository authentication and mirrors.

Private repositories: Most companies run an internal Maven repository (Nexus, Artifactory, or AWS CodeArtifact) to host internal libraries and proxy external repositories. You configure these in settings.xml or in the POM's <repositories> section. For authentication, use settings.xml with a <server> entry that references the repository ID.

Mirrors: A mirror intercepts all requests for a repository and redirects them to another URL. Common setup: mirror Maven Central to your internal proxy to speed up builds and control external access. Mirror configuration is in settings.xml under <mirrors>.

Never hardcode credentials in pom.xml. Always use settings.xml or CI environment variables with -D parameters. Committing credentials to Git is a security incident waiting to happen.

Profiles in settings.xml: You can define profiles in settings.xml that are active for all projects. This is useful for setting up local development vs CI environments. For example, you could have a profile that sets your local proxy settings.

~/.m2/settings.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<settings>
    <!-- Authentication for internal repository -->
    <servers>
        <server>
            <id>internal-releases</id>
            <username>${env.NEXUS_USER}</username>
            <password>${env.NEXUS_PASS}</password>
        </server>
    </servers>

    <!-- Mirror all Maven Central requests through your proxy -->
    <mirrors>
        <mirror>
            <id>internal-central</id>
            <mirrorOf>central</mirrorOf>
            <url>https://nexus.company.com/repository/maven-public/</url>
        </mirror>
    </mirrors>

    <!-- Profile to add internal repositories -->
    <profiles>
        <profile>
            <id>company-repos</id>
            <activation>
                <activeByDefault>true</activeByDefault>
            </activation>
            <repositories>
                <repository>
                    <id>internal-releases</id>
                    <url>https://nexus.company.com/repository/maven-releases/</url>
                    <releases><enabled>true</enabled></releases>
                    <snapshots><enabled>false</enabled></snapshots>
                </repository>
            </repositories>
        </profile>
    </profiles>
</settings>
Forge Warning: No Plaintext Passwords in settings.xml!
Use environment variables (${env.VAR}) or encrypted passwords. Maven supports mvn --encrypt-password to generate an obfuscated string stored in settings-security.xml. Or better, pass credentials via CI secrets and command-line flags (-Dnexus.password=...).
Production Insight
A misconfigured mirror can silently serve outdated artifacts — one team's build uses an old version while another gets the latest.
Missing authentication causes builds to fail with 401 errors, often only in CI where credentials are environment variables.
Rule: always use environment variables for credentials; never commit secrets to your repository.
Key Takeaway
settings.xml configures repositories, mirrors, and authentication globally.
Mirrors redirect all requests for a repository to a proxy.
Never hardcode credentials in pom.xml or in settings.xml in plaintext.
● Production incidentPOST-MORTEMseverity: high

The Guava 32 Nightmare — A Transitive Version Conflict That Took Down Production

Symptom
Post-deployment, a new feature using Guava's ImmutableList.copyOf threw java.lang.NoSuchMethodError: com.google.common.collect.ImmutableList.copyOf(Ljava/lang/Iterable;) — only in production. Tests and staging passed because they had a different classpath order.
Assumption
The team assumed that because they explicitly declared Guava 32.0 in their pom.xml, Maven would use that version everywhere.
Root cause
The project had a transitive dependency chain: Library A (depth 2) pulled in Guava 31.0, another library (depth 3) pulled in Guava 32.0. Maven's nearest-wins rule selected Guava 31.0 because it was closer to the root. The explicit declaration in pom.xml was for a different artifact (com.google.guava:guava) but the transitive dependencies brought in different artifacts like com.google.guava:listenablefuture that also carried Guava classes. The conflict was between Guava JAR versions, not the explicit declaration.
Fix
Declare the exact Guava version in <dependencyManagement> (not <dependencies>) to override all transitive paths. Then run mvn dependency:tree -Dverbose to confirm only Guava 32.0 appears. In this case, adding <dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>32.0.1-jre</version></dependency> inside <dependencyManagement> fixed the issue.
Key lesson
  • Explicit dependencies in <dependencies> only override transitive versions at depth 1 — not deeper transitive paths that use different artifactIds.
  • Always use <dependencyManagement> to enforce versions across all transitive paths, especially for widely used libraries like Guava.
  • Run mvn dependency:tree -Dverbose before every major release to catch hidden version conflicts.
  • Test in a production-like classpath — test environments often have fewer dependencies, so conflicts may not surface.
Production debug guideSymptom → Action guide for the three most common dependency-related failures3 entries
Symptom · 01
NoSuchMethodError at runtime — method exists in one version but not in the runtime version
Fix
Run mvn dependency:tree -Dincludes=guava (or the affected group) to see all versions. Look for multiple entries. The one in bold is the selected version. If it's older than expected, declare the correct version in <dependencyManagement>.
Symptom · 02
ClassNotFoundException — a class referenced in code is not found in the classpath
Fix
Check if the artifact is declared as 'provided' or 'test' scope but needed at runtime. Also check if the artifact is excluded somewhere. Run mvn dependency:tree to see if it appears at all. If not, add the dependency explicitly.
Symptom · 03
Duplicate class warnings in logs during startup (e.g., SLF4J binding conflicts)
Fix
Run mvn dependency:tree -Dverbose to see which jars contain duplicate classes. Use the maven-enforcer-plugin's banDuplicateClasses rule to fail the build on duplicates. Then exclude one of the conflicting jars via <exclusions>.
★ Maven Dependency Quick Debug CommandsUse these commands to diagnose the most common dependency issues without leaving the terminal.
NoSuchMethodError or version conflict
Immediate action
Print the tree filtered to the problematic group
Commands
mvn dependency:tree -Dincludes=com.google.guava:*
mvn dependency:tree -Dverbose
Fix now
Add the correct version in <dependencyManagement> and run mvn clean install.
Missing class in production but not in tests+
Immediate action
Check if the dependency is scoped as 'test' or 'provided'
Commands
mvn dependency:tree | grep -i myartifact
mvn dependency:list -DincludeScope=runtime
Fix now
Change scope to 'compile' if the dependency is needed at runtime.
Duplicate classes causing startup errors+
Immediate action
Find all jars that contain the duplicate class
Commands
mvn enforcer:enforce -Drules=banDuplicateClasses
mvn dependency:tree -Dincludes=*:* -DappendOutput=true | grep 'duplicate'
Fix now
Add exclusion to the older dependency or use dependencyManagement to pin a single version.

Key takeaways

1
Maven's dependency management is declarative
you say what you need, Maven resolves the rest.
2
Always use <dependencyManagement> to enforce versions across transitive paths.
3
Nearest-wins chooses the shortest path, not the newest version
check the tree.
4
SNAPSHOT versions break reproducibility; use release versions for published artifacts.
5
The Maven Wrapper guarantees consistent builds across all environments.
6
BOMs centralize dependency versions and prevent drift across microservices.

Common mistakes to avoid

5 patterns
×

Using <dependencies> instead of <dependencyManagement> for version control in multi-module projects

Symptom
Child modules may get different versions of the same dependency because each module declares its own version. Production failures arise when module A uses Jackson 2.15 and module B uses Jackson 2.16, causing serialization inconsistencies at runtime.
Fix
Define all version numbers in <dependencyManagement> in the parent POM. Child modules declare dependencies without versions. The version is inherited from the parent's managed section.
×

Forgetting to add <exclusions> when swapping a transitive library

Symptom
After replacing Tomcat with Jetty, both Tomcat and Jetty appear on the classpath. Server starts but logs show duplicate warnings about servlet containers. Performance degrades due to class metadata overhead.
Fix
Always exclude the old transitive dependency when adding an alternative. Use <exclusions> inside the dependency that brought in the undesired transitive library. Run mvn dependency:tree to confirm only the intended version remains.
×

Treating nearest-wins as 'best version wins' and not checking the dependency tree

Symptom
NoSuchMethodError at runtime. The code works in tests but fails in production because a transitive dependency pulled in an older version that doesn't have the method being called.
Fix
Run mvn dependency:tree -Dverbose to see the full conflict resolution. Add the correct version to <dependencyManagement> to override all transitive paths. Use enforcer's banDuplicateClasses to catch such issues before deployment.
×

Committing SNAPSHOT dependencies in a release artifact

Symptom
Consumers of your library get inconsistent builds. Your library seems to work on one machine but fails on another. Reproducing issues becomes impossible because the SNAPSHOT may have changed between downloads.
Fix
Use the maven-release-plugin to automatically strip -SNAPSHOT before release. Add an enforcer rule to fail the build if any dependency contains -SNAPSHOT during the release phase.
×

Not using a BOM for shared dependency versions across multiple projects

Symptom
Each microservice has its own set of dependency versions. Upgrading a critical library (e.g., log4j) requires touching every single pom.xml. Some services get missed, leaving them vulnerable.
Fix
Create a company-wide BOM POM that manages all shared dependency versions. All projects import that BOM. When you need to bump a version, you release a new BOM and update the version in each project's dependencyManagement — or better, use a property that the BOM exports.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between and ...
Q02SENIOR
Explain Maven's transitive dependency conflict resolution strategy (near...
Q03SENIOR
How does the Maven Wrapper guarantee build reproducibility across enviro...
Q04SENIOR
What is a BOM in Maven and when should you create one?
Q05JUNIOR
What scopes does Maven support and how does scope affect transitive depe...
Q01 of 05SENIOR

What is the difference between and in a Maven POM?

ANSWER
<dependencies> actually adds the dependency to the project's classpath. <dependencyManagement> only declares version and scope defaults — it does NOT add dependencies to the classpath. Child modules inherit the version from <dependencyManagement> when they declare the dependency without version. This is essential for multi-module projects to ensure consistent versions across all modules. Also used in BOMs to provide version governance to consumers.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why does Maven sometimes pick an older version of a dependency over a newer one I declared?
02
What is the difference between mvn clean install and mvn verify?
03
How do I check which version of a transitive dependency my project is actually using?
04
Should I use or to override a transitive dependency version?
05
What is the Maven Wrapper and why is it important?
🔥

That's Build Tools. Mark it forged?

14 min read · try the examples if you haven't

Previous
Understanding pom.xml in Maven
4 / 5 · Build Tools
Next
Gradle Build Script Basics