Creation and Usage of BOM in Gradle
Tags: jarhell, bom, java, gradle
Published: 2024-01-07 08:40:24

Hello there!

In every company (and if it's a large one, most likely in each department), a culture of using BOM ([bill of materials](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html#bill-of-materials-bom-poms)) should be established for managing dependency versions.

In this article, I want to share my vision of how this can be organized, as well as explore more complex cases of creating and using BOM in Gradle projects.

## Why do we need BOM at all?

In the development of enterprise applications/microservices, modern frameworks and third-party libraries are usually employed. The number of dependencies, even in a simple web application, is very large. These include direct dependencies explicitly added to the project and transitive dependencies – those required by direct dependencies and implicitly added to the classpath.

It happens that two libraries, for example, X and Y, require different versions of the same transitive dependency Z (snakeyaml, Google Guava, Apache Commons, etc.). This situation is called a conflict (or [jar hell](https://dzone.com/articles/what-is-jar-hell) when it occurs on a large scale). More details about this and conflict resolution strategies can be found [in documentation](https://docs.gradle.org/current/userguide/dependency_resolution.html).
Since the examples below will be for Gradle, it's important to note that Gradle generally uses [the latest strategy](https://docs.gradle.org/current/userguide/dependency_resolution.html#sec:version-conflict), meaning the dependency with the higher version is selected. While we can intervene in the dependency conflict resolution process, we'll discuss that later.

So, let's say we encounter a dependency conflict: X brings version 1.3 of Z, and Y brings version 2.4 of Z. Gradle will automatically resolve it in favor of the newer version – Z 2.4. If different versions of dependency Z are binary-compatible with each other, there won't be any issues; otherwise, we will get an error in runtime.

Essentially, there's only one good way to fix the problem – select versions of X and Y that won't conflict with each other. The keyword here is "**_select_**". This is a non-trivial operation, typically requiring a lot of effort and time. This is where BOM files come to the rescue.

## What does a BOM look like?

BOM is a specially crafted [POM file](https://maven.apache.org/guides/introduction/introduction-to-the-pom.html), containing a list of dependencies with their versions that are guaranteed not to conflict with each other.

The most crucial part of a BOM file is the `dependencyManagement` section (the full example is available [here](https://repo1.maven.org/maven2/io/github/mfvanek/pg-index-health-bom/0.10.2/pg-index-health-bom-0.10.2.pom)):

```xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    …
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>io.github.mfvanek</groupId>
                <artifactId>pg-index-health-model</artifactId>
                <version>0.10.2</version>
            </dependency>
            <dependency>
                <groupId>io.github.mfvanek</groupId>
                <artifactId>pg-index-health</artifactId>
                <version>0.10.2</version>
            </dependency>
            <dependency>
                <groupId>io.github.mfvanek</groupId>
                <artifactId>pg-index-health-jdbc-connection</artifactId>
                <version>0.10.2</version>
            </dependency>
            <dependency>
                <groupId>io.github.mfvanek</groupId>
                <artifactId>pg-index-health-generator</artifactId>
                <version>0.10.2</version>
            </dependency>
            <dependency>
                <groupId>io.github.mfvanek</groupId>
                <artifactId>pg-index-health-testing</artifactId>
                <version>0.10.2</version>
            </dependency>
            <dependency>
                <groupId>io.github.mfvanek</groupId>
                <artifactId>pg-index-health-test-starter</artifactId>
                <version>0.10.2</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>
```

We can include a BOM file in the project as follows:

```kotlin
dependencies {
    // Attaching the BOM
    implementation(platform("io.github.mfvanek:pg-index-health-bom:0.10.2"))

    // Using dependencies controlled by BOM
    implementation("io.github.mfvanek:pg-index-health")
    implementation("io.github.mfvanek:pg-index-health-generator")
    implementation("io.github.mfvanek:pg-index-health-testing")
}
```

After this, we no longer need to specify versions for all dependencies controlled by the BOM.

All major frameworks and multi-module projects publish their own BOMs.

## How to create your own BOM using Gradle?

Creating a BOM in Gradle is quite simple: we need the `java-platform` plugin for this. In the `dependencies `section, within the `constraints `block, list the required dependencies with specific versions, and we're done (the full example is [here](https://github.com/mfvanek/pg-index-health-test-starter/blob/master/pg-index-health-bom/build.gradle.kts)):

```kotlin
plugins {
    id("java-platform")
    id("maven-publish")
    id("signing")
}

description = "pg-index-health library BOM"

dependencies {
    constraints {
        api(libs.pg.index.health.model)
        api(libs.pg.index.health.core)
        api(libs.pg.index.health.jdbcConnection)
        api(libs.pg.index.health.generator)
        api(libs.pg.index.health.testing)
        api(project(":pg-index-health-test-starter"))
    }
}

publishing {
…
}

signing {
…
}
```

We can include one BOM in another, thus creating a more versatile and convenient solution. A prominent example of such an aggregate is [spring-boot-dependencies](https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/2.7.18/spring-boot-dependencies-2.7.18.pom).

In Gradle, to achieve this, we need to specify the `allowDependencies` option (the full example is [here](https://github.com/mfvanek/java-experiments/blob/master/internal-bom/build.gradle.kts)):

```kotlin
plugins {
    id("java-platform")
    …
}

description = "Example of BOM for internal usage"

javaPlatform {
    allowDependencies()
}

dependencies {
    api(platform("org.junit:junit-bom:5.10.1"))
    api(platform("org.testcontainers:testcontainers-bom:1.19.3"))
    api(platform("io.github.mfvanek:pg-index-health-bom:0.10.2"))
    api(platform("org.mockito:mockito-bom:5.8.0"))

    constraints {
        api("com.google.code.findbugs:jsr305:3.0.2")
        api("org.postgresql:postgresql:42.7.1")
        api("com.zaxxer:HikariCP:5.1.0")
        api("ch.qos.logback:logback-classic:1.4.14")
        api("org.slf4j:slf4j-api:2.0.10")
        api("org.assertj:assertj-core:3.25.0")
        api("com.h2database:h2:2.2.224")
        api("javax.annotation:javax.annotation-api:1.3.2")
        api("org.threeten:threeten-extra:1.7.2")
        api("io.netty:netty-all:4.1.104.Final")
    }
}
```

## When to create your own BOM?

If your Java/Kotlin library or Spring Boot starter has two or more artifacts, create a BOM for them. Always. Consider this as a best practice.

The BOM will act as a kind of facade, allowing you to conceal from external users the total number of your artifacts (important note: only from the perspective of managing dependency versions) and simplify the creation of composite BOMs.

If you are working in an environment with a large number of developers/teams, separate external and internal dependencies. External dependencies are what we fetch from Maven Central. Internal dependencies are the artifacts we develop and consume within our own company.

All external dependencies, whenever possible, should be consumed in the form of BOMs. A BOM is a kind of minimal distribution unit. If a library doesn't have a BOM, we include it as is. It's crucial to maximize the reuse of existing BOMs, minimizing your own work.
In the end, a BOM with external dependencies will look something like this:

```kotlin
dependencies {
    api(platform("org.springframework.cloud:spring-cloud-dependencies:2021.0.9"))
    api(platform("org.springframework.cloud:spring-cloud-sleuth-otel-dependencies:1.1.4"))
    api(platform("org.springframework.boot:spring-boot-dependencies:2.7.18"))
    api(platform("org.springframework.security:spring-security-bom:5.8.9"))

    api(platform("org.testcontainers:testcontainers-bom:1.19.3"))

    api(platform("net.javacrumbs.shedlock:shedlock-bom:4.46.0"))
    api(platform("org.springdoc:springdoc-openapi:1.7.0"))
    api(platform("io.sentry:sentry-bom:5.7.4"))

    constraints {
        api("commons-io:commons-io:2.11.0")
        api("org.apache.commons:commons-text:1.10.0")
        api("com.zaxxer:HikariCP:5.1.0")
        api("com.vladmihalcea:hibernate-types-52:2.21.1")
        api("org.postgresql:postgresql:42.7.1")
        api("net.ttddyy:datasource-proxy:1.9")
    }
}
```

Do not aim to include absolutely every library used within the company in a BOM with external dependencies. Firstly, it's unlikely that you will succeed, and secondly, it lacks practical sense. The BOM should contain 90%-95% of widely used artifacts. Anything else, specific teams include in their projects independently (and manage compatibility themselves).

A BOM with internal dependencies can (and should) include a BOM with external dependencies. This is convenient for end users.

## What is Rich Model and Gradle Module Metadata?

If you build and publish a BOM using Gradle, upon inspecting the binary repository, you may find an additional JSON-formatted file with the extension `.module`. An example of such a file is [pg-index-health-bom-0.10.2.module](https://repo1.maven.org/maven2/io/github/mfvanek/pg-index-health-bom/0.10.2/pg-index-health-bom-0.10.2.module). This is the so-called [module metadata](https://docs.gradle.org/current/userguide/publishing_gradle_module_metadata.html) – enhanced information that Gradle publishes for more in-depth and flexible dependency management.

Let's take a look at the BOM `spring-boot-dependencies` version 2.7.18. It includes `snakeyaml` version 1.30, which has too many [vulnerabilities](https://mvnrepository.com/artifact/org.yaml/snakeyaml/1.30).

We want to upgrade the `snakeyaml` version to 1.33 for all projects and create an **internal-spring-boot-2-bom** for this purpose:

```kotlin
dependencies {
    api(platform("org.springframework.boot:spring-boot-dependencies:2.7.18"))

    constraints {
        api("org.yaml:snakeyaml:1.33")
    }
}
```

For this BOM, Gradle will generate metadata:
```json
"dependencyConstraints": [
    {
        "group": "org.yaml",
        "module": "snakeyaml",
        "version": {
            "requires": "1.33"
        }
    }
]
```

Let's add this BOM to the application:

```kotlin
dependencies {
    implementation(platform(project(":internal-spring-boot-2-bom")))
    implementation("org.springframework.boot:spring-boot-starter-web")
}
```

And execute the command:
```bash
./gradlew dependencyInsight --dependency org.yaml:snakeyaml
```

At the output, we get approximately the following result:
```bash
org.yaml:snakeyaml:1.33
   Selection reasons:
      - By constraint
      - By conflict resolution: between versions 1.33 and 1.30
```

The higher version 1.33 easily overrides the version 1.30 from `spring-boot-dependencies`.

Now let's add a dependency to the project:

```kotlin
implementation("io.swagger.core.v3:swagger-core:2.2.20")
```

`swagger-core` brings in version 2.2 of `snakeyaml`, which is incompatible with both 1.30 and 1.33, likely breaking your application. `swagger-core` may end up on your classpath implicitly through `springdoc` or another dependency.

```bash
./gradlew dependencyInsight --dependency org.yaml:snakeyaml

org.yaml:snakeyaml:2.2
   Selection reasons:
      - By constraint
      - By conflict resolution: between versions 2.2, 1.33, and 1.30
```

There are at least three ways to solve this problem. The simplest one is to exclude the conflicting dependency:

```kotlin
implementation("io.swagger.core.v3:swagger-core:2.2.20") {
    exclude(group = "org.yaml", module = "snakeyaml")
}
```

This option is not always applicable and does not scale well for a large number of applications/microservices.

Another approach is to use `enforcedPlatform` when importing the BOM:

```kotlin
implementation(enforcedPlatform(project(":internal-spring-boot-2-bom")))
```

```bash
./gradlew dependencyInsight --dependency org.yaml:snakeyaml

org.yaml:snakeyaml:1.33
   Selection reasons:
      - By constraint
      - Forced
```

While this solution works, it can be compared to driving a nail with a sledgehammer and does not scale well either.

The third (and most preferable) option involves using [rich versions](https://docs.gradle.org/current/userguide/rich_versions.html#rich-version-constraints):

```kotlin
dependencies {
    api(platform("org.springframework.boot:spring-boot-dependencies:2.7.18"))

    constraints {
        api("org.yaml:snakeyaml") {
            version {
                strictly("1.33")
            }
        }
    }
}
```

In both cases, the final POM file will look the same, but this time Gradle will generate different metadata:

```json
"dependencyConstraints": [
    {
        "group": "org.yaml",
        "module": "snakeyaml",
        "version": {
            "strictly": "1.33",
            "requires": "1.33"
        }
    }
]
```

Using `strictly` allows us to firmly fix the version and does not require modifications in the applied projects/services:

```bash
./gradlew dependencyInsight --dependency org.yaml:snakeyaml

org.yaml:snakeyaml:1.33
   Selection reasons:
      - By constraint
      - By ancestor
```

## How do several BOMs fit together?

As we saw earlier, overriding the version of a dependency managed by a BOM is relatively straightforward. But what if your project includes multiple BOMs, each bringing in different versions of the same dependency?

In Gradle, generally, the higher version will be chosen. In Maven, the behavior is [different](https://www.baeldung.com/spring-maven-bom): it will select the version from the first declared BOM file.

Sometimes, the order of including BOMs matters even in Gradle, for example, if you are using Spring Boot and the `io.spring.dependency-management` plugin.

Let's consider an example:

```kotlin
plugins {
    id("java")
    id("org.springframework.boot") version "3.2.1"
    id("io.spring.dependency-management") version "1.1.4"
}

dependencyManagement {
    imports {
        mavenBom("org.springdoc:springdoc-openapi:2.2.0")
        // mavenBom("org.springframework.boot:spring-boot-dependencies:3.2.1") // because of springdoc-openapi
        mavenBom("org.testcontainers:testcontainers-bom:1.19.3")
        mavenBom("org.junit:junit-bom:5.10.1")
    }
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
```

If you uncomment the line with the import of the `spring-boot-dependencies` BOM, the project will build; otherwise, there will be a context initialization error.

The [issue](https://github.com/springdoc/springdoc-openapi/issues/2440) is that the `springdoc-openapi` BOM brings an old version of the Spring Framework 6.0, which is incompatible with Spring Boot 3.2. There are several ways to solve this problem: update springdoc, change the order of BOM imports, but the best, in my opinion, is to avoid using the `io.spring.dependency-management` plugin.

## Managing Gradle plugin versions through BOM

In addition to regular dependencies, we can include Gradle plugins in a BOM and control their versions. If you are using Spring Boot, you are probably familiar with the `org.springframework.boot` plugin. Typically, its version aligns with the `spring-boot-dependencies` version, and it makes sense to deliver them together:

```kotlin
plugins {
    id("java-platform")
}

description = "Spring Boot 3 cumulative BOM"

javaPlatform {
    allowDependencies()
}

dependencies {
    val spring3Version = "3.2.1"
    api(platform("org.springframework.boot:spring-boot-dependencies:$spring3Version"))

    constraints {
        api("org.springframework.boot:spring-boot-gradle-plugin:$spring3Version")
    }
}
```

To use a BOM for managing the plugin version, you need to create a `buildSrc` directory in the target project/application with a `build.gradle.kts` file and include the BOM there:

```kotlin
// buildSrc/build.gradle.kts

plugins {
    `kotlin-dsl`
}

repositories {
    mavenLocal()
    gradlePluginPortal()
}

dependencies {
    implementation(platform("io.github.mfvanek:internal-spring-boot-3-bom:0.1.1")) // Include the BOM
    implementation("org.springframework.boot:spring-boot-gradle-plugin") // Include the required plugin. Version is taken from the BOM
}
```

In the actual application, you can use the plugin as usual, without specifying the version:

```kotlin
// In the application

plugins {
    id("java")
    id("org.springframework.boot")
}
```

Example application for experimentation can be found [here](https://github.com/mfvanek/spring-docker-demo/pull/90/files).

## Repository Links

- [Example BOM for my open-source project](https://github.com/mfvanek/pg-index-health-test-starter/tree/master/pg-index-health-bom)
- [Large multi-module project with several BOMs](https://github.com/mfvanek/java-experiments)
- [Experimentation sandbox with io.spring.dependency-management](https://github.com/mfvanek/spring-docker-demo/commit/383c62c7ec452103c9706b698bec61e6d4dc9e90)

## Additional Material

- [My original post in Russian](https://habr.com/ru/articles/784784/)
- [Using Maven’s Bill of Materials (BOM)](https://reflectoring.io/maven-bom/)
- [Manage Java dependencies with confidence using Maven BOMs](https://medium.com/@briannqc/manage-java-dependencies-with-confidence-using-maven-boms-37583980763f)