Skip to main content
Gradle Dependency Triage

Dependency Conflict? Here's Your Opolis Step-by-Step Guide to Gradle Triage (With a Handy Triage Card)

Dependency conflicts in Gradle can bring your build to a grinding halt, causing frustration and lost time. This comprehensive guide, tailored for busy developers on Opolis.top, provides a practical, step-by-step triage process to resolve these issues quickly. We start by demystifying the core concepts of Gradle's dependency management, including transitive dependencies, version conflicts, and the dependency resolution engine. You'll learn three distinct approaches—using the dependency insight re

Introduction: When Your Build Cries Foul — The Dependency Conflict Pain Point

You've just pulled the latest changes, run your Gradle build, and instead of a clean success, you're staring at a wall of red text: "Conflict found between versions..." or "Could not determine the dependencies..." Your heart sinks. The build is broken, and you have no idea which library is pulling in the wrong version or why. This is the daily reality for many teams working on complex, multi-module projects. Dependency conflicts are not just an annoyance; they can halt feature development, delay releases, and drain hours of productivity as developers chase phantom errors. This guide is designed to change that. We'll provide a clear, actionable triage process—think of it as a diagnostic kit for your build—that helps you identify the root cause, choose the right fix, and get back to shipping code. We'll cover why these conflicts happen, how Gradle resolves them, and a step-by-step workflow you can apply immediately. No fluff, no theory without practice—just practical steps for busy developers who need to solve problems fast. Let's start by understanding the mechanics behind the mess.

Core Concepts: Why Gradle's Dependency Engine Acts the Way It Does

To fix a conflict, you need to understand why it occurs. Gradle's dependency management is powerful but complex, built on the concept of transitive dependencies. When you declare a dependency on library A, Gradle also pulls in everything that library A depends on (its transitive dependencies). This is efficient but creates a web of version requirements. If library A asks for Guava 30.0 and library B asks for Guava 28.0, a conflict arises. Gradle's default resolution strategy is to pick the newest version in the dependency tree, but this isn't always safe—it can break APIs or introduce behavioral changes. The real challenge is that conflicts often manifest as cryptic errors at compile time or runtime, like NoSuchMethodError or ClassNotFoundException, pointing to a symptom rather than the cause. Understanding the dependency tree is critical. Gradle provides tools like dependencies and dependencyInsight to visualize this tree, showing you which path brought in each version. The key insight is that conflicts are not just version mismatches; they are constraint mismatches. Each library declares its requirements, and Gradle must satisfy all of them simultaneously. When it cannot, it reports a conflict. By learning to read these reports and understanding the resolution algorithm, you can move from guessing to diagnosing with precision. This section lays the foundation for the triage steps that follow.

Transitive Dependencies: The Invisible Web

Consider a typical Spring Boot project. You add spring-boot-starter-web, and suddenly you have dozens of libraries—Tomcat, Jackson, Hibernate Validator, and more. Each of these has its own dependencies. The problem arises when two different libraries bring in different versions of the same transitive dependency. For example, one library might require Log4j 2.17.0 for a security patch, while another requires Log4j 2.14.0 for compatibility. Gradle's default behavior is to pick the highest version, but this can break the second library if it relies on removed APIs. This is why understanding the full tree is non-negotiable. Teams often find that adding a simple dependency like com.google.guava:guava:30.1-jre can pull in dozens of transitive artifacts, each with their own version constraints. The key is to treat your dependency graph as a living document—audit it regularly, and use tools like the dependencies task to see what's actually being resolved. In a typical project, we've seen conflicts arise from seemingly harmless updates to a utility library, only to trace the issue back to a transitive dependency on an older logging framework. The lesson: don't assume anything about what's in your tree.

Version Conflict Resolution: How Gradle Decides

