Dependency Management in Java

Naveen AnamaneniUpdated · 9 min read

When a school orders new science textbooks, each book says "you will also need the lab manual", and the lab manual says "you will also need the safety guide". Order one book and you really need three. Now imagine the history department orders a different edition of the same safety guide. Someone has to decide which edition the school keeps, or students end up reading different rules. Java libraries work exactly like this. Add one library to your project and it brings the libraries it needs, which bring others, and sometimes two of them want different versions of the same thing. In this lesson, you will learn how Maven and Gradle find and choose libraries, how to read a dependency tree, how to settle version conflicts on purpose, and how to keep libraries up to date and safe. The build files themselves are covered in "Build Tools in Java: Maven and Gradle".

What Is Dependency Management?

Dependency management in Java is the work a build tool does, guided by your build file, that is used to find, download, choose versions for and put on the class path every library a project needs. A dependency is a library your code uses, such as Gson for JSON or JUnit for tests.

Every library is identified by coordinates:

PartExampleMeaning
groupIdcom.google.code.gsonThe organization or project, usually a reversed domain
artifactIdgsonThe library's name
version2.15.0The exact release

Maven writes them as XML elements; Gradle as one string, "com.google.code.gson:gson:2.15.0". Both download from repositories: Maven Central is the public one, companies often run their own (Nexus, Artifactory), and every machine keeps a local cache so a version is downloaded only once.

Transitive Dependencies

Libraries you list yourself are direct dependencies. The libraries they need are transitive dependencies, and the build tool adds them automatically. mvn dependency:tree shows the whole picture. Here it is for a project that lists five libraries: Gson, SLF4J, Logback, JUnit and Mockito.

Terminal
mvn dependency:tree
Terminal output
[INFO] com.example.school:fee-service:jar:1.0.0
[INFO] +- com.google.code.gson:gson:jar:2.15.0:compile
[INFO] |  \- com.google.errorprone:error_prone_annotations:jar:2.50.0:compile
[INFO] +- org.slf4j:slf4j-api:jar:2.0.20:compile
[INFO] +- ch.qos.logback:logback-classic:jar:1.6.6:compile
[INFO] |  \- ch.qos.logback:logback-core:jar:1.6.6:compile
[INFO] +- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO] |  |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] |  |  +- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO] |  |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] |  |  \- org.jspecify:jspecify:jar:1.0.0:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO] |  \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
[INFO] |     \- org.junit.platform:junit-platform-engine:jar:6.1.3:test
[INFO] \- org.mockito:mockito-junit-jupiter:jar:5.24.0:test
[INFO]    \- org.mockito:mockito-core:jar:5.24.0:test
[INFO]       +- net.bytebuddy:byte-buddy:jar:1.17.7:test
[INFO]       +- net.bytebuddy:byte-buddy-agent:jar:1.17.7:test
[INFO]       \- org.objenesis:objenesis:jar:3.3:test
[INFO] ------------------------------------------------------------------------

Explanation:

  1. The lines starting with +- or \- at the first level are the 5 direct dependencies. Everything indented below is transitive: 14 more libraries nobody had to list.
  2. The last word on each line is the scope. Everything under a test dependency is also test, so none of JUnit or Mockito ends up in the application.
  3. logback-classic needs slf4j-api too, but it isn't shown under it because the project already lists it directly. The tree shows each library once.

Dependency Scopes

A scope says where a dependency is needed: to compile, to run, or only for tests.

Maven scopeGradle configurationDescription
compile (default)implementationIt is used for libraries needed to compile and run the main code.
providedcompileOnlyIt is needed to compile, but the server provides it at runtime (such as the Servlet API).
runtimeruntimeOnlyIt is needed only to run, not to compile (such as a JDBC driver).
testtestImplementationIt is available only to tests, never packaged.
(none)apiIn a Gradle library, it is also exposed to projects that use the library.

Version Conflicts

Sometimes two libraries need different versions of the same transitive dependency. Only one version can be on the class path, so the build tool must choose. Maven and Gradle choose differently.

Here is a real case. A project uses an older Guava (33.0.0) and the current Gson (2.15.0), and both depend on error_prone_annotations: Guava wants 2.23.0, Gson wants 2.50.0. The -Dverbose option shows what Maven dropped:

pom.xml (dependencies)
<dependencies>
  <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.0.0-jre</version>
  </dependency>
  <dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.15.0</version>
  </dependency>
</dependencies>
Terminal
mvn dependency:tree -Dverbose
Terminal output
[INFO] com.example.school:dep-demo:jar:1.0.0
[INFO] +- com.google.guava:guava:jar:33.0.0-jre:compile
[INFO] |  +- com.google.guava:failureaccess:jar:1.0.2:compile
[INFO] |  +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO] |  +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
[INFO] |  +- org.checkerframework:checker-qual:jar:3.41.0:compile
[INFO] |  +- com.google.errorprone:error_prone_annotations:jar:2.23.0:compile
[INFO] |  \- com.google.j2objc:j2objc-annotations:jar:2.8:compile
[INFO] \- com.google.code.gson:gson:jar:2.15.0:compile
[INFO]    \- (com.google.errorprone:error_prone_annotations:jar:2.50.0:compile - omitted for conflict with 2.23.0)

Maven kept the older 2.23.0. Gradle, given the same two libraries, picks the newer one:

