Dependency Management in Java
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:
| Part | Example | Meaning |
|---|---|---|
| groupId | com.google.code.gson | The organization or project, usually a reversed domain |
| artifactId | gson | The library's name |
| version | 2.15.0 | The 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.
mvn dependency:tree[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:
- 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. - The last word on each line is the scope. Everything under a
testdependency is alsotest, so none of JUnit or Mockito ends up in the application. logback-classicneedsslf4j-apitoo, 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 scope | Gradle configuration | Description |
|---|---|---|
compile (default) | implementation | It is used for libraries needed to compile and run the main code. |
provided | compileOnly | It is needed to compile, but the server provides it at runtime (such as the Servlet API). |
runtime | runtimeOnly | It is needed only to run, not to compile (such as a JDBC driver). |
test | testImplementation | It is available only to tests, never packaged. |
| (none) | api | In 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:
<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>mvn dependency:tree -Dverbose[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:
./gradlew dependencies --configuration runtimeClasspathruntimeClasspath - 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.0Explanation:
- 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.
- Gradle uses "highest wins". It picks the newest requested version and shows the change as
2.23.0 -> 2.50.0. - 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.
<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>[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:testExplanation:
<dependencyManagement>pins a version without adding a dependency. Wherevererror_prone_annotationsappears in the tree, Maven now uses 2.50.0.- A BOM (Bill of Materials) is a POM that lists versions for a whole family of libraries. Importing
junit-bommeansjunit-jupiterneeds no<version>, and every JUnit module is guaranteed to match. <exclusions>removes one transitive dependency.jsr305has disappeared from under Guava.
| Need | Maven | Gradle |
|---|---|---|
| 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 modules | A parent POM | gradle/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:
mvn versions:display-dependency-updates[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
- Easy: Run
mvn dependency:treeon any Maven project, such as thefee-appproject from the previous lesson, and count the direct and the transitive dependencies. - Harder: Recreate the Guava and Gson conflict above. Swap the order of the two dependencies and run
mvn dependency:tree -Dverboseagain. Which version wins now, and why? Then fix the version with<dependencyManagement>.
Key Takeaways
Was this tutorial helpful?