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:

  1. Declare the version you want explicitly in your pom.xml
  2. Use a Bill of Materials (BOM) for consistent versions across related libraries
  3. 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

<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:

  1. Update immediately:

    <version>2.16.1</version>  <!-- Patch version with fix -->
    
  2. 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>
    
  3. 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

  1. Pin Major Versions: Never use latest or unbounded ranges
  2. Review Changelogs: Before updating, check what changed
  3. Test Thoroughly: Run full test suite after updates
  4. Document Dependencies: Add comments explaining why libraries are needed
  5. Reduce Scope: Use test and runtime scopes appropriately
  6. Monitor Security: Run vulnerability scans in CI/CD
  7. Regular Updates: Don't let dependencies grow stale (patch versions monthly, major versions quarterly)
  8. 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.