Synchronize Dependencies with BOM

Gradle provides support for importing bill of materials (BOM) files. When a BOM is passed to a Platform dependency, Gradle will make sure that the project will depend on the same version for all modules of the same library. This recently helped us fix an upgrade problem where there had been version skew and a breaking change across dependencies on different modules of the same library.

Without BOM

At Cash App, we have a lot of services depending on each other and when we decided to bump the version of Wire from 4.1.1 to 4.2.0, we started getting a lot of CI build failures, and even worse, runtime crashes from breaking changes in Wire’s API.
As an example, here is what Wire’s version definition looked like for Wire in one of our service:

val wireGrpcClient = "com.squareup.wire:wire-grpc-client:4.2.0"
val wireRuntime = "com.squareup.wire:wire-runtime:4.2.0"

We explicitly declare 2 dependencies on Wire. However, we discovered that we transitively depend on more Wire modules. We are able to diagnose this with the gradle my-module:dependencies command.

Here is what a sample result would look like:

$ ./gradlew my-module:dependencies
   ....
|    \--- com.squareup.wire:wire-runtime-jvm:4.2.0
|  ....
|    +--- com.squareup.wire:wire-compiler:4.1.0
|    +--- com.squareup.wire:wire-grpc-client:4.0.1 -> 4.2.0 (*)
|    |    +--- com.squareup.wire:wire-runtime:4.0.1 -> 4.2.0 (*)

We can see that we have a transitive dependency to wire-compiler, and some artifacts are resolved on version 4.1.0 while others are on 4.2.0. That’s a problem! Because of a backward incompatibility change in Wire, we’d get errors when one service depended on a second service which transitively depended on other modules of Wire. We would also get into troubles in reverse where a service using Wire 4.1.0 would depend on another one which already upgraded its dependency on Wire to 4.2.0.
We have more than a hundred of those services so this made the upgrade very difficult.

With BOM

Wire started publishing a BOM file and we were then able to benefit from it. Here is what version definition looks like when we use the BOM:

val wireBom = "com.squareup.wire:wire-bom:4.2.0"
val wireGrpcClient = "com.squareup.wire:wire-grpc-client"
val wireRuntime = "com.squareup.wire:wire-runtime"

We now have a unique source of truth for the Wire version. We can use it to synchronize what version all dependencies to Wire will be. To do so, the BOM dependency needs to be passed to all places declaring a dependency on Wire to take effect.

 dependencies {
+  implementation(platform(libs.wireBom))
   implementation(libs.wireRuntime)
 }

or for instance, like so if you’re using the Wire Gradle plugin.

 dependencies {
+  classpath(platform(libs.wireBom))
   classpath(libs.wireGradlePlugin)
 }

Publishing BOM files

If your library publishes multiple modules, you can help your users managing its dependencies by publishing a BOM file. This is done with a single file defining its dependency constraints.
Here is how it looks for Wire:

dependencies {
  constraints {
    api(project(":wire-compiler"))
    api(project(":wire-gradle-plugin"))
    api(project(":wire-grpc-client"))
    api(project(":wire-grpc-server"))
    api(project(":wire-grpc-server-generator"))
    api(project(":wire-grpc-mockwebserver"))
    api(project(":wire-gson-support"))
    api(project(":wire-java-generator"))
    api(project(":wire-kotlin-generator"))
    api(project(":wire-moshi-adapter"))
    api(project(":wire-profiles"))
    api(project(":wire-reflector"))
    api(project(":wire-runtime"))
    api(project(":wire-schema"))
    api(project(":wire-swift-generator"))
  }
}

This file is published in its own module com.squareup.wire:wire-bom for users to consume.

BOM

Cash App now publishes BOM files for Okio, OkHttp, and Wire. To avoid having to manually add a platform dependency on every call sites, as well so that we don’t forget to add it for future ones, we wrote a small snippet which we use in our projects so that platform definitions are automatically added when needed. This also covers dependencies getting pulled in transitively.

allprojects {
  configurations.all {
    dependencies.all {
      val bom = when (group) {
        "com.squareup.okio" -> Dependencies.okioBom
        "com.squareup.okhttp" -> Dependencies.okhttpBom
        "com.squareup.wire" -> Dependencies.wireBom
        else -> return@all
      }
      dependencies.add(project.dependencies.platform(bom))
    }
  }
}

Update 2023.05.12

When upgrading the Gradle plugin, this snippet started throwing exceptions such as java.util.ConcurrentModificationException. Here’s a new snippet we started to use to work around that:

allprojects {
  configurations.all {
    withDependencies {
      if (any { it.group == "com.squareup.okio" }) {
        add(project.dependencies.platform(Dependencies.okioBom))
      }
      if (any { it.group == "com.squareup.okhttp" }) {
        add(project.dependencies.platform(Dependencies.okhttpBom))
      }
      if (any { it.group == "com.squareup.wire" }) {
        add(project.dependencies.platform(Dependencies.wireBom))
      }
    }
  }
}

BOM files are great; let’s use them!