2.3 Dependency Management
Modern Java applications depend on external libraries for everything from JSON parsing to database drivers. Managing these dependencies effectively—resolving versions, handling conflicts, and maintaining security—is critical for stable, maintainable projects.
The Dependency Problem
Consider a simple Maven project:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.0</version>
</dependency>
</dependencies>
What you don't see:
- Spring Boot depends on Spring Framework, which depends on other libraries
- Those libraries have their own dependencies
- You end up with 50+ transitive dependencies
Problems arise when:
- Two libraries depend on conflicting versions of the same library
- A security vulnerability is discovered in a transitive dependency
- You accidentally upgrade a dependency that breaks your code
Understanding Transitive Dependencies
Run Maven's dependency tree to see everything included:
mvn dependency:tree
Output (simplified):
com.example:myapp:jar:1.0.0
+- org.springframework.boot:spring-boot-starter-web:jar:3.2.0:compile
| +- org.springframework:spring-core:jar:6.1.0:compile
| | \- org.springframework:spring-jcl:jar:6.1.0:compile
| +- org.springframework:spring-web:jar:6.1.0:compile
...and 40+ more
Each dependency brings transitive dependencies, creating a complex graph.
Dependency Scopes
Maven provides scopes to control when dependencies are used:
| Scope | Compile | Runtime | Package | Usage |
|---|---|---|---|---|
| compile | ✓ | ✓ | ✓ | Normal libraries (default) |
| runtime | ✗ | ✓ | ✓ | Database drivers, logging implementations |
| provided | ✓ | ✗ | ✗ | Servlet API (provided by container) |
| test | ✓ | ✓ | ✗ | JUnit, Mockito (test only) |
| system | ✓ | ✓ | ✗ | Local JARs (avoid) |
Example:
<dependencies>
<!-- Compile-time, shipped with app -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.16.0</version>
<scope>compile</scope> <!-- default -->
</dependency>
<!-- Runtime only (included in JAR, not in source) -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.2.0</version>
<scope>runtime</scope>
</dependency>
<!-- Test only (not shipped) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Resolving Version Conflicts
When two libraries require different versions of the same dependency, Maven applies conflict resolution rules:
Rule: Nearest in Dependency Tree Wins
Your app (you choose the version)
├── Library A
│ └── Common Lib v1.0
└── Library B
└── Common Lib v2.0 ← Used if B is closer, OR if explicitly declared
Best Practices:
- Declare the version you want explicitly in your pom.xml
- Use a Bill of Materials (BOM) for consistent versions across related libraries
- Exclude transitive dependencies when you know you need a different version
Using Bill of Materials (BOM)
A BOM is a POM that specifies consistent versions for related libraries. Spring, Quarkus, and others provide BOMs:
<dependencyManagement>
<dependencies>
<!-- Import Spring Boot BOM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Version inherited from BOM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<!-- No version specified; comes from BOM -->
</dependency>
</dependencies>
Excluding Unwanted Transitive Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<!-- Exclude default logging, use custom provider -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Provide alternative -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
Version Pinning Strategies
1. Exact Pinning (Recommended for Production)
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.16.0</version> <!-- Exact version -->
</dependency>
Pros: Reproducible, predictable Cons: Manual updates needed
2. Patch-Level Range
Allow bug fixes but not new features:
<version>[2.16.0,2.17)</version> <!-- Any 2.16.x -->
Pros: Security patches automatic Cons: May break unexpectedly
3. Managed Dependencies (Maven)
Use dependencyManagement to centralize version declarations:
<properties>
<jackson.version>2.16.0</jackson.version>
<junit.version>5.10.0</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
Then reference without versions:
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
Update versions in one place:
<jackson.version>2.17.0</jackson.version> <!-- Single update propagates everywhere -->
Security: Managing Vulnerable Dependencies
Scanning for Vulnerabilities
Maven:
mvn org.owasp:dependency-check-maven:check
Gradle:
gradle dependencyCheckAnalyze
Configure in pom.xml:
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>8.4.0</version>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS> <!-- Fail if severity >= 7 -->
</configuration>
</plugin>
Fixing Vulnerabilities
When a vulnerability is discovered:
Update immediately:
<version>2.16.1</version> <!-- Patch version with fix -->Override transitive versions if needed:
<dependencyManagement> <dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-text</artifactId> <version>1.11.0</version> <!-- Override to patched version --> </dependency> </dependencies> </dependencyManagement>Exclude and replace if upstream won't update:
<dependency> <groupId>vulnerable.lib</groupId> <artifactId>old-lib</artifactId> <exclusions> <exclusion> <groupId>commons-text</groupId> <artifactId>commons-text</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-text</artifactId> <version>1.11.0</version> <!-- Non-vulnerable version --> </dependency>
Updating Dependencies Regularly
Monthly Dependency Review
Create a schedule to check for updates:
# Maven
mvn versions:display-dependency-updates
mvn versions:display-plugin-updates
# Gradle
gradle dependencyUpdates
Example Maven output:
[INFO]
[INFO] The following dependencies have newer versions:
[INFO] com.fasterxml.jackson.core:jackson-databind ........... 2.16.0 -> 2.17.0
[INFO] org.junit.jupiter:junit-jupiter ...................... 5.10.0 -> 5.10.1
[INFO] org.springframework.boot:spring-boot-starter-web ...... 3.2.0 -> 3.2.1
Testing After Updates
Always test after updating dependencies:
mvn clean package # Recompile
mvn test # Run all tests
mvn integration-test # Run integration tests
# Manual testing of affected features
Lock Files (Gradle)
Gradle provides lock files for reproducible builds:
# Generate lock file
gradle dependencies --write-locks
# Commit lock file to version control
git add gradle.lockfile
Lock files ensure everyone on the team uses identical versions across all dependency trees.
Dependency Updates in CI/CD
Automate dependency updates with CI/CD:
GitHub Actions Example:
name: Dependency Updates
on:
schedule:
- cron: '0 0 * * 0' # Weekly
jobs:
update-deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: 25
- run: mvn versions:update-parent-versions versions:update-properties
- uses: dawidd6/action-send-mail@v3
with:
subject: 'Dependency Updates Available'
body: 'Review and merge dependency updates'
to: 'team@example.com'
Best Practices
- Pin Major Versions: Never use
latestor unbounded ranges - Review Changelogs: Before updating, check what changed
- Test Thoroughly: Run full test suite after updates
- Document Dependencies: Add comments explaining why libraries are needed
- Reduce Scope: Use
testandruntimescopes appropriately - Monitor Security: Run vulnerability scans in CI/CD
- Regular Updates: Don't let dependencies grow stale (patch versions monthly, major versions quarterly)
- Use BOMs: For consistency across related libraries
Conclusion
Dependency management is not glamorous, but it's critical. A well-managed dependency tree:
- Reduces vulnerabilities and security risk
- Prevents version conflicts
- Ensures builds are reproducible
- Makes upgrades predictable and testable
Invest time in understanding transitive dependencies, scanning for vulnerabilities, and maintaining a disciplined update schedule. Your future self will thank you.
Next section: Creating distributable artifacts—JARs, WAR files, and deploying to different environments.