Maven Dependency Management & BOMs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Maven 3.10.0, the primary runtime, with Maven 3.9.16 and Maven 4.0.0-rc-7 (a release candidate) used wherever behaviour could differ. Every example was run on JDK 26 on 2026-10-16, with the Dependency Plugin 3.11.0 (pinned for the Maven 3.9.16 and 4.0.0-rc-7 runs, because Maven 3.9.16 would otherwise use its older default). Paths in the console output were shortened and Maven’s progress lines (download messages, resources and compiler plugin lines) were left out; nothing else was edited. Anything that was not run is labelled "not run here".

A real build has dozens of dependencies and many modules. Without help, the same version number is typed in every module, one module drifts, and the version of a library that you never declared is chosen by whichever dependency happens to come first. Maven’s answer is dependencyManagement: one place that says which version (and scope, and exclusions) applies to a given artifact wherever it shows up in the graph, direct or transitive. A Bill of Materials (BOM) is that same section packaged as a POM that other projects import. How the graph is built (scopes and nearest-wins mediation) is on Dependencies, Scopes & Mediation; how to inspect and police it is on Dependency Analysis & Convergence; parent POMs and inheritance are on Inheritance, Aggregation & the Effective POM; version syntax is on Versions, Snapshots & Ranges; the cross-tool vocabulary (constraints, platforms, locking) is in Dependency Management Fundamentals.

The problem: versions typed everywhere

The scratch tidepool build used on these pages has four modules: tidepool-model, tidepool-engine, tidepool-cli and tidepool-report. In this first version every module spells out its own versions, exactly as a beginner would write it. The engine declares Commons CSV 1.14.1 and the CLI, written months later, declares 1.14.0. The report module uses Commons FileUpload 1.5 next to Commons CSV 1.14.1. The tree of the whole reactor, filtered to the three libraries involved (mvn -Dverbose -Dincludes=commons-io,commons-codec,org.apache.commons:commons-csv compile dependency:tree), shows two silent inconsistencies:

--- dependency:3.11.0:tree (default-cli) @ tidepool-engine ---
io.tidepool:tidepool-engine:jar:1.0.0
\- org.apache.commons:commons-csv:jar:1.14.1:compile
   +- commons-io:commons-io:jar:2.20.0:compile
   \- commons-codec:commons-codec:jar:1.19.0:compile
--- dependency:3.11.0:tree (default-cli) @ tidepool-cli ---
io.tidepool:tidepool-cli:jar:1.0.0
+- io.tidepool:tidepool-engine:jar:1.0.0:compile
|  \- (org.apache.commons:commons-csv:jar:1.14.1:compile - omitted for conflict with 1.14.0)
\- org.apache.commons:commons-csv:jar:1.14.0:compile
   +- commons-io:commons-io:jar:2.18.0:compile
   \- commons-codec:commons-codec:jar:1.18.0:compile
--- dependency:3.11.0:tree (default-cli) @ tidepool-report ---
io.tidepool:tidepool-report:jar:1.0.0
+- commons-fileupload:commons-fileupload:jar:1.5:compile
|  \- commons-io:commons-io:jar:2.11.0:compile
\- org.apache.commons:commons-csv:jar:1.14.1:compile
   +- (commons-io:commons-io:jar:2.20.0:compile - omitted for conflict with 2.11.0)
   \- commons-codec:commons-codec:jar:1.19.0:compile

(The tidepool-parent and tidepool-model sections are empty here and were omitted.) The CLI ships a CSV library older than the engine it depends on, and therefore also Commons IO 2.18.0 and Commons Codec 1.18.0 instead of 2.20.0 and 1.19.0. In the report module Commons IO 2.11.0 wins only because Commons FileUpload is declared first, while the library that actually needs the newer one (Commons CSV) loses the conflict. Nobody decided any of this.

Managing versions in one place

