Improving the Maven Bill of Materials (BOM) Pattern

Managing the versions of a project's dependencies is difficult, and Apache Maven was one of the early tools to bring some automation to the process. Its Project Object Model (POM) allows a developer to declaratively indicate versions of project dependencies, even before (and whether) they are used in submodules. The Bill of Materials (BOM) pattern facilitates version management even further, by allowing a project to effectively export a group of related dependency versions. But the approach Apache recommends to creating a BOM is verbose and a bit unweildy. I'll show you how to trim it down to produce a more manageable BOM for your project.

Consuming BOMs

First lets take a look at how BOMs are used in other projects. You might not have heard of a BOM before (unless you work with the Byte Order Mark of UTF-* encoded text files—but that's something different). Projects such as Spring and JUnit, each of which is composed of multiple subprojects (usually but not necessarily at the same version), have seen the need for a way to publish a set of dependency versions that are in sync, especially with the advent of Spring Boot. The preferred approach to use Spring Boot with Maven is to declare the org.springframework.boot:spring-boot-starter-parent as the project parent, like this:

<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">
  …
  <parent>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-parent</artifactId>
      <version>3.1.0</version>
  </parent>

  …

Among other things spring-boot-starter-parent declares the versions of many of its projects (Spring MVC, Spring Data, etc.) so that you don't have to declare them manually. However there are various reasons you may prefer to use your own parent POM; without any import mechanism, you would have to declare the version of each Spring Boot project manually:

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
        <version>3.1.0</version>
      </dependency>

      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
        <version>3.1.0</version>
      </dependency>

      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>3.1.0</version>
      </dependency>
    </dependencies>
  </dependencyManagement>

There is a better way: Spring Boot provides a Bill of Materials artifact org.springframework.boot:spring-boot-dependencies which you can import into your dependency management section. The following would declare the correct version of all Spring Boot related projects, making the verbose version above unnecessary. Note in particular the scope indicating a value of import.

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>3.1.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

After importing the Spring Boot BOM, you would simply declare your actual dependencies as needed, without worrying about specifying the version:

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    …

  </dependencies>

The BOM Mechanism

So how do you create your own BOM? There isn't really any specific mechanism in Maven for creating a BOM. The mechanism here is using <scope>import</scope> and <type>pom</type> together to effectively include all the dependencies defined in some other POM, as if they were defined in your POM. A “bill of materials” is merely a pattern of producing a POM that is most convenient to be imported into another project.

A Naive BOM Pattern

To see how this works and to arrive at the best technique, let's start with a naive approach. Consider a Maven project com.example:parent consisting of two subprojects, com.example:foo and com.example:bar. Here is what the POMs might look like, with a dependency on Google Guava thrown in for illustration.

<!-- pom.xml -->
<project …>

  …

  <groupId>com.example</groupId>
  <artifactId>parent</artifactId>
  <version>8.0.0</version>
  <packaging>pom</packaging>
  <name>Example Parent</name>

  <modules>
    <module>foo</module>
    <module>bar</module>
  </modules>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.example</groupId>
        <artifactId>foo</artifactId>
        <version>1.2.3/version>
      </dependency>

      <dependency>
        <groupId>com.example</groupId>
        <artifactId>bar</artifactId>
        <version>1.2.3/version>
      </dependency>

      <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>32.0.0-jre</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>
<!-- foo/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.2.3</version>
  </parent>

  <artifactId>foo</artifactId>
  <name>Foo</name>
</project>
<!-- bar/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.2.3</version>
  </parent>

  <artifactId>bar</artifactId>
  <name>Bar</name>

  <dependencies>
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>foo</artifactId>
      <version>1.2.3/version>
    </dependency>

    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
    </dependency>
  </dependencies>
</project>

The directory structure looks like this:

pom.xml
├── foo/
│   └── pom.xml
└── bar/
    └── pom.xml

A few things to note:

Important: It so happens that project aggregation and project inheritance are two separate concepts! Hear the aggregated POMs declare the aggregate POM as their parent, but this need not be the case: a POM may aggregate other POMs that do not in turn declare the aggregator POM as their parent. We'll revisit this distinction shortly and explain its implications.

In another project, if I want to use com.example:foo and com.example:bar, I can declare them separately:

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.exmaple</groupId>
        <artifactId>foo</artifactId>
        <version>1.2.3</version>
      </dependency>

      <dependency>
        <groupId>com.exmaple</groupId>
        <artifactId>bar</artifactId>
        <version>1.2.3</version>
      </dependency>
    </dependencies>
  </dependencyManagement>

But because com.example:parent has put under dependency management the versions of all the aggregated POMs, I could instead use that com.example:parent POM as a Bill of Materials and simply import it, like we did with Spring Boot dependencies above:

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.example</groupId>
        <artifactId>parent</artifactId>
        <version>1.2.3</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

That works! Maven's dependency import mechanism doesn't care if the imported artifact has been named a “BOM” or not—as mentioned, there is no separate “bill of materials” type or designation in Maven. It might appear that we've produced a BOM quite easily.

Unfortunately this approach has a drawback. It's small and you may choose to live with it, but it's not so nice to consumers of your library: importing com.example:parent not only declares the version of com.example:foo and com.example:bar, but of com.google.guava:guava as well! If you've worked on a project that was tied to a particular version of Guava (such a project probably has even bigger issues), then they would be less than ecstatic that your library is trying to force them to use a different version of Guava. There are workarounds, but the consuming project probably has more workarounds than they want already. They just want to use your library without it trying to specify versions of projects that may be unrelated. (The consuming project may only actually use com.example:foo, so it doesn't care what version of Guava that com.example:bar needs.)