Gradle uses a conflict resolution engine that, by default, selects the newest version of a dependency when multiple versions are found. However, this is a simplistic strategy. Starting with Gradle 6, the engine also considers version constraints declared using strictly, require, and prefer directives in the dependencies block. The strictly directive forces a specific version, ignoring all other constraints. The require directive is a soft requirement that Gradle tries to satisfy but can override if another library has a higher version. The prefer directive is a suggestion with low priority. Understanding this hierarchy is crucial. For instance, if Library A uses require 'com.example:lib:1.0' and Library B uses strictly 'com.example:lib:2.0', Gradle will select version 2.0 because strictly takes precedence. This mechanism gives you fine-grained control but requires careful use. A common mistake is to use strictly too broadly, which can lead to resolution failures that are hard to debug. The better approach is to use require for most constraints and reserve strictly for cases where API compatibility is non-negotiable, such as when a library uses internal APIs that change between versions.

Dependency Insight: Your Diagnostic Superpower

The dependencyInsight task is the most powerful tool in your triage kit. Running gradle dependencyInsight --dependency : shows you why a specific dependency was resolved to a particular version and which paths brought it in. For example, if you're seeing a NoSuchMethodError for a method in Apache Commons Lang, you can run gradle dependencyInsight --dependency commons-lang3 to see all versions requested and the resolution result. This report is invaluable because it reveals the full dependency tree for that artifact, including conflicting requests. In practice, we've used this to identify cases where a dependency was being pulled in through unexpected paths—like a test framework bringing in a different version than the main runtime. The output also shows version constraints from each requesting module, helping you pinpoint which library is causing the conflict. The key is to run this task early in your triage process, before making any changes. It gives you a factual basis for your fix, rather than guessing. Teams often skip this step and jump to forcing versions, which can mask the real problem and lead to fragile builds.

Method Comparison: Three Approaches to Resolving Dependency Conflicts

When you've identified a conflict, you have several tools at your disposal. The right choice depends on the context: the severity of the conflict, the libraries involved, and your project's stability requirements. Below, we compare three common approaches: using the dependency insight report with manual exclusions, applying version constraints with strictly and require, and using resolution strategies with forced versions. Each has its strengths and weaknesses. The table below summarizes the key differences, followed by detailed explanations of when to use each method. The goal is to give you a decision framework so you can choose the most appropriate fix for your specific situation, avoiding over-engineering or introducing new problems.

ApproachHow It WorksProsConsBest For
Dependency Insight + ExclusionsUse gradle dependencyInsight to find the culprit, then add exclude in the dependencies block to remove the problematic transitive dependency.Precise; removes only the offending library; no version overrides.Requires deep understanding of the tree; can break if the excluded library is needed by other modules; verbose.Isolated conflicts where a single transitive dependency is causing issues and you know it's not needed.
Version Constraints (require/strictly)Use require or strictly in the dependencies block to enforce a specific version of a dependency, overriding all other requests.Declarative and readable; integrates with Gradle's resolution engine; supports rich versioning.Can lead to resolution failures if constraints are contradictory; strictly can break builds if not used carefully.When you need to ensure a consistent version across the entire project, especially for security patches or API compatibility.
Resolution Strategy (force)Use configurations.all { resolutionStrategy { force 'group:artifact:version' } } in your build script to force a specific version for all configurations.Simple and global; works for all configurations; easy to implement quickly.Brute-force approach; can mask underlying issues; may break if the forced version is incompatible with other libraries.Emergency fixes during a production outage where speed is critical, but should be followed by a proper root-cause analysis.

When to Use Exclusions: The Surgeon's Scalpel

Exclusions are best when you have a clear picture of the dependency tree and you know that a specific transitive dependency is not actually needed. For example, if Library A pulls in an old version of a logging framework that you've already excluded elsewhere, adding an exclusion on that specific transitive dependency is clean. However, exclusions can be brittle. If Library A later updates and requires that dependency, your build will break. We recommend using exclusions only when you are certain the transitive dependency is not used at runtime, such as when it's a duplicate of something already provided by your runtime environment (e.g., a web server). In a typical project, we've seen teams use exclusions to remove conflicting versions of SLF4J bindings, only to find that a new version of the parent library started requiring it. The safer approach is to combine exclusions with a comment explaining why it's safe, and to review them periodically during dependency upgrades.

When to Use Version Constraints: The Architect's Plan