The fix is a dependencyManagement section in the parent POM. It lists artifacts with the version (and optionally scope, exclusions, type and classifier) that should apply; it does not add anything to any classpath.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>${project.version}</version>
    </dependency>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-engine</artifactId>
      <version>${project.version}</version>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
    </dependency>
    <dependency>
      <groupId>commons-io</groupId>
      <artifactId>commons-io</artifactId>
      <version>2.20.0</version>
    </dependency>
    <dependency>
      <groupId>info.picocli</groupId>
      <artifactId>picocli</artifactId>
      <version>4.7.7</version>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

The modules then name only what they need. This is the whole <dependencies> block of tidepool-cli:

<dependencies>
  <dependency>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-engine</artifactId>
  </dependency>
  <dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli</artifactId>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-csv</artifactId>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
  </dependency>
</dependencies>

A dependency is matched against the management section by the four identifying elements groupId, artifactId, type and classifier. Type defaults to jar and the classifier to none, which is why groupId and artifactId usually suffice; a test-jar or a war dependency needs its type repeated to find its managed entry. mvn help:effective-pom (see the effective POM) shows the result of the merge for the CLI module: version and scope were filled in, including test for JUnit, which the module did not write.

<dependencies>
  <dependency>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-engine</artifactId>
    <version>1.0.0</version>
    <scope>compile</scope>
  </dependency>
  <dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli</artifactId>
    <version>4.7.7</version>
    <scope>compile</scope>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-csv</artifactId>
    <version>1.14.1</version>
    <scope>compile</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.3</version>
    <scope>test</scope>
  </dependency>
</dependencies>

The same verbose tree now reads (again filtered to the three libraries):

--- dependency:3.11.0:tree (default-cli) @ tidepool-cli ---
io.tidepool:tidepool-cli:jar:1.0.0
+- io.tidepool:tidepool-engine:jar:1.0.0:compile
|  \- (org.apache.commons:commons-csv:jar:1.14.1:compile - version managed from 1.14.1; omitted for duplicate)
\- org.apache.commons:commons-csv:jar:1.14.1:compile
   +- commons-io:commons-io:jar:2.20.0:compile (version managed from 2.20.0)
   \- commons-codec:commons-codec:jar:1.19.0:compile
--- dependency:3.11.0:tree (default-cli) @ tidepool-report ---
io.tidepool:tidepool-report:jar:1.0.0
+- commons-fileupload:commons-fileupload:jar:1.5:compile
|  \- commons-io:commons-io:jar:2.20.0:compile (version managed from 2.11.0)
\- org.apache.commons:commons-csv:jar:1.14.1:compile
   +- (commons-io:commons-io:jar:2.20.0:compile - version managed from 2.20.0; omitted for duplicate)
   \- commons-codec:commons-codec:jar:1.19.0:compile

The note version managed from X is printed by -Dverbose whenever management is consulted for an artifact; when X is the same version it is just noise, and when it differs (managed from 2.11.0) management has overridden what the declaring POM asked for. In the report module, Commons FileUpload still asks for Commons IO 2.11.0 in its own POM, but the build uses 2.20.0, regardless of declaration order. The output of this and the following examples was identical on Maven 3.9.16, Maven 3.10.0 and Maven 4.0.0-rc-7.

It controls, it does not add

Management only configures dependencies that something else brings in. The tidepool-model module declares none, and although the parent manages six artifacts its tree is just itself:

--- dependency:3.11.0:tree (default-cli) @ tidepool-model ---
io.tidepool:tidepool-model:jar:1.0.0

A dependency that is declared with no version and is not managed fails the build. This is the message when tidepool-report omits the version of Commons FileUpload, which nobody manages (run before the section was added; Maven 3.10.0):

[ERROR] [ERROR] Some problems were encountered while processing the POMs:
[ERROR] 'dependencies.dependency.version' for commons-fileupload:commons-fileupload:jar is missing. @ line 16, column 17
[ERROR] The build could not read 1 project -> [Help 1]