Benefits
  • It's simple and straightforward.
  • It may require no additional work.
Drawbacks
  • Importing the BOM imports unrelated dependencies.

There needs to be a way to publish only the versions of artifacts in the POM that are part of the com.example project. That means the artifacts com.example itself relies on need to be moved to a separate POM.

The Official BOM Pattern

Introduction to the Dependency Mechanism: Bill of Materials (BOM) POMs documents what could be considered the offical pattern for creating a set of project dependencies to be imported into another project. Computer scientists such as Butlert Lampson and David Wheeler has been quoted as saying, “All problems in computer science can be solved by another level of indirection.” In this case the Maven documentation recommends moving the entire com.example project structure down a level of inheritance, while only the project-related versions would be declared at a new top-level POM, which we can refer to as com.example:bom.

The Maven documentation isn't clear if it is suggesting that the projects move down another level in the directory structure as well, or if com.example:parent, while being “demoted” in the inheritance hierarchy, is merely placed at the same level as com.example:foo and com.example:bar in the directory structure. Either way would work; I'll choose the latter because it makes the directory structure simpler:

<!-- pom.xml -->
<project …>

  …

  <groupId>com.example</groupId>
  <artifactId>bom</artifactId>
  <version>1.2.3</version>
  <packaging>pom</packaging>
  <name>Example BOM</name>

  <modules>
    <module>parent</module>
  </modules>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.example</groupId>
        <artifactId>foo</artifactId>
        <version>1.2.3/version>
      </dependency>

      <dependency>
        <groupId>com.example</groupId>
        <artifactId>bar</artifactId>
        <version>1.2.3/version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>
<!-- parent/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>bom</artifactId>
    <version>1.2.3</version>
  </parent>

  <artifactId>parent</artifactId>
  <packaging>pom</packaging>
  <name>Example Parent</name>

  <modules>
    <module>foo</module>
    <module>bar</module>
  </modules>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>32.0.0-jre</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>
<!-- foo/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.2.3</version>
    <relativePath>../parent/pom.xml</relativePath>
  </parent>

  <artifactId>foo</artifactId>
  <name>Foo</name>
</project>
<!-- bar/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.2.3</version>
    <relativePath>../parent/pom.xml</relativePath>
  </parent>

  <artifactId>bar</artifactId>
  <name>Bar</name>

  <dependencies>
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>foo</artifactId>
      <version>1.2.3/version>
    </dependency>

    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
    </dependency>
  </dependencies>
</project>
pom.xml
├── parent/
│   └── pom.xml
├── foo/
│   └── pom.xml
└── bar/
    └── pom.xml

Note:

Now importing com.example:bom into another project will correctly set versions com.example:foo:1.2.3 and com.example:bar:1.2.3, without saying anything about the version of Google Guava. But how easy is it to maintain the project POMs? The project comprises the foo and bar modules, yet to find the modules declaration one has to search out the com.example:parent POM, which is no longer at the type of the hierarchy. Here “indirection” has led to misdirection”, as the top-level list of <modules> only include parent. It's effective, but seems to be lacking in elegance.