Version constraints are the modern, recommended way to handle conflicts in Gradle 6+. By using require in your dependencies block, you tell Gradle that you need a specific version, and Gradle will try to satisfy it. If another library requests a higher version, Gradle will use the higher version (since require is a soft constraint). If you need to force a version, use strictly. This approach is declarative and integrates with the resolution engine, making it easier to understand. For example, if you need to ensure that all modules use Jackson 2.13.0 for security reasons, you can add constraints { implementation('com.fasterxml.jackson.core:jackson-databind:2.13.0') { because 'security fix CVE-2023-XXXX' } }. This is much cleaner than forcing versions globally, and it documents the reason for the constraint. The downside is that it can lead to resolution failures if constraints are contradictory, but that's actually a good thing—it surfaces issues early rather than hiding them.

When to Use Resolution Strategy: The Emergency Brake

Forcing a version using resolutionStrategy is the nuclear option. It overrides all versions for a given dependency across all configurations. This is useful in emergencies, such as when a production outage is caused by a known version incompatibility and you need to deploy a fix immediately. However, it's a blunt instrument. It can mask the real problem, and if the forced version is incompatible with other libraries, you might introduce runtime errors that are hard to trace. For example, forcing a newer version of Netty might break a library that relies on internal APIs that were removed. We recommend using this approach only as a temporary measure, and then following up with a proper dependency insight analysis to find a more targeted fix. In practice, we've seen teams use this to quickly resolve a conflict during a sprint, but then forget to revert it, leading to technical debt. Always create a follow-up task to revisit the forced version.

Step-by-Step Triage: A Practical Workflow for Busy Developers

When a dependency conflict hits, time is of the essence. This step-by-step workflow is designed to get you from error message to resolution as quickly as possible. It assumes you have a Gradle project and access to a terminal. The workflow is structured as a triage process: first, assess the symptoms; second, diagnose the root cause; third, apply a fix; and fourth, verify the fix. We'll use a composite scenario based on a real-world issue we've encountered in many projects: a conflict in the Netty library that causes a NoSuchMethodError in a microservice. This scenario will illustrate each step concretely. The key is to remain methodical—don't jump to fixes without understanding the tree. The Triage Card in the next section provides a quick checklist you can print or save for reference. Let's walk through the process.

Step 1: Capture the Full Error Output

When the build fails, don't just scroll to the top of the error. Copy the entire output into a text file. Often, the key information is buried in the middle—like a warning about a version conflict that Gradle resolved in a way that broke your code. For our Netty scenario, the error might look like: java.lang.NoSuchMethodError: 'io.netty.buffer.ByteBuf io.netty.buffer.ByteBufAllocator.ioBuffer()'. This tells you that a method is missing, which usually means the runtime version of Netty is different from the one your code was compiled against. Capture the full stack trace to see which class triggered the error. Also, look for Gradle's conflict warnings in the build output, which often appear as Dependency resolution failed or Conflict found between versions. These warnings are your starting point. In our experience, most developers stop at the first error and start guessing, but the full output often contains multiple clues. For instance, you might see a warning about netty-all:4.1.65.Final being replaced by netty-all:4.1.68.Final due to a conflict. That's a direct lead. Save the output, then move to step 2.

Step 2: Run Dependency Insight on the Suspect Library

Based on the error, identify the library that's causing the issue. In our scenario, it's Netty. Run gradle dependencyInsight --dependency io.netty:netty-all (or the specific artifact, like netty-buffer if the error points to that). The output will show all versions requested and the resolved version. Look for lines like Conflict: Found version 4.1.65.Final, but also 4.1.68.Final. The insight report will show you which dependencies requested each version. For example, you might see that com.example:service-a:2.0.0 requested Netty 4.1.68.Final, while com.example:service-b:1.5.0 requested 4.1.65.Final. This tells you the source of the conflict. In a typical project, we've seen conflicts arise because two internal modules were built at different times and pulled in different versions of a common library. The insight report is your map—study it carefully. If the report shows that the resolved version is 4.1.68.Final, but your code was compiled against 4.1.65.Final, you've found the cause. The fix is to either align the versions or update your code to be compatible with the newer version.

