Build Tools & Modules

Dependency Management Best Practices

15 min Lesson 9 of 13

Dependency Management Best Practices

Adding a dependency to a build file takes seconds. Understanding what that dependency brings with it — and keeping the build predictable six months later — takes deliberate discipline. This lesson covers three pillars every professional Java team must master: diagnosing and resolving version conflicts, using a Bill of Materials (BOM) to centralise version governance, and establishing habits that guarantee reproducible builds.

The Problem: Version Conflicts

A version conflict arises when two or more dependencies on the classpath require different versions of the same library. The JVM loads only one version, so one consumer will be running against a version it was never tested with. Symptoms range from subtle behavioural bugs to NoSuchMethodError or ClassNotFoundException at runtime — errors that can be almost impossible to diagnose without understanding their root cause.

Consider a project that depends on Library A (which requires jackson-databind:2.14) and Library B (which requires jackson-databind:2.17). Maven resolves this through its nearest-definition rule: the version declared closest to the root POM wins. Gradle uses a different strategy: newest wins by default. Both rules can produce a jar that one of the transitive consumers is incompatible with.

Diagnosing Conflicts in Maven

The dependency:tree goal is your primary diagnostic tool. Run it and pipe the output through a search to find multiple versions of a library:

mvn dependency:tree -Dverbose | grep jackson-databind

The -Dverbose flag prints all candidates, including those that were omitted due to conflict resolution, annotated with (version managed) or (omitted for conflict). That annotation tells you exactly which transitive path brought in the losing version, making the fix obvious.

To force a specific version regardless of what transitive dependencies request, declare an explicit <dependencyManagement> entry in your POM:

<dependencyManagement> <dependencies> <!-- Pin jackson-databind to 2.17.2 across all transitive pulls --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.2</version> </dependency> </dependencies> </dependencyManagement>

An entry in <dependencyManagement> does not add the dependency to the classpath — it only controls the version if that artifact appears anywhere in the dependency graph.

Diagnosing Conflicts in Gradle

Gradle provides the dependencies task and the dependencyInsight task for deeper investigation:

# See the full dependency tree for the runtimeClasspath configuration ./gradlew dependencies --configuration runtimeClasspath # Find out why a specific version was selected ./gradlew dependencyInsight \ --dependency jackson-databind \ --configuration runtimeClasspath

The insight report shows the winning version, the resolution rule applied, and every path that requested the dependency. To force a version in Gradle, use a resolution strategy:

configurations.all { resolutionStrategy { force("com.fasterxml.jackson.core:jackson-databind:2.17.2") } }
Forcing a version carries responsibility. You are asserting that your code and all of its transitive consumers work correctly with the pinned version. Always check the library changelog for breaking changes between the conflicting versions before forcing a downgrade.

Bills of Materials (BOMs)

A BOM is a special POM whose sole purpose is to declare a curated, tested set of dependency versions. When you import a BOM, you get all of its version constraints at once — without adding every library to your own classpath. The result is a build where related libraries are always aligned.

Well-known BOMs include spring-boot-dependencies, jackson-bom, and junit-bom. Here is how to import the Jackson BOM in Maven:

<dependencyManagement> <dependencies> <dependency> <groupId>com.fasterxml.jackson</groupId> <artifactId>jackson-bom</artifactId> <version>2.17.2</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <!-- Now you add Jackson modules WITHOUT specifying versions --> <dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> </dependency> </dependencies>

The critical insight here is that all Jackson modules share a release cycle — using jackson-databind:2.17.2 with jackson-datatype-jsr310:2.14.0 is a common mistake that causes subtle serialisation bugs. The BOM eliminates that entire class of error.

In Gradle, import a BOM using the platform() dependency:

dependencies { implementation(platform("com.fasterxml.jackson:jackson-bom:2.17.2")) implementation("com.fasterxml.jackson.core:jackson-databind") // no version implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") // no version }
Spring Boot users already use a BOM. When you set a Spring Boot parent POM or import spring-boot-dependencies, you inherit managed versions for hundreds of libraries. That is why a vanilla Spring Boot project almost never specifies versions for common dependencies — they are all pinned in the BOM.

Reproducible Builds

A reproducible build is one where the same source code, built with the same tool, produces byte-for-byte identical output every time — regardless of who runs it or when. Even if you are not targeting that level of exactness, the underlying practices are essential for any professional project:

  • Pin every version explicitly. Never use version ranges (e.g. [1.0,2.0) in Maven or latest.release in Gradle). Ranges cause silent upgrades that change the build on Tuesday compared to Monday.
  • Commit your lock file. Gradle generates gradle/verification-metadata.xml (dependency verification) and supports a Lockfile API. Maven has no native lockfile but achieves pinning through dependencyManagement and BOMs. Both approaches belong in version control.
  • Pin the build tool itself. Use the Maven Wrapper (mvnw) or Gradle Wrapper (gradlew) and commit the wrapper JAR and properties file. The wrapper file specifies an exact build tool version, so every developer and every CI agent uses the same Maven or Gradle binary.
  • Pin the JDK. Specify the Java release in the build file and document the required JDK version in the project README. Bytecode produced by Java 21 may differ from bytecode produced by Java 17 even for the same source.
# Gradle wrapper — pins Gradle 8.8 for every build environment # gradle/wrapper/gradle-wrapper.properties distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
# Maven wrapper — pins Maven 3.9.6 # .mvn/wrapper/maven-wrapper.properties distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
Enable Gradle dependency verification for security-critical projects. Run ./gradlew --write-verification-metadata sha256 to generate gradle/verification-metadata.xml, then commit it. Gradle will verify the SHA-256 checksum of every downloaded artifact on subsequent builds, catching supply-chain attacks or corrupted downloads before they reach your runtime.

Keeping Dependencies Healthy Over Time

A pinned dependency that is never updated becomes a security liability. The goal is not to freeze dependencies forever but to update them deliberately:

  • Use the versions:display-dependency-updates Maven plugin or the Gradle dependencyUpdates task (via the ben-manes/versions plugin) to get a report of available upgrades as part of your weekly or sprint routine.
  • Enable Dependabot or Renovate in your GitHub/GitLab repository. These tools open automated pull requests for dependency updates, which your CI pipeline tests automatically — giving you updates with a safety net.
  • Treat a CVE (Common Vulnerabilities and Exposures) alert as a P1. When a transitive dependency has a known vulnerability, pin the patched version in <dependencyManagement> or resolutionStrategy immediately, even before the direct dependency releases an update.
  • Periodically remove unused dependencies. The dependency:analyze Maven goal lists declared-but-unused and used-but-undeclared artifacts. Undeclared transitive dependencies you rely on directly are a ticking clock — they can disappear when the declaring library changes its own deps.
# Maven: list outdated dependencies mvn versions:display-dependency-updates # Maven: analyse declared vs. actually used mvn dependency:analyze # Gradle: list outdated dependencies (requires ben-manes plugin) ./gradlew dependencyUpdates -Drevision=release

Summary

Version conflicts are resolved by understanding Maven nearest-definition or Gradle newest-wins strategies and overriding them explicitly via <dependencyManagement> or resolutionStrategy. A BOM lets you import a tested set of aligned versions with a single declaration, eliminating version mismatches within a library family. Reproducible builds demand pinned tool versions (wrappers), pinned dependency versions (no ranges), and a committed lock file or dependencyManagement block. Regular hygiene — automated update PRs, CVE monitoring, and unused-dependency analysis — keeps the build healthy without sacrificing stability.