Terminal
./gradlew dependencies --configuration runtimeClasspath
Terminal output
runtimeClasspath - Runtime classpath of source set 'main'.
+--- com.google.guava:guava:33.0.0-jre
|    +--- com.google.guava:failureaccess:1.0.2
|    +--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
|    +--- com.google.code.findbugs:jsr305:3.0.2
|    +--- org.checkerframework:checker-qual:3.41.0
|    \--- com.google.errorprone:error_prone_annotations:2.23.0 -> 2.50.0
\--- com.google.code.gson:gson:2.15.0
     \--- com.google.errorprone:error_prone_annotations:2.50.0

Explanation:

  1. Maven uses "nearest wins". The version closest to your project in the tree wins. Here both are at the same depth, so the first one declared wins: Guava is listed first, so its 2.23.0 is used, even though Gson was built against 2.50.0.
  2. Gradle uses "highest wins". It picks the newest requested version and shows the change as 2.23.0 -> 2.50.0.
  3. Neither rule is always right. Gson could fail at runtime with an old version (a NoSuchMethodError), and a library could break with a version newer than it was tested with. When it matters, decide yourself.

Taking Control of Versions

Maven has three tools for this, and Gradle has matching ones.

pom.xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>6.1.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    <dependency>
      <groupId>com.google.errorprone</groupId>
      <artifactId>error_prone_annotations</artifactId>
      <version>2.50.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>
 
<dependencies>
  <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.0.0-jre</version>
    <exclusions>
      <exclusion>
        <groupId>com.google.code.findbugs</groupId>
        <artifactId>jsr305</artifactId>
      </exclusion>
    </exclusions>
  </dependency>
  <dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.15.0</version>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
Terminal output
[INFO] com.example.school:dep-demo:jar:1.0.0
[INFO] +- com.google.guava:guava:jar:33.0.0-jre:compile
[INFO] |  +- com.google.guava:failureaccess:jar:1.0.2:compile
[INFO] |  +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO] |  +- org.checkerframework:checker-qual:jar:3.41.0:compile
[INFO] |  +- com.google.errorprone:error_prone_annotations:jar:2.50.0:compile
[INFO] |  \- com.google.j2objc:j2objc-annotations:jar:2.8:compile
[INFO] +- com.google.code.gson:gson:jar:2.15.0:compile
[INFO] \- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO]    +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO]    |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO]    |  +- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO]    |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO]    |  \- org.jspecify:jspecify:jar:1.0.0:test
[INFO]    +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO]    \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
[INFO]       \- org.junit.platform:junit-platform-engine:jar:6.1.3:test

Explanation:

  1. <dependencyManagement> pins a version without adding a dependency. Wherever error_prone_annotations appears in the tree, Maven now uses 2.50.0.
  2. A BOM (Bill of Materials) is a POM that lists versions for a whole family of libraries. Importing junit-bom means junit-jupiter needs no <version>, and every JUnit module is guaranteed to match.
  3. <exclusions> removes one transitive dependency. jsr305 has disappeared from under Guava.
NeedMavenGradle
Force one version<dependencyManagement>constraints { implementation("g:a:v") } or a version catalog
Import a BOM<scope>import</scope> in <dependencyManagement>implementation(platform("org.junit:junit-bom:6.1.3"))
Drop a transitive library<exclusions>exclude(group = "...", module = "...")
Share versions across modulesA parent POMgradle/libs.versions.toml (version catalog)

Keeping Dependencies Up to Date

Old libraries miss bug fixes and, more importantly, security fixes. The Versions plugin lists what is out of date:

Terminal
mvn versions:display-dependency-updates
Terminal output
[INFO] The following dependencies in Dependencies have newer versions:
[INFO]   com.google.guava:guava ...................... 33.0.0-jre -> 33.8.0-jre
[INFO]
[INFO] The following dependencies in Dependency Management have newer versions:
[INFO]   com.google.errorprone:error_prone_annotations ....... 2.50.0 -> 2.52.0
  • Semantic versioning: most libraries number releases MAJOR.MINOR.PATCH. A patch (2.15.0 to 2.15.1) fixes bugs, a minor version adds features compatibly, and a major version may break your code. Read the release notes before a major upgrade.
  • Automate the reminders: GitHub's Dependabot and Renovate open pull requests when new versions appear, and your tests show whether the upgrade is safe.
  • Scan for known vulnerabilities: OWASP Dependency-Check, Snyk or GitHub's alerts compare your libraries against public vulnerability lists. The 2021 Log4Shell flaw in Log4j 2 showed how one transitive library can put millions of applications at risk.

Common Mistakes

Copying JAR files into the project by hand. Nobody knows their versions or what they need. Always declare dependencies in the build file.

Using LATEST, version ranges or + in Gradle. The build changes on its own when a new version is released. Pin exact versions.

Ignoring a conflict because it compiles. Check the tree after every new library or upgrade.

Using a -SNAPSHOT version in a release. Snapshots are unfinished builds that change over time; releases must use fixed versions.

Giving everything the default compile scope. Test libraries and JDBC drivers then leak into places they don't belong.

Practice

  1. Easy: Run mvn dependency:tree on any Maven project, such as the fee-app project from the previous lesson, and count the direct and the transitive dependencies.
  2. Harder: Recreate the Guava and Gson conflict above. Swap the order of the two dependencies and run mvn dependency:tree -Dverbose again. Which version wins now, and why? Then fix the version with <dependencyManagement>.

Quick check: Dependency management

4 questions
1What is a transitive dependency?
2Two libraries at the same depth need different versions of a third. Which does Maven choose?
3What does importing a BOM do?
4Which Maven scope fits a JDBC driver that is loaded only at runtime?

Key Takeaways

Was this tutorial helpful?

Lesson 528 of 578