Version Management

Managing dependency versions effectively in Maven and Gradle

← Back to Index

Why Version Management Matters

Think of versions like recipe ingredients:

Version management ensures:

  • Reproducible builds: Same code + same versions = same result
  • Security: Track and update vulnerable dependencies
  • Compatibility: Avoid conflicts between libraries
  • Maintainability: Centralized version updates

Semantic Versioning (SemVer)

// Version format: MAJOR.MINOR.PATCH
//                   1   . 2   . 3

// Examples:
// 1.0.0  - First stable release
// 1.0.1  - Bug fix (PATCH)
// 1.1.0  - New feature, backward compatible (MINOR)
// 2.0.0  - Breaking changes (MAJOR)

// When to increment:
// MAJOR - Incompatible API changes (breaking)
//         Example: Removed method, changed signature
// MINOR - Added functionality (backward compatible)
//         Example: New method, new feature
// PATCH - Bug fixes (backward compatible)
//         Example: Fixed null pointer, performance fix
Version Change Meaning Safe to Upgrade?
1.2.3 → 1.2.4 Bug fix Yes, always
1.2.3 → 1.3.0 New features Yes, usually
1.2.3 → 2.0.0 Breaking changes Review required

SNAPSHOT vs Release Versions

<!-- SNAPSHOT - Development version -->
<version>1.0.0-SNAPSHOT</version>

<!-- Release - Stable, immutable version -->
<version>1.0.0</version>
Aspect SNAPSHOT Release
Purpose Development, testing Production use
Mutability Can change anytime Immutable (never changes)
Maven behavior Checks for updates daily Downloads once, cached forever
Reproducibility Not guaranteed Guaranteed
In production? Never! Always
Warning

Never deploy SNAPSHOT versions to production! They can change without notice, making builds unreproducible and potentially introducing bugs.

Other Version Suffixes

<!-- Common version qualifiers -->
1.0.0-SNAPSHOT     <!-- Development -->
1.0.0-alpha        <!-- Early testing, unstable -->
1.0.0-beta         <!-- Feature complete, testing -->
1.0.0-RC1          <!-- Release Candidate 1 -->
1.0.0-M1           <!-- Milestone 1 -->
1.0.0              <!-- Final release -->
1.0.0-GA           <!-- General Availability (same as final) -->

<!-- Version ordering (lowest to highest): -->
<!-- alpha < beta < milestone < rc < snapshot < final -->

Centralizing Versions with Properties