Precedence for the version of one dependency

For the version actually used, from strongest to weakest:

graph TD A["A dependency appears in the graph"] --> B{"Declared directly with a version?"} B -- yes --> R1["That version is used for a direct declaration"] B -- no --> C{"Managed in this POM?"} C -- yes --> R2["The local managed entry wins"] C -- no --> D{"Managed by a parent, nearest parent first?"} D -- yes --> R3["The inherited managed entry wins"] D -- no --> E{"Managed by an import, in declaration order?"} E -- yes --> R4["The first import that manages it wins"] E -- no --> F{"Declared directly without a version?"} F -- yes --> R5["Error: version is missing"] F -- no --> R6["Transitive: mediation, nearest definition wins"]

Four of these steps were run (all three Maven versions gave identical results). A direct declaration with an explicit version is not overridden by management. If tidepool-cli is changed to declare Commons CSV 1.10.0 itself, the managed 1.14.1 that the engine would give it is ignored:

--- dependency:3.11.0:tree (default-cli) @ tidepool-cli ---
io.tidepool:tidepool-cli:jar:1.0.0
+- io.tidepool:tidepool-engine:jar:1.0.0:compile
|  \- (org.apache.commons:commons-csv:jar:1.14.1:compile - version managed from 1.14.1; omitted for conflict with 1.10.0)
\- org.apache.commons:commons-csv:jar:1.10.0:compile

(Filtered to Commons CSV.) The declaration wins and the drift is back; dependency:analyze-dep-mgt ignores direct dependencies by default and reported None for every module of this build, see Dependency Analysis & Convergence for -Dmdep.analyze.ignore.direct=false and the enforcer rules that catch it. The rule for team practice is simple: in a multi-module build, never write a version on a dependency whose version is managed.

A child that manages the same artifact again overrides its parent. A child that imports a BOM does not: the parent’s managed entry wins over the imported one, because imports only fill gaps. Both cases were run with a parent that manages Commons IO 2.20.0:

Child POM Managed in child Commons IO used

Manages commons-io 2.15.1 itself

2.15.1

2.15.1

Imports a BOM that manages commons-io 2.11.0

2.11.0 (from the BOM)

2.20.0 (the parent wins)

The Maven documentation states the same rule for transitive dependencies: dependency management takes precedence over mediation, so a managed version is chosen whichever dependency refers to the artifact, and the declaration in the current POM takes precedence over the one in its parent. It also states that management does not affect the dependencies of plugins in the same POM, only project dependencies; this last point was not run here.

Scope and exclusions are managed too

The scope in a management entry is applied to direct dependencies that omit it (JUnit above became test that way) and also to transitive occurrences. Adding Commons Codec to the parent with <scope>runtime</scope> changes how it arrives through Commons CSV:

<dependency>
  <groupId>commons-codec</groupId>
  <artifactId>commons-codec</artifactId>
  <version>1.19.0</version>
  <scope>runtime</scope>
</dependency>
--- dependency:3.11.0:tree (default-cli) @ tidepool-cli ---
io.tidepool:tidepool-cli:jar:1.0.0
\- org.apache.commons:commons-csv:jar:1.14.1:compile
   +- commons-io:commons-io:jar:2.20.0:compile
   \- commons-codec:commons-codec:jar:1.19.0:runtime

Commons Codec is now on the runtime classpath only, not the compile classpath of the CLI, although Commons CSV declares it as compile. Use this deliberately; changing the scope of a library behind someone else’s API can break compilation of code that uses it.

An <exclusions> element in a managed entry applies to every dependency path that reaches that artifact, so one declaration in the parent replaces the same exclusion repeated in each module:

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-csv</artifactId>
  <version>1.14.1</version>
  <exclusions>
    <exclusion>
      <groupId>commons-codec</groupId>
      <artifactId>commons-codec</artifactId>
    </exclusion>
  </exclusions>