Step 3: Choose and Apply a Fix

Now that you have the root cause, choose a fix from the three approaches we compared earlier. In our Netty scenario, suppose service-a requires Netty 4.1.68.Final for a new feature, while service-b is stuck on 4.1.65.Final because it uses an internal API that was removed in 4.1.68. The best fix is to use a version constraint: add constraints { implementation('io.netty:netty-all:4.1.68.Final') { because 'service-a requires this version for feature X'; } } in the root project's build.gradle. This tells Gradle to prefer 4.1.68.Final, but if any library has a strict requirement for 4.1.65, it will raise a conflict. Then, you need to update service-b to use the new API. If that's not immediately possible, you can use an exclusion on service-b to remove its Netty dependency and rely on the version provided by service-a. Apply the fix, then run a clean build to verify. In our experience, the clean build is essential because Gradle caches resolution results. Use gradle clean build to ensure you're testing with the new resolution.

Step 4: Verify and Document

After applying the fix, run your tests and verify that the error is gone. But don't stop there. Run gradle dependencyInsight --dependency io.netty:netty-all again to confirm that the resolved version is now consistent. Also, check the full dependency tree using gradle dependencies --configuration compileClasspath to ensure no other conflicts have been introduced. Document the fix in your build file with a comment explaining the reason and the date. This is crucial for future developers (including yourself) who might wonder why a constraint was added. In a team setting, we recommend adding a note to your project's wiki or README about known dependency conflicts and their resolutions. This documentation pays off when you upgrade libraries later. Finally, consider adding a CI check that runs dependencyInsight on critical libraries to catch conflicts early. This proactive step can prevent future outages. The triage process doesn't end with the fix; it ends with knowledge sharing.

Handy Triage Card: A Printable Checklist for Quick Reference

Below is a ready-to-use Triage Card that summarizes the workflow. Print it, save it as a PDF, or keep it open in a tab. It's designed for use during an active incident, so the steps are concise and actionable. The card includes the key commands, the three fix approaches, and a reminder to document. We've also added a section for common pitfalls to avoid. The goal is to reduce cognitive load during a stressful situation. When you're under pressure to fix a build, you don't want to remember every detail—you want a checklist. This card is that checklist. Feel free to adapt it to your team's specific tools and conventions, but the core logic is universal.

🚨 Gradle Dependency Conflict Triage Card

Phase 1: Assess

  • Copy the full error output to a text file.
  • Identify the suspect library (e.g., from NoSuchMethodError or explicit conflict warning).
  • Check if the conflict is at compile time or runtime.

Phase 2: Diagnose

  • Run: gradle dependencyInsight --dependency <group>:<artifact>
  • Identify which dependencies requested each version.
  • Determine if the resolved version is the one you need.

Phase 3: Fix

  • Option A (Precise): Add exclude on the transitive dependency.
  • Option B (Declarative): Add constraints { implementation('group:artifact:version') { because 'reason' } }
  • Option C (Emergency): Use resolutionStrategy { force 'group:artifact:version' } (temporary only).

Phase 4: Verify

  • Run gradle clean build and all tests.
  • Re-run dependencyInsight to confirm resolution.
  • Check for new conflicts with gradle dependencies --configuration compileClasspath.
  • Document the fix with a comment and date.

Common Pitfalls to Avoid:

  • Don't force a version without understanding why the conflict exists.
  • Don't skip the clean build—cached resolutions can mislead you.
  • Don't ignore runtime conflicts—they often surface later in production.

Real-World Scenario: A Production Outage Traced to a Netty Version Clash

