BOMs and Dependency Management

Use dependencyManagement, BOMs and Gradle platforms to keep library families on matching versions and patch vulnerable ones in one place.

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

Example · xml
<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.

Example · kotlin
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.

Example · xml
<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.

Example · bash
# 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

  • Be the first to comment on this lesson.