Designing an Internal Maven BOM for SDKs

This is Part 2 of a series: Maven Patterns from Building an Internal SDK.

What a BOM Actually Is

A BOM (Bill of Materials) is a Maven module with pom packaging that exists solely to manage dependency versions. No JAR, no code—just metadata.

dependencyManagement vs dependencies

This distinction trips up even experienced developers:

  • dependencyManagement: "If you use this dependency, use this version"

  • dependencies: "You will use this dependency"

The difference matters for SDK design. A BOM uses only dependencyManagement. It pins versions without forcing anything onto consumers. When a consumer declares a dependency without a version, Maven looks it up in dependencyManagement.

<!-- BOM declares version management only -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.17.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

Consumers import the BOM and then declare dependencies without specifying versions:

<!-- Consumer pom.xml -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.example.sdk</groupId>
      <artifactId>sdk-dependencies</artifactId>
      <version>1.0.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <!-- version inherited from sdk-dependencies BOM -->
  </dependency>
</dependencies>

If the consumer doesn’t use Jackson at all, nothing happens. That’s the point.

Why Packaging is pom

A BOM produces no artifact beyond its pom.xml. The <packaging>pom</packaging> signals this to Maven and to developers reading the project structure.

Structuring the BOM Module

Naming Conventions

Two common patterns:

  • *-bom: Short, clear, traditional

  • *-dependencies: Signals broader scope (Spring Boot uses this)

Pick one and stick with it. We went with -dependencies because our BOM manages more than just direct dependencies—it includes test utilities, optional integrations, and version overrides for transitive dependencies.

Relationship to Parent POM

You have two structural choices:

Option 1: BOM extends the SDK parent

<parent>
  <groupId>com.example.sdk</groupId>
  <artifactId>sdk-parent</artifactId>
  <version>${revision}</version>
</parent>
<artifactId>sdk-dependencies</artifactId>
<packaging>pom</packaging>

This works for internal use. The BOM inherits plugin configuration and properties from the parent.

Option 2: BOM is standalone

<groupId>com.example.sdk</groupId>
<artifactId>sdk-dependencies</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<!-- No parent -->

Use this when the BOM is published for external consumers who shouldn’t inherit your internal build configuration.

The 3-Level Hierarchy Pattern

For larger SDKs, a three-level hierarchy works well:

  1. Root parent: Plugin configuration, properties, build settings

  2. BOM/dependencies: Version management only

  3. Starter parent: Consumer-facing, inherits from BOM

sdk-parent (root)
    └── sdk-dependencies (BOM, extends sdk-parent)
            └── sdk-starter-parent (extends sdk-dependencies)

This separation keeps concerns clean. The root parent handles how things build. The BOM handles what versions things use. The starter parent provides the consumer-facing contract.

What Belongs in a BOM

Third-Party Libraries You’ve Vetted

Include libraries that:

  • Your starters depend on

  • You’ve tested together as a coherent set

  • You’ve reviewed for security

<dependencyManagement>
  <dependencies>
    <!-- HTTP client stack -->
    <dependency>
      <groupId>org.apache.httpcomponents.client5</groupId>
      <artifactId>httpclient5</artifactId>
      <version>5.3</version>
    </dependency>

    <!-- Resilience -->
    <dependency>
      <groupId>io.github.resilience4j</groupId>
      <artifactId>resilience4j-spring-boot3</artifactId>
      <version>2.2.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

Internal Module Versions

Your own starters and utilities belong here. Use ${project.version} for modules released together:

<dependency>
  <groupId>com.example.sdk</groupId>
  <artifactId>sdk-starter-web</artifactId>
  <version>${project.version}</version>
</dependency>
<dependency>
  <groupId>com.example.sdk</groupId>
  <artifactId>sdk-starter-data</artifactId>
  <version>${project.version}</version>
</dependency>

If you’re using the ${revision} pattern from Part 1, ${project.version} will already resolve to the effective revision.

Test Dependencies

Test utilities often get overlooked. Include them:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-bom</artifactId>
  <version>1.19.7</version>
  <type>pom</type>
  <scope>import</scope>
</dependency>
<dependency>
  <groupId>org.wiremock</groupId>
  <artifactId>wiremock-standalone</artifactId>
  <version>3.5.4</version>
</dependency>

Consumers shouldn’t hunt for compatible test library versions. If your SDK relies heavily on Testcontainers internally, pinning it here avoids mismatched test environments across teams.

Importing External BOMs

The Import Scope Pattern

Use <scope>import</scope> with <type>pom</type> to pull in another BOM’s dependency management:

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

This import lives in your BOM’s dependencyManagement, alongside any other imported BOMs and your explicit version declarations.

Order of Import Matters

When two imported BOMs manage the same dependency, the last one wins. Structure your imports deliberately:

<dependencyManagement>
  <dependencies>
    <!-- Spring Boot first (base) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>3.3.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>

    <!-- Spring Cloud second (may override Spring Boot versions) -->
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2024.0.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>

    <!-- Your explicit overrides last (highest priority) -->
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.17.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

Handling Version Conflicts

When external BOMs disagree, make an explicit choice and document why:

<!-- Override: Spring Cloud's Netty version conflicts with our gRPC client.
     Pinning to version tested with both. -->
<dependency>
  <groupId>io.netty</groupId>
  <artifactId>netty-bom</artifactId>
  <version>4.1.108.Final</version>
  <type>pom</type>
  <scope>import</scope>
</dependency>

The comment matters. Six months from now, someone will ask why you override Spring’s version.

What Does NOT Belong in a BOM

Actual Dependencies

A BOM should contain only dependencyManagement, not dependencies. The consequences depend on how consumers use your BOM:

When imported (<scope>import</scope>): Maven ignores the <dependencies> section entirely—only dependencyManagement is pulled in. Your actual dependencies have no effect, but they clutter the BOM and confuse readers.

When used as a parent: The <dependencies> section is inherited, forcing those dependencies onto every child module whether they need them or not.

<!-- WRONG: Clutters the BOM; forced on children if used as parent -->
<dependencies>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
  </dependency>
</dependencies>

If you need shared dependencies across modules, put them in a parent POM (like a starter-parent), not in the BOM. Keep the BOM’s job simple: version management only.

Plugin Versions

Plugin management belongs in the parent POM, not the BOM:

<!-- In sdk-parent, NOT in sdk-dependencies -->
<build>
  <pluginManagement>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
      </plugin>
    </plugins>
  </pluginManagement>
</build>

Keep dependency versions and build configuration separate.

Transitive Exclusions

Don’t encode exclusions in the BOM:

<!-- WRONG: Exclusions in BOM are brittle -->
<dependency>
  <groupId>some.library</groupId>
  <artifactId>some-artifact</artifactId>
  <version>1.0.0</version>
  <exclusions>
    <exclusion>
      <groupId>commons-logging</groupId>
      <artifactId>commons-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Exclusions depend on consumer context. Let consumers manage their own transitive dependency problems. If a transitive is genuinely problematic, use the Enforcer plugin to ban it (covered in Part 4).

Conclusion

A BOM is a contract: "these versions work together."

The rules are simple:

  • dependencyManagement only—never force dependencies

  • Import external BOMs in deliberate order

  • Document every override

  • Keep plugin management elsewhere

A well-designed BOM is boring. That’s the goal. Consumers import it, declare dependencies without versions, and everything works. No surprises, no conflicts, no hunting for compatible versions.

Keep it focused. Keep it honest.