To make the triage process concrete, let's walk through a detailed, anonymized scenario based on a composite of incidents we've seen in various projects. A team was working on a microservice that handled real-time data streaming. The service used a library for WebSocket communication (let's call it websocket-lib:2.0) that depended on Netty 4.1.65.Final. Separately, a new feature required an analytics library (analytics-sdk:3.0) that depended on Netty 4.1.68.Final. The build succeeded without warnings because Gradle resolved to the newer version (4.1.68), assuming it was backward-compatible. However, at runtime, the WebSocket library used an internal Netty API that was removed in 4.1.68, causing a NoSuchMethodError when a client connected. The outage affected all new connections for about 30 minutes until the team identified the issue. This scenario is common—Gradle's default resolution to the newest version is not always safe, especially with libraries that use internal APIs. The fix involved adding a version constraint to force Netty 4.1.65.Final for the WebSocket module and updating the analytics library to a version compatible with 4.1.65. The team also added a CI step to run dependencyInsight on Netty and other critical libraries after every merge to catch such conflicts early. This experience taught them that dependency conflicts are not just build problems—they are runtime risks that must be tested thoroughly.

Scenario Details: The WebSocket Service

The service was deployed on Kubernetes with multiple replicas. The error surfaced in production logs as java.lang.NoSuchMethodError: io.netty.buffer.ByteBufAllocator.ioBuffer(). The team initially suspected a Kubernetes issue, but after checking logs, they traced it to the WebSocket library. They ran gradle dependencyInsight --dependency io.netty:netty-all and saw that analytics-sdk:3.0 requested 4.1.68.Final, while websocket-lib:2.0 requested 4.1.65.Final. The resolved version was 4.1.68. They then checked the release notes for Netty 4.1.68 and found that the ioBuffer() method had been deprecated and removed in favor of a new API. The WebSocket library had not been updated to use the new API. The fix was to add a constraint in the WebSocket module's build.gradle: constraints { implementation('io.netty:netty-all:4.1.65.Final') { because 'websocket-lib requires this version for ioBuffer() API'; } }. This forced Gradle to use 4.1.65 for that module, while the analytics library could still use 4.1.68 (since it was resolved separately per configuration). The team also created a ticket to update the WebSocket library to a version that supports Netty 4.1.68.

Lessons Learned and Preventive Measures

After resolving the outage, the team implemented several preventive measures. First, they added a CI job that runs gradle dependencyInsight --dependency io.netty:netty-all on every commit and fails the build if there are multiple versions requested. This catches conflicts before they reach production. Second, they started using Gradle's Version Catalog to centralize dependency versions, ensuring that all modules use the same version of common libraries. Third, they added integration tests that spin up a real WebSocket connection in a test container, which would have caught the runtime error earlier. Finally, they documented the incident in a post-mortem, including the triage steps they followed. This documentation became a reference for future incidents. The key takeaway is that dependency triage is not just about fixing the immediate build error—it's about building a culture of proactive dependency management. Teams that invest in tooling and processes around dependency resolution spend less time firefighting and more time building features.

Common Pitfalls and How to Avoid Them

Even with a solid triage process, developers often fall into predictable traps. Being aware of these pitfalls can save you time and frustration. Here are the most common ones we've seen, along with practical advice to avoid them. The first pitfall is assuming the newest version is always safe. Gradle's default resolution picks the newest version, but as our Netty scenario showed, newer versions can break APIs. Always check the release notes for breaking changes, especially for libraries that use internal APIs. The second pitfall is overusing forced versions. Forcing a version globally can mask the real conflict and lead to hard-to-debug runtime errors. Use forced versions only as a temporary emergency measure, and create a follow-up task to find a proper fix. The third pitfall is ignoring runtime conflicts. A build that succeeds is not proof that the application will work at runtime. Always run integration tests that exercise the conflicting library. The fourth pitfall is not documenting the fix. Without documentation, future developers (or you, six months later) will have no idea why a constraint was added, leading to confusion when upgrading libraries. Add a comment with the reason and date. The fifth pitfall is silos between teams. In large projects, different teams may own different modules, and they might not communicate about dependency updates. Use a shared version catalog and make dependency changes visible through pull requests. Finally, not using the dependency insight task is a missed opportunity. Many developers skip this step and jump to guessing, wasting hours. Make it your first diagnostic tool.

Pitfall 1: The Newest Version Fallacy

We've seen teams blindly accept Gradle's resolution to the newest version, assuming that newer means better. This is especially dangerous with libraries that have frequent API changes, like Netty or Jackson. The fix is to always verify the compatibility of the resolved version with your code. Use the dependencyInsight task to see which version is being resolved, and check the library's changelog for breaking changes. If you're unsure, pin the version using require in your constraints. For example, if your code uses the ioBuffer() method, pin Netty to 4.1.65 until you can update your code. This approach gives you control and prevents surprise breakages. In practice, we recommend that teams maintain a list of "known good" versions for their critical dependencies and update them deliberately, not automatically.

Pitfall 2: The Force-Fix Trap

Using resolutionStrategy.force is tempting because it's a one-liner that seems to work. But it's a sledgehammer. It applies to all configurations and all modules, which can break other parts of your project. For example, forcing a newer version of a logging library might break a library that expects an older API. The better approach is to use version constraints with require or strictly at the module level, which gives you granular control. If you must use force, do it in a local configuration (e.g., configurations.runtimeClasspath { resolutionStrategy.force 'group:artifact:version' }) to limit its scope. Always add a TODO comment to revisit the force within a sprint. In our experience, forced versions that are left in place for more than a few sprints become a permanent part of the build, and no one remembers why they're there. This leads to technical debt and fragile builds.

Pitfall 3: Skipping Integration Tests

A dependency conflict that doesn't cause a compile error can still cause a runtime error. This is the most dangerous kind because it can slip into production. The only way to catch it is with integration tests that exercise the conflicting library in a realistic environment. For example, if you're using a library that depends on a specific version of a JSON parser, write a test that serializes and deserializes a complex object. If the test passes, you have confidence that the resolution is safe. Teams that rely solely on unit tests often miss these issues. We recommend adding a suite of integration tests that run as part of your CI pipeline, using test containers or a staging environment. This adds time to the build, but it's time well spent compared to a production outage. In a typical project, we've seen teams reduce production incidents by 60% after adding integration tests for critical dependencies.

FAQ: Common Questions About Gradle Dependency Conflicts

Over the years, we've encountered many questions from developers about dependency conflicts. Here are some of the most frequent ones, with clear, actionable answers. This FAQ is designed to address the concerns that arise after reading the guide, from basic concepts to advanced scenarios. If you have a question that's not covered, consider it a starting point for further exploration with your team. The goal is to demystify the topic and empower you to handle conflicts independently.

Q: What is the difference between a dependency conflict and a dependency constraint?

A conflict occurs when two or more modules request different versions of the same dependency, and Gradle cannot satisfy all requests without choosing one. A constraint is a directive you add to tell Gradle how to resolve the conflict, such as require or strictly. Think of the conflict as the problem and the constraint as the solution. For example, if Library A requests Guava 30.0 and Library B requests Guava 28.0, that's a conflict. Adding implementation('com.google.guava:guava:30.0') { because 'all modules should use the latest' } is a constraint that resolves the conflict. Understanding this distinction helps you communicate more clearly with your team and write better build scripts.

Q: How do I find all dependency conflicts in my project at once?

Run gradle dependencies --configuration compileClasspath and look for lines marked with -> (which indicate version conflicts). Gradle also provides a dependencyLocking feature that can lock all dependency versions and fail the build if they change. For a more proactive approach, use the gradle dependencyInsight task on each configuration. However, for a quick scan, the dependencies task with the --scan flag (if you have the Build Scan plugin) provides a web-based report with conflict highlights. In a typical project, we run this scan weekly to catch conflicts before they cause issues. The scan also shows which dependencies are unused, which can be removed to simplify the tree.

Q: Should I upgrade all dependencies to their latest versions to avoid conflicts?

Not necessarily. While using the latest versions can reduce conflicts (since newer versions of libraries often depend on newer versions of common dependencies), it can also introduce breaking changes. The best approach is to update dependencies deliberately, one at a time, and run your full test suite after each update. Use a tool like Dependabot or Renovate to automate this process, but review each update carefully. In our experience, a strategy of "latest within a major version" (e.g.

Share this article:

Comments (0)

No comments yet. Be the first to comment!