DEV Community

Cover image for Creation and Usage of BOM in Gradle
Ivan Vakhrushev
Ivan Vakhrushev

Posted on

Creation and Usage of BOM in Gradle

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) 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 when it occurs on a large scale). More details about this and conflict resolution strategies can be found in documentation.
Since the examples below will be for Gradle, it's important to note that Gradle generally uses the latest strategy, 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, 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):

<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>
Enter fullscreen mode Exit fullscreen mode

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

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")
}
Enter fullscreen mode Exit fullscreen mode

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 dependenciessection, within the constraintsblock, list the required dependencies with specific versions, and we're done (the full example is here):

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 {

}
Enter fullscreen mode Exit fullscreen mode

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.

In Gradle, to achieve this, we need to specify the allowDependencies option (the full example is here):

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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. This is the so-called module metadata – 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.

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

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

    constraints {
        api("org.yaml:snakeyaml:1.33")
    }
}
Enter fullscreen mode Exit fullscreen mode

For this BOM, Gradle will generate metadata:

"dependencyConstraints": [
    {
        "group": "org.yaml",
        "module": "snakeyaml",
        "version": {
            "requires": "1.33"
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

Let's add this BOM to the application:

dependencies {
    implementation(platform(project(":internal-spring-boot-2-bom")))
    implementation("org.springframework.boot:spring-boot-starter-web")
}
Enter fullscreen mode Exit fullscreen mode

And execute the command:

./gradlew dependencyInsight --dependency org.yaml:snakeyaml
Enter fullscreen mode Exit fullscreen mode

At the output, we get approximately the following result:

org.yaml:snakeyaml:1.33
   Selection reasons:
      - By constraint
      - By conflict resolution: between versions 1.33 and 1.30
Enter fullscreen mode Exit fullscreen mode

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

Now let's add a dependency to the project:

implementation("io.swagger.core.v3:swagger-core:2.2.20")
Enter fullscreen mode Exit fullscreen mode

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.

./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
Enter fullscreen mode Exit fullscreen mode

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

implementation("io.swagger.core.v3:swagger-core:2.2.20") {
    exclude(group = "org.yaml", module = "snakeyaml")
}
Enter fullscreen mode Exit fullscreen mode

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:

implementation(enforcedPlatform(project(":internal-spring-boot-2-bom")))
Enter fullscreen mode Exit fullscreen mode
./gradlew dependencyInsight --dependency org.yaml:snakeyaml

org.yaml:snakeyaml:1.33
   Selection reasons:
      - By constraint
      - Forced
Enter fullscreen mode Exit fullscreen mode

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:

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

    constraints {
        api("org.yaml:snakeyaml") {
            version {
                strictly("1.33")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

"dependencyConstraints": [
    {
        "group": "org.yaml",
        "module": "snakeyaml",
        "version": {
            "strictly": "1.33",
            "requires": "1.33"
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

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

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

org.yaml:snakeyaml:1.33
   Selection reasons:
      - By constraint
      - By ancestor
Enter fullscreen mode Exit fullscreen mode

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

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")
}
Enter fullscreen mode Exit fullscreen mode

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

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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
}
Enter fullscreen mode Exit fullscreen mode

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

// In the application

plugins {
    id("java")
    id("org.springframework.boot")
}
Enter fullscreen mode Exit fullscreen mode

Example application for experimentation can be found here.

Repository Links

Additional Material

Top comments (1)

Collapse
 
axeln profile image
Anoshkin Alex

Amazing guide, thanks!