Ever had your build fail with a dreaded NoClassDefFoundError
or ClassNotFoundException
? You're not alone. Maven's approach to resolving dependencies can feel like navigating through an intergalactic maze — especially when direct and transitive dependencies clash unexpectedly.
In this post, we'll demystify how Maven handles dependency conflicts, explore common pitfalls, and share practical tips to keep your build on track.
Understanding Maven and its ecosystem
Maven is more than just an XML-based build tool — it's your project's orchestrator, managing everything from artifact creation to dependency resolution.
The core concept of Maven is your project definition, the POM Project Object Model
that contains the name and version of your project and all your dependencies and much more.
Introduced in 2004, it became the de facto standard for Java builds, even as alternatives like Gradle emerged.
Managing dependencies and versions at the open source scale is challenging. Other ecosystems like NPM, PyPI or RubyGems combined the flexibility of version ranges with lock-files. They encourage following semantic versioning and providing flexible compatible version ranges for your transitive dependencies. An application developer resolves versions once and saves the resolution in a lock-file locking the versions used by the application.
Maven took a different approach and it's not common to have version range in your dependencies. Instead, Maven promotes explicitly declaring fixed dependency versions, ensuring consistent and repeatable builds. This removed the need for a lock-file.
Dependency resolution: order matters!
Let's break it down visually.
%%{init: {"theme": "neutral"}}%% graph TD classDef dot stroke-dasharray: 4 8, fill: #ffe0cf classDef picked fill: #d7ffcf A1(application 1.0) --> B1(spring-boot 1.0) B1 -.-> D1(guava 1.0):::dot A1 -.-> D2(guava 2.0):::dot class D2 picked
In the above example, Maven selects Guava 2.0 because its declaration is closer to the root.
But in this other scenario:
%%{init: {"theme": "neutral"}}%% graph TD classDef dot stroke-dasharray: 4 8, fill: #ffe0cf classDef picked fill: #d7ffcf A2(your_application 2.0) --> B1(spring-boot 1.0) B1 -.-> D1(guava 1.0):::dot A2 --> C1(log4j 1.0) C1 -.-> D2(guava 2.0):::dot class D1 picked
Now, Guava 1.0 is chosen—illustrating how subtle differences in dependency order can lead to very different outcomes.
Maven resolves dependencies using a breadth-first search (BFS) strategy combined with two conflict resolution rules:
- If the same dependency appears multiple times at different depths, Maven selects the declaration closest to the root of the dependency tree.
- If the same dependency appears multiple times at the same depth, Maven selects the declaration encountered first during traversal.
Mastering the POM: Sharing Common Versions
Parent POMs
When managing multiple projects, keeping track of versions can be a headache. Maven's solution? Parent POMs paired with <dependencyManagement>
. This lets you centralize dependency versions so all your child projects inherit consistent settings.
Parent
<project><groupId>com.company</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging><dependencyManagement><dependencies><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>33.3.0-jre</version></dependency></dependencies></dependencyManagement></project>
Application
<project><parent><groupId>com.company</groupId><artifactId>parent</artifactId><version>1.0</version></parent><artifactId>application</artifactId><version>1.0</version><packaging>jar</packaging><dependencies><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId></dependency></dependencies></project>
This setup eliminates version mismatches, but there is one limitation, a project may have at most one parent.
POM import and BOMs
Using a POM import lets you pull in an entire dependency management section from another POM. This is particularly useful when you need to share a set of dependency versions across multiple projects without the one-parent limitation.
<dependencyManagement><dependencies><dependency><groupId>org.example</groupId><artifactId>example-deps</artifactId><version>1.0</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
This will "simply" copy the entire dependency management of the imported POM at the location of the POM import dependency and continue resolving dependencies. But bear in mind that order matters in Maven, so be careful with the location you put the imports.
This is a common pattern in the Java ecosystem. Frameworks like Spring and Quarkus often provide BOMs (Bill of Materials) that list compatible versions for all their related dependencies. You add a POM import for this BOM in your project and get a list of compatible versions for main libraries used.
When using a BOM, your project automatically benefits from coordinated versioning—up until you start mixing incompatible ones!
Modular build
In the Java ecosystem, artifacts are deployed as a Jar (Java Archive) that contains all the classes and interfaces of a given artifact. For an application to compile and run correctly, it needs to have the required jars in the correct ClassPath (the list of locations where to find the jars). Then the JDK reads the jars and loads the classes into memory linking them at runtime.
Scopes: split dev, test, production code
-
Compile: Default scope, code is always available
-
Provided: Used at compile time but expected to be provided by the runtime platform and not packaged by Maven (e.g. servlet). This is not transitive (meaning that a transitive provided dependency is not available in any scope).
-
Runtime: Code is not used at compile time but required for execution (e.g. DB drivers).
-
Test: Used for compiling and running tests, not packaged in the resulting application. This is not transitive.
-
System: Similar to provided but not managed by Maven; the dependency has to be installed on the host before use.
-
Import: Used for importing dependency management of other POMs as mentioned before.
Dependency scopes for third-party libraries change based on the scope of your direct dependency. The table below shows how a direct dependency's scope (left) transforms a transitive library's original scope (top):
From —> To | compile | provided | runtime | test |
---|---|---|---|---|
compile | compile | removed | runtime | removed |
provided | provided | removed | provided | removed |
runtime | runtime | removed | runtime | removed |
test | test | removed | test | removed |
But it still impacts the resolution of dependencies!
In the following example, we have a log4j dependency and a junit test dependency, but as junit is defined earlier, then the transitive guava library will pick this version for all scopes (compile, runtime, and not only tests).
%%{init: {"theme": "neutral"}}%% graph TD classDef dot stroke-dasharray: 4 8, fill: #ffe0cf classDef picked fill: #d7ffcf classDef test fill: #ff9cf3 A2(your_application 2.0) --> B1(junit 1.0 test):::test B1 -.-> D1(guava 1.0):::dot A2 --> C1(log4j 1.0) C1 -.-> D2(guava 2.0):::dot class D1 picked
Your own code should only use classes from your direct dependencies. However, Java's object model (inheritance) requires the entire classes' definition to compile the code. As such, Maven has to put transitive dependencies in the compile ClassPath instead of the runtime one. As a result, there is nothing forbidding you to use code from transitive dependencies directly in your code.
This is regularly ignored by developers and they discover problems later.
More on this here ⇒ https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html
Profiles: dynamic loading
Java compilation used to be slow and developers started splitting their projects into many smaller modules.
Maven profiles allow you to customize builds by modifying dependencies, properties, or plugins based on conditions like environment, OS, or user-defined parameters. They're useful for handling different deployment environments (e.g., dev, test, prod) or platform-specific configurations.
However, profiles can also introduce inconsistencies, making builds harder to reproduce and debug—especially when different team members or CI pipelines activate profiles differently. Overusing them can lead to hidden dependencies and unexpected behavior, so they should be used sparingly and always with clear documentation.
A profile
may modify any of: dependencies, dependencyManagement, properties, modules and be activated by any of: explicitly (by the CLI), implicitly (active by default), based on OS, based on system properties, or based on the presence of files.
<profile><id>withprotobuf</id><dependencies><dependency><groupId>com.google.protobuf</groupId><artifactId>protobuf-java</artifactId><version>4.30.2</version></dependency></dependencies></profile>
You may add a new dependency, or pin a different version as shown above.
<profile><id>it-tests</id><modules><module>it-test-1</module><module>it-test-2</module></modules></profile>
Or activate some modules that are not active by default.
<profile><id>native-linux</id><activation><os><family>linux</family><arch>x86</arch></os></activation><dependencies><dependency>...native dependency for linux x86...</dependency></dependencies></profile>
Or add a native code dependency that depends on the target OS family / architecture that will fetch pre-build native code packaged as a JAR.
More information on this here ⇒ https://maven.apache.org/guides/introduction/introduction-to-profiles.html
Tackling Dependency Conflicts: Strategies and Recommendations
Identify conflicts
The command mvn dependency:tree
will display the resolved dependency graph and, with the option -Dverbose
, it will output any omitted duplicates in parenthesis:
\- (org.apache.logging.log4j:log4j-api:jar:2.14.1:compile - version managed from 2.15.1; omitted for duplicate)
But it will not tell you where the resolved version is coming from: is it hard-coded in the project, is it coming from a dependency management, from a parent POM, or from a third-party BOM?
You may have to manually cycle through your dependencies' POM files and their potential parents to find the location where it is defined. It is easier with an IDE helping you navigate in this ocean of POMs.
Trying to solve conflicts
After having identified a conflict, you have multiple solutions:
Exclusions
You may exclude some third-party dependency when declaring a dependency. This way, you explicitly tell Maven not to pick this dependency and rely on another declaration (another third-party, or manually adding it as a direct dependency).
<dependencies><!-- Dependency that includes a transitive dependency we want to exclude --><dependency><groupId>com.example</groupId><artifactId>library-a</artifactId><version>1.0.0</version><exclusions><exclusion><groupId>com.example</groupId><artifactId>library-b</artifactId></exclusion></exclusions></dependency><!-- Optionally, include a different version of the excluded dependency if needed --><dependency><groupId>com.example</groupId><artifactId>library-b</artifactId><version>2.0.0</version></dependency></dependencies>
This is a way to pick the version you want for a particular dependency but it may cause some issues at runtime due to incompatibilities between versions.
Dependency Shading
Sometimes you don't have a proper solution and you have to rely on both versions. Fortunately, Maven supports this as well by a mechanism called shading, where you embed/hide a dependency within your application or library code (most of the time under a different namespace to avoid potential conflicts).
This should be a very last resort as you lose almost all traceability to the original dependency. If you shade a vulnerable version, it will be hard to know that your application or library embeds it in its artifact.
There is a Maven plugin for that
Given Maven's modular architecture, plugins have become indispensable tools to address common build and dependency challenges. One particularly effective solution for tackling dependency conflicts is the Maven Enforcer Plugin.
This plugin helps enforce rules that maintain build consistency and prevent dependency chaos by:
- Banning unwanted dependencies explicitly.
- Ensuring dependency convergence (no conflicting versions).
- Mandating explicit dependency management to clearly control versions.
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-enforcer-plugin</artifactId><version>3.5.0</version><executions><execution><id>enforce-dependency-compatibility</id><goals><goal>enforce</goal></goals><configuration><rules><bannedDependencies><excludes><exclude>commons-logging:commons-logging</exclude></excludes></bannedDependencies><dependencyConvergence/><requireDependencyManagement/></rules></configuration></execution></executions></plugin>
In this example:
- The
bannedDependencies
rule explicitly prevents using a specified dependency. If this dependency is found anywhere, the build fails immediately. - The
dependencyConvergence
rule ensures your project doesn't contain conflicting versions of the same dependency. - The
requireDependencyManagement
rule ensures all dependencies explicitly declare versions throughdependencyManagement
, avoiding unintended or implicit versions.
Recommendations
- ✓ 1. Use dependency management
- ✓ 2. Avoid splitting your projects too much
- ✓ 3. Use the enforcer plugin
- ✓ 4. Avoid profiles as you end up with non-reproducible builds
Some big companies already faced this challenge and came up with some guidelines on how to survive the open source wildlife:
The Java Library Best Practices (JLBP) is a handy collection of tips originally put together by Google to help developers navigate the tricky world of open-source Java libraries. It offers practical advice on things like managing dependency versions, compatibility, and handling upgrades without breaking things. Following these guidelines can save teams a lot of headaches, making their projects more stable and easier to maintain over time.
If you want a solid explanation of how dependency management works under the hood in Maven, this talk by Moritz Halbritter is a great watch. It covers common issues like dependency mediation, version conflicts, and why your build sometimes pulls in unexpected versions. Super helpful if you've ever been surprised by what ends up on your ClassPath.
Need Help With Your Maven Dependencies?
Whether you're battling dependency conflicts or looking to optimize your build process, Konvu can help streamline your Java development workflow.
Contact our engineering team to learn how our tools can help you manage dependencies more effectively and boost your development productivity.
Bonus: Maven Dependency Management Cheat-Sheet
Concept | Best Practices / Quick Tips |
---|---|
Dependency Scopes | compile (default), provided (runtime-provided), runtime (execution only), test (tests only), system (external), import (dependencyManagement import) |
Dependency Resolution | Closest declaration wins; breadth-first traversal; order matters |
Parent POM | Centralize common versions using <dependencyManagement> |
BOM (Bill of Materials) | Use to import version sets across multiple modules |
Exclusions | Explicitly remove transitive dependencies; careful with runtime compatibility |
Shading | Last-resort solution; embeds dependency under different namespace |
Profiles | Use sparingly; clearly document activation conditions |
Plugins | Maven Enforcer Plugin recommended: ban dependencies, enforce convergence, require explicit versions |
Identifying Conflicts | Use mvn dependency:tree -Dverbose |
Common Pitfalls | Avoid excessive modularization; don't overuse profiles; always declare explicit versions |
Recommended Plugins:
- Maven Enforcer: Prevent dependency issues
- Dependency Plugin: Inspect resolved dependencies clearly