</dependency>
--- dependency:3.11.0:tree (default-cli) @ tidepool-cli ---
io.tidepool:tidepool-cli:jar:1.0.0
\- org.apache.commons:commons-csv:jar:1.14.1:compile
   \- commons-io:commons-io:jar:2.20.0:compile

Exclusions themselves are explained on Dependencies, Scopes & Mediation.

Properties for versions

It is common to name versions as properties (<commons-io.version>2.20.0</commons-io.version>) and use ${commons-io.version} in the management section. With inheritance, a child can override the property to change the managed version; this was run with a parent that manages Commons IO as ${commons-io.version} set to 2.20.0, and a child that sets the property to 2.11.0:

--- dependency:3.11.0:tree (default-cli) @ override-parent ---
com.example:override-parent:jar:1.0.0
\- commons-io:commons-io:jar:2.11.0:compile

This only works through inheritance. See the import section below for the case where it silently does not.

Importing a BOM

A project has only one parent, so management cannot be inherited from two places. The answer is import: declare a POM as a dependency with <type>pom</type> and <scope>import</scope> inside dependencyManagement, and its dependencyManagement entries are merged into yours. A BOM ("Bill of Materials") is a POM written for exactly that purpose. A consumer of the tidepool BOM (the BOM itself is built in the next section) looks like this:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>harbour-app</artifactId>
  <version>1.0.0</version>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-bom</artifactId>
        <version>1.0.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-engine</artifactId>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

Neither dependency has a version; both come from the BOM (the engine) or from the BOM’s own import of the JUnit BOM:

--- dependency:3.11.0:tree (default-cli) @ harbour-app ---
com.example:harbour-app:jar:1.0.0
+- io.tidepool:tidepool-engine:jar:1.0.0:compile
|  +- io.tidepool:tidepool-model:jar:1.0.0:compile
|  \- org.apache.commons:commons-csv:jar:1.14.1:compile
|     +- commons-io:commons-io:jar:2.20.0:compile
|     \- commons-codec:commons-codec:jar:1.19.0:compile
\- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
   +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
   |  +- org.opentest4j:opentest4j:jar:1.3.0:test
   |  +- org.junit.platform:junit-platform-commons:jar:6.1.3:test
   |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test
   |  \- org.jspecify:jspecify:jar:1.0.0:test
   +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
   \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
      \- org.junit.platform:junit-platform-engine:jar:6.1.3:test

The effective POM of the consumer (mvn help:effective-pom) holds 22 managed entries: the six the BOM wrote itself plus the sixteen it received from junit-bom. Imports are recursive: a BOM may import another BOM, and the importing project sees the union as though they had been written in the BOM.

What an import takes, and what it ignores

An import brings in only the dependencyManagement section of the imported POM (after resolving that POM’s own parent chain and properties). Its <dependencies> are not added to the consumer. To check this a test BOM, bom-a, was given a real <dependencies> block with Commons Codec next to its management entries; a project that imports it did not get Commons Codec on its classpath (the trees in the next table show only the two libraries the project declared).

The rules are strict and the failures are quiet. Both mistakes below produced a warning on all three Maven versions, then a build that succeeded without applying the import (BUILD SUCCESS on all three; Maven 3.10.0 messages shown):

[WARNING] 'dependencies.dependency.scope' for io.tidepool.demo:bom-b:pom must be one of [provided, compile, compile-only, runtime, test, system] but is 'import'. @ line 14, column 14

That one is <scope>import</scope> placed in <dependencies> instead of in dependencyManagement; the build then treated the BOM as an ordinary dependency (io.tidepool.demo:bom-b:pom:1.0.0:import in the tree). The next one is an import that omits <type>pom</type>:

[WARNING] 'dependencyManagement.dependencies.dependency.type' for io.tidepool.demo:bom-b:jar must be 'pom' to import the managed dependencies. @ line 10, column 19

