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 |
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)
}
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
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
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.
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.