Version Management

Semantic versioning, Gradle version catalogs, and a real strategy for keeping dependencies current

← Back to Index

Why Version Management Matters

A version number is a promise about compatibility. Trusting that promise — and managing it deliberately rather than reactively — is what makes a build reproducible: the same source checked out a year from now should still compile to the same behaviour, not silently pull in a different dependency graph. For the mechanics of how Maven actually resolves conflicting versions transitively, BOMs, and exclusions, see Dependencies Management — this page focuses on the versioning scheme itself and how to manage updates as a deliberate process rather than an accident.

Semantic Versioning (SemVer)

MAJOR.MINOR.PATCH is a contract, not just a counter — and the contract is only as good as the library maintainer's discipline in following it.

// 1 . 2 . 3
//   MAJOR.MINOR.PATCH

// MAJOR — incompatible API changes (a method removed, a signature changed,
//         a default behaviour flipped). Upgrading requires review.
// MINOR — new functionality, backward compatible. Existing code keeps working.
// PATCH — bug fixes only, backward compatible. Should always be safe.
Change Meaning Safe to upgrade blindly?
1.2.3 → 1.2.4 Patch — bug fix Generally yes
1.2.3 → 1.3.0 Minor — new feature Usually, but read the changelog
1.2.3 → 2.0.0 Major — breaking change No — review required, expect code changes
SemVer is a convention, not an enforced contract

Nothing stops a maintainer from shipping a breaking change in a minor or patch release — accidentally or otherwise. SemVer tells you the intended risk level, not a guarantee. For dependencies your production system relies on heavily, run your test suite after every upgrade regardless of how "safe" the version bump looks on paper.

Pre-release qualifiers and their ordering

1.0.0-alpha   // earliest, most unstable — internal testing
1.0.0-beta    // feature-complete, still finding bugs
1.0.0-RC1     // release candidate — believed ready, final verification
1.0.0         // final, stable release

// SemVer precedence: alpha < beta < rc < (no suffix = final)
// A SNAPSHOT sits conceptually before its corresponding release —
// 1.0.0-SNAPSHOT precedes 1.0.0 once it's actually released

SNAPSHOT vs Release — the Practical Difference

Aspect SNAPSHOT Release
Mutability Can be re-published under the same version Immutable once published — repository normally rejects re-deploy
Maven's default behaviour Re-checks the remote repo for updates regularly Downloaded once, cached forever (with -o offline mode safe)
Reproducibility Not guaranteed — same coordinate, different bytes over time Guaranteed
Use in production Never Always

The detailed CI/CD mechanics of publishing SNAPSHOT vs release artifacts, and why builds containing a SNAPSHOT typically fail a release pipeline gate, are covered in What is Maven?.

Gradle Version Catalogs — Centralised, Type-Safe Versions

Maven centralises versions with <properties>; Gradle's modern equivalent is the version catalog — a single TOML file shared across every module in a multi-project build, with IDE autocomplete and compile-time checking of the references themselves.

// gradle/libs.versions.toml — one file, the single source of truth
[versions]
spring-boot = "3.2.0"
jackson     = "2.16.0"
junit       = "5.10.0"

[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
jackson-databind        = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
junit-jupiter           = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }

[bundles]
# group related libraries under one name, declared together everywhere they're used
testing = ["junit-jupiter"]

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
// build.gradle.kts — reference via the generated type-safe accessor 'libs'
dependencies {
    implementation(libs.spring.boot.starter.web)   // IDE autocompletes this — typo = compile error, not a runtime surprise
    implementation(libs.jackson.databind)
    testImplementation(libs.bundles.testing)
}

plugins {
    alias(libs.plugins.spring.boot)
}
Why this beats a plain val/property approach

A Kotlin val springVersion = "6.1.0" at the top of a build.gradle.kts only helps within that single file. A version catalog is visible and IDE-autocompleted across every module in a multi-project build, and referencing a non-existent entry is a build script compile error — caught immediately, not discovered at dependency resolution time. For any multi-module Gradle project, the catalog should be the default, not an optional extra.

A Practical Strategy for Staying Current

"Update dependencies regularly" is advice nobody disagrees with and almost nobody operationalises. Here's what it looks like as an actual process.

# Step 1 — see what's available, on a schedule (weekly/monthly), not reactively
mvn versions:display-dependency-updates
# or for Gradle:
./gradlew dependencyUpdates  # (com.github.ben-manes.versions plugin)

# Step 2 — patch releases: batch-update, low risk, run full test suite
mvn versions:use-latest-releases -DallowMajorUpdates=false -DallowMinorUpdates=false

# Step 3 — minor releases: update one at a time, read changelogs, run test suite
# Step 4 — major releases: NEVER batch. One major upgrade per PR, dedicated review,
#           explicit migration notes, and a rollback plan before merging
Security updates are not optional, regardless of "stability"

A CVE patch released as a patch version still needs to go out even if it's inconvenient — unlike feature updates, security fixes shouldn't wait for a "convenient" release window. Tools like mvn org.owasp:dependency-check-maven:check or GitHub's Dependabot alerts should feed directly into this process as a higher-priority lane than routine version bumps.

Interview Questions

🎓 Junior level

Q: What do the three numbers in semantic versioning mean?
MAJOR.MINOR.PATCH. MAJOR increments for incompatible/breaking API changes. MINOR increments for new, backward-compatible functionality. PATCH increments for backward-compatible bug fixes. The version number is a signal of risk level for upgrading.

Q: Why should SNAPSHOT versions never reach production?
A SNAPSHOT coordinate can be republished with different content under the same version — the build is not reproducible. The exact bytes your production system runs could silently differ from what you tested, depending purely on when the artifact was resolved.

🔥 Senior level

Q: Why can SemVer still produce a broken build even when followed correctly?
SemVer is a promise about the library's own public API, but says nothing about transitive consistency across an entire dependency graph. A minor version bump in library A might transitively pull a newer minor version of a shared dependency that library B (unrelated, unchanged) is incompatible with at the binary level — this is exactly the scenario covered in Dependencies Management's discussion of "nearest definition wins". SemVer discipline reduces but does not eliminate this risk, which is why automated dependency update tools should always run the full test suite, never just trust the version number.

Q: What advantage does a Gradle version catalog have over Maven's property-based centralisation?
Both centralise the version string, but a Maven property is just text substitution — referencing ${nonexistent.version} silently resolves to a literal empty string rather than failing fast. A Gradle version catalog generates type-safe accessors (libs.spring.boot.starter.web) that are checked at build-script compile time — a typo or stale reference is a clear compile error in the IDE before you ever run a build. The catalog is also natively cross-module in a multi-project build without needing parent POM inheritance to share it.