The managed versions simply did not apply. Maven 3.9.16 prints the same two messages without compile-only in the list of valid scopes; Maven 4.0.0-rc-7 prints a shorter summary ("1 problem was encountered …​ use -e to see details") and the same detail with -e. After adding an import, always run dependency:tree or help:effective-pom and look for the versions.

Order of imports

Several BOMs can be imported, and they may disagree. The first import that manages an artifact wins; a managed entry written in the importing POM, or inherited, beats both (the previous section). The test used two BOMs, bom-a (Commons IO 2.11.0, picocli 4.7.6) and bom-b (Commons IO 2.20.0, picocli 4.7.7), and three projects that declare Commons IO and picocli without versions. All three Maven versions agreed:

Management in the project commons-io picocli

imports bom-a, then bom-b

2.11.0

4.7.6

imports bom-b, then bom-a

2.20.0

4.7.7

imports bom-b, then bom-a, and manages picocli 4.7.5 itself (listed after the imports)

2.20.0

4.7.5

Reordering two import blocks therefore changes the classpath without touching a dependency, and a library’s own override beats every BOM wherever it is written. Treat import order as part of the contract, and use dependency:tree after a reorder.

Properties and imports do not mix

A BOM’s ${commons-io.version} is resolved in the context of the BOM, not of the importer. A project that imports tidepool-bom (which uses properties for its third-party versions) and sets its own commons-io.version property to 2.11.0 gets no change: the tree still shows Commons IO 2.20.0 (Maven 3.9.16, 3.10.0 and 4.0.0-rc-7 identical). Override a BOM’s version by managing the artifact in your POM, as in the last row of the table above, not by shadowing its property.

Writing a BOM: the tidepool BOM