Benefits
  • The BOM only brings in the project's own modules as dependencies when imported.
Drawbacks
  • The project hierarchy is now more complex.
  • Aggregated modules are hard to find, because they are no longer in the top-level POM.

Refining the BOM Pattern

The core thing that irks me about the “official” BOM pattern is that we've moved aggregation down a level. I would expect to be able to quickly find all the aggregated modules in the top-level POM. Why can't we place them there?

Remember earlier that I stressed that in Maven aggregation and inheritance are distinct! Many people get this confused, assuming that all modules aggregated by a POM have that aggregator POM as their parent. (See the discussion at Updating version numbers of modules in a multi-module Maven project for example to see how easy it is to make that assumption.) But Maven doesn't require this. We can actually have the BOM com.example:bom aggregate all three com.example:parent, com.example:foo, and com.example:bar; while still having com.example:foo and com.example:bar inherit from com.example:parent as their parent. It sounds odd, but works just fine. It would look like this:

<!-- pom.xml -->
<project …>

  …

  <groupId>com.example</groupId>
  <artifactId>bom</artifactId>
  <version>1.2.3</version>
  <packaging>pom</packaging>
  <name>Example BOM</name>

  <modules>
    <module>parent</module>
    <module>foo</module>
    <module>bar</module>
  </modules>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.example</groupId>
        <artifactId>foo</artifactId>
        <version>1.2.3/version>
      </dependency>

      <dependency>
        <groupId>com.example</groupId>
        <artifactId>bar</artifactId>
        <version>1.2.3/version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>
<!-- parent/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>bom</artifactId>
    <version>1.2.3</version>
  </parent>

  <artifactId>parent</artifactId>
  <packaging>pom</packaging>
  <name>Example Parent</name>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>32.0.0-jre</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>
<!-- foo/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.2.3</version>
    <relativePath>../parent/pom.xml</relativePath>
  </parent>

  <artifactId>foo</artifactId>
  <name>Foo</name>
</project>
<!-- bar/pom.xml -->
<project …>

  <parent>
    <groupId>com.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.2.3</version>
    <relativePath>../parent/pom.xml</relativePath>
  </parent>

  <artifactId>bar</artifactId>
  <name>Bar</name>

  <dependencies>
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>foo</artifactId>
      <version>1.2.3/version>
    </dependency>

    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
    </dependency>
  </dependencies>
</project>
pom.xml
├── parent/
│   └── pom.xml
├── foo/
│   └── pom.xml
└── bar/
    └── pom.xml

The difference may seem slight, but now all aggregation occurs in the top-level BOM. Now adding or removing modules to the project is easier—they are declared where one would expect them: at the top in the main “project definition”. Still it seems odd and cluttered that the “parent” POM is stuck off in some directory, all by itself.

Benefits
  • The BOM only brings in the project's own modules as dependencies when imported.
  • Aggregated modules are easy to find—in the top-level POM where they logically should be.
Drawbacks
  • Having a “parent” POM own its on in a separate directory needlessly clutters and complicates the file structure.

The Finishing Touch: Flattening the Directory Structure

Warning: This approach doesn't work with some crucial tools; see update below.

I mentioned briefly above that we could have implemented the “official” BOM pattern using a multilevel directory structure like this:

pom.xml
└── parent/
    ├── pom.xml
    ├── foo/
    │   └── pom.xml
    └── bar/
        └── pom.xml

Instead I chose to flatten the structure a bit by making all the directories siblings:

pom.xml
├── parent/
│   └── pom.xml
├── foo/
│   └── pom.xml
└── bar/
    └── pom.xml

Frankly we can put the POMs anywhere we want (update: this may not be a correct assumption, as many tools will assume a relative target/ directory, which can conflict), and name them anything we want. (If we name them something else other than pom.xml, we'll simply need to take care to be explicit in our references, as Maven assumes pom.xml as the default if we only indicate a directory.) So why not take the com.example:parent POM out of its own directory and put it in the project root directory? It would look like this:

pom.xml
parent-pom.xml
├── foo/
│   └── pom.xml
└── bar/
    └── pom.xml

To me that is simpler; it simply makes sense on its own.