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:
Root parent: Plugin configuration, properties, build settings
BOM/dependencies: Version management only
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:
dependencyManagementonly—never force dependenciesImport 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.