A BOM is an ordinary POM with <packaging>pom</packaging> whose only job is a dependencyManagement section. The tidepool one manages the three library modules at its own version, so that all of them are always consumed at matching versions, plus the third-party libraries the project has tested against, and imports JUnit’s own BOM for the test side:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-bom</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>

  <name>Tidepool BOM</name>
  <description>Versions of the Tidepool modules and of the libraries they are tested with.</description>

  <properties>
    <commons-csv.version>1.14.1</commons-csv.version>
    <commons-io.version>2.20.0</commons-io.version>
    <picocli.version>4.7.7</picocli.version>
    <junit.version>6.1.3</junit.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <!-- the library's own modules, all at the version of this BOM -->
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-cli</artifactId>
        <version>${project.version}</version>
      </dependency>

      <!-- third-party libraries -->
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-csv</artifactId>
        <version>${commons-csv.version}</version>
      </dependency>
      <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>${commons-io.version}</version>
      </dependency>
      <dependency>
        <groupId>info.picocli</groupId>
        <artifactId>picocli</artifactId>
        <version>${picocli.version}</version>
      </dependency>

      <!-- a BOM can import another BOM -->
      <dependency>
        <groupId>org.junit</groupId>
        <artifactId>junit-bom</artifactId>
        <version>${junit.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

Guidelines that follow from the mechanics shown above:

  • No <dependencies>. They would not be inherited by importers, so they mislead readers. Likewise no plugins and no build configuration: that belongs to a parent POM, which a BOM should not need to have.

  • No <parent> unless it is needed, and no scopes or exclusions unless they are universally correct. Importers cannot remove an entry; they can only override it.

  • One BOM version per release of the library, with ${project.version} for its own modules, so a consumer who upgrades the BOM upgrades every module together.

  • A property per third-party version is convenient inside the BOM; remember importers cannot override it (the previous section).

  • Import other BOMs for families of artifacts (here JUnit) rather than copying their lists.

The BOM was built and installed into a scratch local repository with mvn install (Maven 3.10.0) before the consumer above resolved it. Publishing to a remote repository (mvn deploy with distributionManagement and credentials, see Install, Deploy & Distribution Management) is the same for a BOM as for any other POM artifact and was not run here.

BOM or parent POM

Parent POM BOM

How a project uses it

<parent>, exactly one

<scope>import</scope>, any number

What the project receives

Everything: properties, dependencyManagement, dependencies, plugins and pluginManagement, repositories, profiles

Only the managed dependencies

Typical owner

The organisation or the multi-module build

A library or framework that wants consumers to stay in sync

Overrides

Child manages again, or sets a property the parent uses

The importer manages the artifact itself; properties of the BOM cannot be overridden

A multi-module build commonly has both: a parent for its own modules, and imports of third-party BOMs inside the parent’s dependencyManagement. The parent POM itself and the effective-POM merge are on Inheritance, Aggregation & the Effective POM.

Maven 4: the bom packaging, the consumer POM and classified BOMs

Maven 4 (4.0.0-rc-7 here) introduces a dedicated bom packaging so that a BOM is no longer a pom that happens to be used as one. The new packaging needs model version 4.1.0, which only Maven 4 reads, but Maven 4 writes a Maven 3 compatible consumer POM for the repository. The build POM:

<project xmlns="http://maven.apache.org/POM/4.1.0">
  <modelVersion>4.1.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-bom4</artifactId>
  <version>1.0.0</version>
  <packaging>bom</packaging>

  <name>Tidepool BOM (Maven 4 packaging)</name>

  <properties>
    <commons-csv.version>1.14.1</commons-csv.version>
    <commons-io.version>2.20.0</commons-io.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-csv</artifactId>
        <version>${commons-csv.version}</version>
      </dependency>
      <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>${commons-io.version}</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

mvn install with Maven 4.0.0-rc-7 prints that the packaging is changed to pom and installs two POM files into the repository, the original with a -build suffix and the generated consumer POM under the normal name (paths shortened):

[INFO] The packaging attribute of the 'Tidepool BOM (Maven 4 packaging)' project is configured as 'bom' and changed to 'pom'
[WARNING] Version not locked for default bindings plugins [maven-install-plugin], you should define versions in pluginManagement section of your pom.xml or parent
[INFO] --- install:3.2.0:install (default-install) @ tidepool-bom4 ---
[INFO] Installing .../bom4/pom.xml to .../repo/io/tidepool/tidepool-bom4/1.0.0/tidepool-bom4-1.0.0-build.pom
[INFO] Installing .../bom4/target/consumer-6456160834115253385.pom to .../repo/io/tidepool/tidepool-bom4/1.0.0/tidepool-bom4-1.0.0.pom

The consumer POM that is stored under tidepool-bom4-1.0.0.pom is plain Maven 3 model 4.0.0: packaging pom, the property references already replaced by the values, and the properties section gone:

<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">
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-bom4</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>
  <name>Tidepool BOM (Maven 4 packaging)</name>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>1.0.0</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>1.0.0</version>
      </dependency>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-csv</artifactId>
        <version>1.14.1</version>
      </dependency>
      <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>2.20.0</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

Consequently every Maven version can consume it. A project like harbour-app above, importing tidepool-bom4 and declaring tidepool-engine without a version, produced the same tree on Maven 3.9.16, 3.10.0 and 4.0.0-rc-7: the engine, the model, Commons CSV 1.14.1, Commons IO 2.20.0 and Commons Codec 1.19.0. What 3.9.16 and 3.10.0 cannot do is build the BOM project itself. Maven 3.10.0 on the build POM:

[ERROR] 'modelVersion' of '4.1.0' is newer than the versions supported by this version of Maven: [4.0.0]. Building this project requires a newer version of Maven. @ line 5, column 17

The project that produces the BOM therefore needs Maven 4; the projects that import it do not.

The Maven 4 documentation also describes a consumer POM that is flattened (no parent reference, BOM imports expanded, only used managed dependencies kept) when the user property maven.consumer.pom.flatten is true, and it is off by default. That flattening was not run here; the consumer POM above is the default output.

Do not import a BOM from the same reactor

Maven 4 recommends that the BOM an application imports is built and available before the application is, not part of the same reactor. A reactor (model version 4.1.0) holding a bom project and an application that imports it by its coordinates prints, with Maven 4.0.0-rc-7 and mvn validate:

[WARNING] BOM imports from within reactor should be avoided @ line 13, column 7

The same reactor written with model version 4.0.0 and a plain pom packaging built silently on Maven 3.9.16, 3.10.0 and 4.0.0-rc-7, so the warning belongs to the new packaging. The Maven documentation says that it may become a build failure in the future. This is the reason the tidepool BOM above is a standalone project built and released before its consumers, not a module of the multi-module build.

Classified BOMs

In model version 4.1.0 an import dependency may carry a <classifier>, so that a project can publish its BOM as an attached artifact next to its normal POM (for example tidepool-bom-1.0.0-bom.pom). Maven 4.0.0-rc-7 resolved this import (a 4.1.0 build POM that sets <classifier>bom</classifier> next to <type>pom</type> and <scope>import</scope>) and produced the expected tree for Commons CSV, Commons IO 2.20.0 and Commons Codec 1.19.0. No BOM-building plugin was used: the classified file was put into the scratch repository with install:install-file -Dclassifier=bom -Dpackaging=pom, so producing the classified artifact with a real plugin and deploying it was not run here.

Maven 3 rejects the same import, first because it does not know model version 4.1.0 and then because it does not allow a classifier on an import. Maven 3.10.0 and 3.9.16 both printed:

[ERROR] 'dependencyManagement.dependencies.dependency.classifier' for io.tidepool:tidepool-bom:pom:bom must be empty, imported POM cannot have a classifier. @ line 14, column 21

Both 3.x versions also printed the model version error ("has 2 errors"). The documentation adds that Maven 4 can exclude dependencies that a BOM declares by using the existing <exclusions> element on the import; this was not run here.

Differences between Maven versions

Everything shown above ran on three Maven versions; the table lists every difference that was observed.

Observation Maven 3.9.16 Maven 3.10.0 Maven 4.0.0-rc-7

Version, scope and exclusions managed; managed beats mediation; import order; local over parent over import

identical

identical

identical

Properties of an imported BOM cannot be overridden by the importer

identical

identical

identical

Warning for import in <dependencies>

valid scopes listed without compile-only

valid scopes list includes compile-only

short summary, detail with -e

bom packaging, model version 4.1.0

cannot build the project

cannot build the project

builds; installs a consumer POM and the build POM

Importing a bom project that is in the same reactor

cannot build the 4.1.0 project

cannot build the 4.1.0 project

BOM imports from within reactor should be avoided (a model 4.0.0 reactor with a pom BOM is silent on all three)

Import with <classifier>

error (and the model version error)

error (and the model version error)

resolved (4.1.0 build POM)

Maven 3.9.16 uses an older default Dependency Plugin (3.7.0) than the 3.11.0 that was pinned for the comparison runs; its tree lines were identical for the examples here.

Summary of pitfalls

  • Management configures, it does not add. A dependency with no version that nobody manages is an error.

  • A direct declaration with its own version beats management; remove versions of managed artifacts from modules.

  • Management beats mediation for transitive dependencies: the managed version replaces what the declaring POM asked for, so a library can end up on a version it was never tested against.

  • A managed scope also changes the scope of transitive occurrences.

  • scope import works only in dependencyManagement with <type>pom</type>; the wrong forms warn and are ignored.

  • An imported BOM contributes only its dependencyManagement; its dependencies, plugins and properties are not inherited, and its own properties cannot be overridden by the importer.

  • The first import wins; a local or inherited entry beats every import. Reordering imports changes the classpath.

  • A BOM build needs Maven 4 only when it uses the bom packaging; its consumers do not.

  • Keep a BOM out of the reactor that consumes it (Maven 4 warns).

  • Always confirm the outcome with dependency:tree -Dverbose or help:effective-pom.