Maven — Guava 32 Transitive Conflict
NoSuchMethodError on Guava 32.
- 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
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.
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.
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.
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.
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.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 forspring-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 forservlet-apiin 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 againstjava.sql.Driver(the interface), but the MySQL driver JAR is only needed at runtime. Another example: Jackson annotations arecompile-scoped, but the Jackson databind runtime isruntime-scoped.system: Similar toprovidedbut 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 runningmvn install:install-fileto install the JAR into your local repository, then use a normalcompilescope. 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.
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.
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).
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.
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).
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.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.
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.
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.
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=...).The Guava 32 Nightmare — A Transitive Version Conflict That Took Down Production
- 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.
Key takeaways
Common mistakes to avoid
5 patternsUsing <dependencies> instead of <dependencyManagement> for version control in multi-module projects
Forgetting to add <exclusions> when swapping a transitive library
Treating nearest-wins as 'best version wins' and not checking the dependency tree
Committing SNAPSHOT dependencies in a release artifact
Not using a BOM for shared dependency versions across multiple projects
Interview Questions on This Topic
What is the difference between
Frequently Asked Questions
That's Build Tools. Mark it forged?
14 min read · try the examples if you haven't