BOMs and Dependency Management
Use dependencyManagement, BOMs and Gradle platforms to keep library families on matching versions and patch vulnerable ones in one place.
<dependencyManagement><dependencies><dependency>
<groupId>org.junit</groupId><artifactId>junit-bom</artifactId><version>6.0.3</version>
<type>pom</type><scope>import</scope>
</dependency></dependencies></dependencyManagement>Many libraries ship as families of modules that must line up: Jackson's streaming core and databind, JUnit's API and engine, Netty's dozen JARs. Mixing versions inside a family compiles fine and then fails at run time with NoSuchMethodError. Dependency management fixes the versions in one place so the family always lines up.
dependencyManagement
In Maven, <dependencyManagement> sets versions (and optionally scopes and exclusions) without adding anything to the project. A dependency declared later without a <version> takes the managed one. Managed versions also apply to transitive dependencies, which is how you override a vulnerable version that arrives three levels down. Put it in a parent POM and every module agrees.
Bills of materials
A BOM is a POM with pom packaging whose job is its <dependencyManagement> section. Import it with <type>pom</type> and <scope>import</scope> and its versions become yours. JUnit, Jackson, Testcontainers, Netty and Spring Boot all publish one. Jackson 3 publishes tools.jackson:jackson-bom, and its modules use the tools.jackson group IDs; Jackson 2 is still maintained (2.21.x) under com.fasterxml.jackson, with its own BOM. The annotations stay in com.fasterxml.jackson.core:jackson-annotations for both, so let the BOM pick that version too. The library authors have tested those combinations, which is more than you can say for versions picked one at a time.
Precedence rules, in order: a version you write directly in your own <dependencyManagement> beats an imported one; between two imported BOMs that manage the same artifact, the one imported first wins. So when two BOMs disagree, reorder them or pin the artifact yourself.
Spring Boot
spring-boot-starter-parent inherits from spring-boot-dependencies, a BOM for several hundred libraries, and exposes each version as a property. With the parent you override one by setting the property, for example <postgresql.version>. If your company already has its own parent POM, import spring-boot-dependencies instead; properties no longer override anything then, so add the artifact to your own <dependencyManagement> above the import.
Gradle platforms
Gradle imports a BOM with platform(...). A platform adds version constraints that take part in normal conflict resolution, where the highest version wins, so a transitive dependency can still lift a version above the BOM's. enforcedPlatform(...) forces the BOM's versions, even downwards, which is safer for an application but should never be used in a published library, because it forces consumers too. For a single library, a constraints { } block with a because(...) reason records why the version is pinned.
Keeping it current
Upgrading a BOM version upgrades the whole family in one line, which is what you want when a CVE lands. mvn versions:display-dependency-updates lists newer releases; automated update tools work best when versions live in BOMs and properties rather than scattered across modules.
Example
<dependencyManagement>
<dependencies>
<!-- Our own pin beats every imported BOM -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.4</version>
</dependency>
<!-- BOMs: versions only, nothing is added to the build yet -->
<dependency>
<groupId>tools.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>3.1.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- No versions: the BOMs supply matching ones -->
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>When to use it
- Patch a vulnerable transitive YAML parser across a service by pinning it once in dependencyManagement.
- Keep every Jackson module in an API on one version so serialisation features do not fail at run time.
- Use Spring Boot's tested library versions in a company that requires its own parent POM.
More examples
Platforms and constraints in Gradle
The platform keeps the Jackson modules aligned, and the constraint lifts a transitive library without declaring it as a direct dependency. The because text shows up in dependencyInsight output, so the next person knows why the pin exists.
dependencies {
// Import a BOM as a platform: versions become constraints
implementation(platform("tools.jackson:jackson-bom:3.1.5"))
implementation("tools.jackson.core:jackson-core")
implementation("tools.jackson.core:jackson-databind")
testImplementation(platform("org.junit:junit-bom:6.0.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
constraints {
implementation("org.yaml:snakeyaml:2.4") {
because("older versions arrive transitively and have known vulnerabilities")
}
}
}Spring Boot's BOM without its parent
Teams with a company parent POM still get Boot's tested versions by importing its BOM. Setting a postgresql.version property would do nothing here, because properties only steer versions in the POM that defines them.
<parent>
<groupId>com.example.platform</groupId>
<artifactId>company-parent</artifactId>
<version>12</version>
</parent>
<dependencyManagement>
<dependencies>
<!-- Override first: properties cannot override an imported BOM -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.13</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>4.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>Checking which version won
The effective POM shows what is managed, and the dependency tree shows what was resolved, and the two can differ when an exclusion or a direct version gets in the way. dependencyInsight prints every constraint that took part, including the because text.
# The managed versions after all parents and imports are merged
./mvnw help:effective-pom -Doutput=effective-pom.xml
grep -A2 '<artifactId>snakeyaml</artifactId>' effective-pom.xml
# What actually resolved, and newer releases available
./mvnw dependency:tree -Dincludes=org.yaml
./mvnw versions:display-dependency-updates
# Gradle: the constraint, the platform and the reason for the result
./gradlew dependencyInsight --dependency snakeyaml --configuration runtimeClasspath
Discussion