<project>
    <properties>
        <!-- Define versions in one place -->
        <spring.version>6.1.0</spring.version>
        <jackson.version>2.16.0</jackson.version>
        <junit.version>5.10.0</junit.version>
        <lombok.version>1.18.30</lombok.version>
    </properties>

    <dependencies>
        <!-- Use property references -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-core</artifactId>
            <version>${spring.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
            <version>${spring.version}</version>  <!-- Same version -->
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>${jackson.version}</version>
        </dependency>
    </dependencies>
</project>
Benefits
  • Update version in one place, applies everywhere
  • Ensures related libraries use matching versions
  • Easy to review all versions at a glance

Bill of Materials (BOM)

A BOM is a special POM that defines a set of compatible dependency versions.

<!-- Import a BOM to manage versions automatically -->
<dependencyManagement>
    <dependencies>
        <!-- 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>

        <!-- Jackson BOM -->
        <dependency>
            <groupId>com.fasterxml.jackson</groupId>
            <artifactId>jackson-bom</artifactId>
            <version>2.16.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<!-- Now you can omit versions! -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <!-- Version managed by BOM -->
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <!-- Version managed by Jackson BOM -->
    </dependency>
</dependencies>

Popular BOMs

BOM What It Manages
spring-boot-dependencies All Spring Boot compatible libraries
spring-framework-bom Spring Framework modules
jackson-bom Jackson JSON library modules
junit-bom JUnit 5 modules
aws-sdk-java-pom AWS SDK modules

Version Ranges (Use Carefully!)

<!-- Maven version range syntax -->

<!-- Exact version -->
<version>1.0</version>           <!-- 1.0 (soft requirement) -->

<!-- Ranges -->
<version>[1.0]</version>         <!-- Exactly 1.0 (hard requirement) -->
<version>[1.0,2.0)</version>     <!-- 1.0 <= x < 2.0 -->
<version>[1.0,2.0]</version>     <!-- 1.0 <= x <= 2.0 -->
<version>(1.0,2.0)</version>     <!-- 1.0 < x < 2.0 -->
<version>[1.0,)</version>        <!-- x >= 1.0 -->
<version>(,2.0]</version>        <!-- x <= 2.0 -->

<!-- Example: Accept any 1.x version -->
<dependency>
    <groupId>com.example</groupId>
    <artifactId>my-lib</artifactId>
    <version>[1.0,2.0)</version>
</dependency>
Avoid Version Ranges

Version ranges make builds non-reproducible. A build today might use 1.5, tomorrow 1.6. This can introduce unexpected bugs. Use exact versions instead.

Resolving Version Conflicts

# When two dependencies need different versions of the same library

# Check your dependency tree
mvn dependency:tree

# Example output showing conflict:
# [INFO] com.example:my-app:jar:1.0.0
# [INFO] +- org.springframework:spring-web:jar:6.1.0:compile
# [INFO] |  \- org.springframework:spring-core:jar:6.1.0:compile
# [INFO] \- some-library:some-lib:jar:1.0:compile
# [INFO]    \- org.springframework:spring-core:jar:5.3.0:compile (conflict!)

Solution 1: Exclude Transitive Dependency

<dependency>
    <groupId>some-library</groupId>
    <artifactId>some-lib</artifactId>
    <version>1.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.springframework</groupId>
            <artifactId>spring-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>

Solution 2: Force Version in dependencyManagement

<dependencyManagement>
    <dependencies>
        <!-- This version wins for ALL transitive dependencies -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-core</artifactId>
            <version>6.1.0</version>
        </dependency>
    </dependencies>
</dependencyManagement>

Maven's Conflict Resolution Rules

  1. Nearest definition wins: Closer to your project in the dependency tree
  2. First declaration wins: If same depth, first in POM order
  3. dependencyManagement overrides all: Versions declared here always win

Checking for Updates

# Check for newer versions of dependencies
mvn versions:display-dependency-updates

# Output:
# [INFO] The following dependencies have newer versions:
# [INFO]   org.springframework:spring-core .......... 6.0.0 -> 6.1.0
# [INFO]   com.fasterxml.jackson.core:jackson-core . 2.15.0 -> 2.16.0

# Check for plugin updates
mvn versions:display-plugin-updates

# Update versions in pom.xml (interactive)
mvn versions:use-latest-versions

# Update to latest release versions
mvn versions:use-latest-releases

# Update properties that define versions
mvn versions:update-properties

Version Management in Gradle

// build.gradle.kts (Kotlin DSL)

// Define versions in one place
val springVersion = "6.1.0"
val jacksonVersion = "2.16.0"

dependencies {
    implementation("org.springframework:spring-core:$springVersion")
    implementation("org.springframework:spring-web:$springVersion")
    implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
}

// Or use a BOM (platform)
dependencies {
    implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0"))
    implementation("org.springframework.boot:spring-boot-starter-web")  // No version!
}

// Version catalogs (recommended for larger projects)
// gradle/libs.versions.toml
// [versions]
// spring = "6.1.0"
// jackson = "2.16.0"
// [libraries]
// spring-core = { module = "org.springframework:spring-core", version.ref = "spring" }

Best Practices

DO:

  • Use exact versions - Reproducible builds
  • Centralize versions - Properties or BOMs
  • Use BOMs - For compatible library sets
  • Review updates regularly - Security patches
  • Test after upgrades - Even minor versions
  • Document major version changes - Migration notes
  • Use dependencyManagement - Control transitive versions

DON'T:

  • Don't use SNAPSHOT in production - Unreliable
  • Don't use version ranges - Non-reproducible
  • Don't mix major versions - spring-core 5 + spring-web 6
  • Don't ignore security updates - CVE vulnerabilities
  • Don't upgrade everything at once - Incremental upgrades

Summary

  • Semantic Versioning: MAJOR.MINOR.PATCH for clear meaning
  • SNAPSHOT: Development only, never production
  • Properties: Centralize versions in one place
  • BOM: Pre-tested compatible version sets
  • Conflicts: Use exclusions or dependencyManagement
  • Updates: Regular reviews with versions:display-dependency-updates
  • Reproducibility: Exact versions ensure consistent builds