You've just pulled the latest branch and Gradle sync fails with a dependency conflict. The error message is long, the stack trace is cryptic, and you have a feature to deliver by end of day. This guide is for that moment. We'll walk through a practical triage process that gets you from error to fix in under fifteen minutes, without guesswork or cargo-culting version numbers.
We assume you know the basics of Gradle and dependency management. What we focus on here is the diagnostic workflow: how to read conflict messages, how to use Gradle's built-in tooling to inspect the dependency tree, and how to choose a resolution strategy that won't break again next week. Along the way, we'll cover the mistakes that even experienced teams make and how to avoid them.
1. Where Dependency Conflicts Show Up in Real Work
Dependency conflicts don't announce themselves politely. They appear as build failures, runtime classpath errors, or—worst of all—subtle bugs that only surface in production. In a typical project, the first sign is a Gradle sync error with a message like Conflict found between versions X and Y. That's the easy case. Harder to diagnose are situations where the build succeeds but you get a NoSuchMethodError or ClassNotFoundException at runtime because the wrong version of a transitive dependency ended up on the classpath.
Common Entry Points
Most conflicts enter your project through transitive dependencies. You add a library, and that library pulls in its own dependencies. If two libraries depend on different versions of the same library, Gradle has to pick one. By default, it picks the newest version, but that's not always the right choice. The classic example: Library A depends on Guava 27.0, Library B depends on Guava 30.0. Gradle resolves to Guava 30.0, but Library A was written against an API that was removed in 30.0. Build succeeds, runtime fails.
Another common scenario is when your project uses a BOM (Bill of Materials) or a platform constraint, and a library you add later overrides those constraints. This often happens with Spring Boot and its managed dependencies: you add a third-party library that brings in a different version of Jackson, and suddenly your JSON serialization breaks in subtle ways.
We also see conflicts arise from multi-module projects where different modules specify different versions of the same dependency. Without a centralized version catalog, each module can drift independently. Over time, the divergence grows, and the conflict resolution becomes a manual archaeology exercise.
Finally, don't forget about annotation processors and build plugins. They can introduce dependencies that conflict with your application's classpath. For example, a Dagger version mismatch can cause generated code to fail compilation, and the error message often points to a missing method rather than the actual conflict.
2. Foundations Readers Confuse
Before we dive into the triage process, we need to clear up some common misunderstandings. The biggest one: thinking that 'force' or 'strictly' is a universal fix. Many developers, when they see a conflict, immediately add a force = true or a strictly constraint. That works in the short term but often causes downstream problems because it overrides all other constraints, including those from libraries that genuinely need a different version.
How Gradle Resolves Conflicts
Gradle uses a conflict resolution strategy that, by default, selects the newest version. But that's only the starting point. The actual resolution involves a complex negotiation between dependency constraints, forced versions, and transitive dependencies. Understanding the hierarchy helps:
- Direct dependencies (declared in your
build.gradle) have the highest priority, but they can still be overridden bystrictlyconstraints from other modules. - Transitive dependencies are resolved based on the version requested by the nearest dependency in the tree (the 'nearest wins' rule).
- Constraints (declared with
constraintsblock or via a BOM) influence resolution but are not absolute. - Forced versions (via
resolutionStrategy.force) override everything, includingstrictly.
Another confusion is between api and implementation configurations. Using api exposes the dependency to consumers of your module, which can lead to unintended version conflicts in downstream projects. Many teams use api by default because they think it's safer, but it actually increases the surface area for conflicts.
Finally, there's the myth that using a BOM or version catalog eliminates conflicts. It doesn't. It centralizes version declarations, but if two BOMs disagree, you still have a conflict. The BOM just makes it easier to see where the disagreement comes from.
3. Patterns That Usually Work
Now let's get to the practical steps. When you encounter a dependency conflict, follow this triage workflow. It's designed to be quick and repeatable, so you can get back to coding.
Step 1: Read the Error Message Carefully
Gradle's conflict error messages have improved. They now often list the conflicting versions and the paths that brought them in. Look for the line that says 'Conflict found between versions' and note the module name and versions. Then look for the 'dependency path' or 'requested by' lines. These tell you which direct dependencies are pulling in the conflicting versions. Write down the module coordinates (group:artifact:version) for each path.
Step 2: Run the Dependency Report
Use gradle dependencies --configuration compileClasspath (or runtimeClasspath if it's a runtime issue). This prints a tree of all dependencies and their versions. Look for the module that's conflicting. You'll see it appear multiple times with different versions. The tree shows which path led to each version. This is your map.
Step 3: Decide on a Resolution Strategy
Based on the map, choose one of these strategies:
- Exclude the transitive dependency if you don't need it. Use
excludein the dependency declaration:implementation('com.example:library-a') { exclude group: 'com.conflicting', module: 'unwanted-lib' }. This is clean if the excluded library is truly not used. - Force a version using
resolutionStrategy.forceonly as a last resort, and only if you've verified that the forced version works with all libraries. Add it in a central place likeallprojectsor a commonsubprojectsblock. - Use a dependency constraint via
constraintsblock:constraints { implementation('com.conflicting:lib:2.0') { because 'preferred version for all consumers' } }. This is softer than force and allows Gradle to still consider other versions if needed. - Upgrade or downgrade one of the direct dependencies to use a version that aligns with the other. This is often the best long-term fix but requires checking release notes for breaking changes.
Step 4: Verify the Fix
After applying the change, run gradle dependencies again to confirm the conflict is resolved. Then run your tests. If the fix involved changing a version, pay extra attention to tests that exercise the affected functionality.
4. Anti-Patterns and Why Teams Revert
Even with a good process, teams often revert to bad habits. Here are the anti-patterns we see most frequently, and why they fail.
The 'Force Everything' Approach
When a conflict appears, some developers add force = true to the conflicting module in a global resolutionStrategy. This works once, but it's a ticking time bomb. The next time a library updates its dependencies, the forced version may become incompatible, and you'll get runtime errors that are hard to trace back to the force. We've seen teams spend days debugging a NoSuchMethodError that was caused by a force added six months ago.
Copy-Pasting Version Numbers from Stack Overflow
It's tempting to search for the error message and paste the version number from an answer. But that answer may be for a different Gradle version, a different set of libraries, or a different configuration. We've seen teams end up with a mix of incompatible versions because they applied multiple copy-paste fixes without understanding the dependency tree.
Ignoring the Deprecation Warning
Gradle often prints deprecation warnings about conflict resolution strategies that will change in future versions. Many teams ignore these warnings until the build breaks after a Gradle upgrade. By then, the fix is more urgent and less careful. A better approach is to treat deprecation warnings as tickets to investigate: read the documentation, update your resolution strategy, and test.
Using 'api' Instead of 'implementation'
As mentioned earlier, api exposes dependencies to consumers. If you have a multi-module project and every module uses api, you create a dense web of version requirements that are hard to untangle. We've seen projects where the dependency tree has hundreds of nodes because of unnecessary api declarations. Switching to implementation where possible reduces the surface area for conflicts.
Teams often revert to these anti-patterns because they're fast in the moment. But the cost compounds. A single force can cause a cascade of failures in downstream modules. Our advice: resist the quick fix, invest the extra ten minutes in a proper triage, and document the decision.
5. Maintenance, Drift, and Long-Term Costs
Dependency management is not a one-time task. Over the life of a project, dependencies drift: new versions are released, old versions become deprecated, and transitive dependencies change. Without ongoing maintenance, the dependency tree becomes a tangled mess that resists change.
Version Drift in Practice
Consider a project that uses a BOM from Spring Boot. Spring Boot manages versions for dozens of libraries. When you upgrade Spring Boot, many library versions change at once. That's fine if your other dependencies are compatible with those versions. But if you have a library that depends on an older version of, say, Hibernate, the upgrade can break. The fix is often to either upgrade that library or exclude its transitive Hibernate dependency and rely on the BOM's version.
Over time, as you add more libraries, the number of such conflicts grows. Without a systematic approach, you end up with a patchwork of excludes and forces that are hard to understand. We've seen projects where the build.gradle file has dozens of exclude statements, each added to fix a specific conflict, with no documentation of why.
The Cost of Technical Debt
Dependency conflicts are a form of technical debt. Each quick fix adds a little more complexity. Eventually, upgrading a single library becomes a multi-day effort because you have to untangle all the forces and excludes. The cost is not just developer time; it's also the risk of introducing bugs when you finally do the cleanup.
To avoid this, we recommend a regular dependency health check. Every sprint, spend an hour reviewing the dependency tree, checking for outdated versions, and removing unused dependencies. Use tools like Gradle's dependencyUpdates plugin or the built-in outdated task to identify candidates for upgrade. Document each change in a commit message that explains the rationale.
Automation to the Rescue
Consider adding a CI step that runs gradle dependencies and fails if there are any conflicts. This catches issues early, before they become emergencies. You can also use a tool like Dependabot or Renovate to automate version updates, but be careful: automated updates can introduce conflicts if they update a library that has incompatible transitive dependencies. Always run your full test suite after an automated update.
6. When Not to Use This Approach
The triage process we've described works for most dependency conflicts, but there are situations where it's not the right tool. Knowing when to step back is as important as knowing how to triage.
When the Conflict Is in a Library You Don't Control
If the conflict is between two transitive dependencies of a third-party library, and you can't change that library's dependencies, your options are limited. You can try to exclude the problematic transitive dependency and hope the library works without it, but that's risky. A better approach is to contact the library maintainer or switch to an alternative library that doesn't have the conflict.
When the Build Is Already Broken and You Need a Workaround
If you're in a production emergency and need a fix now, the careful triage process may be too slow. In that case, a temporary force or exclude is acceptable, but you must create a ticket to revisit it later. Make sure the fix is isolated to the specific module and configuration, and document it clearly. After the emergency, schedule time to do the proper triage.
When the Project Is Being Decommissioned
If the project is near end-of-life and won't receive significant updates, spending time on a perfect resolution may not be worth it. A quick force that gets the build green may be acceptable, as long as you understand the risks. But be honest with yourself: many 'temporary' fixes become permanent because the project never gets decommissioned.
When You're Evaluating a New Library
When you're adding a new library to your project, a dependency conflict is a red flag. It may indicate that the library is not compatible with your existing stack. Before you start triaging, consider whether there's an alternative library that integrates cleanly. The cost of a clean integration now is lower than the cost of maintaining a patchwork of excludes later.
7. Open Questions and FAQ
We've covered the core process, but some questions come up repeatedly in practice. Here are our answers to the most common ones.
How do I find which library introduced a transitive dependency?
Use the gradle dependencies report. Look for the module in the tree and trace its path back to a direct dependency. The report shows the full hierarchy. If the tree is too large, use the --scan option to generate a build scan, which provides a searchable web interface.
What's the difference between 'force' and 'strictly'?
force is a method on ResolutionStrategy that overrides all other versions. strictly is a constraint that declares a version as the only acceptable one, but it can still be overridden by force. In practice, strictly is more declarative and works better with Gradle's module metadata, but both should be used sparingly.
Should I use a version catalog or a BOM?
Both are good practices, but they serve different purposes. A version catalog centralizes version declarations for your own project's dependencies. A BOM (Bill of Materials) is a curated set of versions for a family of libraries (like Spring Boot). You can use both: a version catalog for your own dependencies, and a BOM for managed dependencies. Just be aware that BOMs can conflict with each other.
How do I handle conflicts in annotation processors?
Annotation processors are often declared separately from regular dependencies. Use the annotationProcessor configuration. If you have a conflict, treat it like any other dependency: check the tree with gradle dependencies --configuration annotationProcessor and apply the same triage steps.
What if the conflict only appears on a specific machine or CI?
This usually indicates a difference in Gradle version, JDK version, or local cache state. Ensure all environments use the same Gradle version (use the Gradle wrapper) and JDK version. Clear the Gradle cache (~/.gradle/caches) on the affected machine and rebuild. If the problem persists, compare the dependency trees from the working and failing machines.
8. Summary and Next Experiments
Dependency triage is a skill that improves with practice. The process we've outlined—read the error, inspect the tree, choose a strategy, verify—is repeatable and reliable. But the real key is to avoid the quick fixes that create long-term pain.
Here are your next steps:
- Run a dependency report today on your current project. Even if there's no conflict, look at the tree. Identify any dependencies that appear multiple times with different versions. These are potential conflicts waiting to happen.
- Set up a CI check that fails the build if there are any dependency conflicts. This prevents new conflicts from being introduced silently.
- Review your existing forces and excludes. Create a ticket for each one to investigate whether it's still needed. If you can remove it, do so. If not, add a comment explaining why.
- Adopt a version catalog if you haven't already. It makes version declarations consistent across modules and makes it easier to see what versions you're using.
- Schedule a regular dependency health check every few sprints. Use it to upgrade libraries, remove unused ones, and clean up resolution strategies.
Dependency conflicts are inevitable in any non-trivial project. But with a systematic triage process and a commitment to long-term maintenance, you can reduce the time you spend on them and keep your builds green. The next time you see that conflict error, you'll know exactly what to do.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!