A POM by any other name

By POM I mean Apache Maven's Project Object Model. The POM format is widely used not just by Apache Maven to build and consume projects, but also by other tools such as IDEs, build tools, code analyzers, etc. Understanding the capabilities exposed by this format are key to successful builds, developer productivity, supply chain management (and security as well), and other concerns. What follows is a list of different types of POMs you may encounter in the wild. This list is not exhaustive, feel free to comment on other patterns you may find.

To begin with, a pom.xml file is used by Apache Maven to describe how a project should be built but also how it may be consumed by other projects. Other build tools use different means to describe how projects are built however, they must rely on the POM format to describe how said projects should be consumed by others, also when resolving dependencies required by the project to be built in the first case. Hence, even if you use a build tool other than Apache Maven it's important that you know the different types of POMs that exist out there.

Basic POM

We've got to start with the most basic of POMs, the one that lays down the basic rules. The following is the minimal POM that you may write. It shows what are known as the GAV (groupId, artifactId, version) coordinates:

pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.acme</groupId>
    <artifactId>producer</artifactId>
    <version>1.2.3</version>
</project>

You are free to choose any values as you deem fit. The version number may or may not follow Semantic Versioning. Apache Maven relies on conventions and default settings, allowing you to write down the minimum set of instructions to get the job done. In this particular case the producer project has the capabilities to be compiled and packaged, plus a few other things. However, this current build setup is not that useful beyond the basic "Hello World" example. We can enhance it by adding dependencies which will allow the project to increase its capabilities why reusing existing libraries (published as JARs with companion POM files), for example:

pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.acme</groupId>
    <artifactId>producer</artifactId>
    <version>1.2.3</version>

    <dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M7</version>
            </plugin>
        </plugins>
    </build>
</project>

The project can now make use of the Slf4j logging API and JUnit5 for testing. Note that we also updated the build instructions by specifying a version (latest at the time of writing) for the maven-surefire-plugin plugin which is used for testing. From the point of view of this project when building this POM file is alright. From the point of view of a consumer project (a project that declares com.acme:producer:1.2.3 as a dependency) the XML elements of dependencies on test scope are ignored and any instructions found inside <build> as well. But, the consumer build will still download the full producer POM file as is. That's just a waste of data transfer across the wire, also a leak of information that's not needed. Consumers should not care how producers were built, they should only care on how a producer may be consumed. For this reason the upcoming Apache Maven 4.0 has added a new feature that will let producer projects be published with a modified POM known as Consumer POM. Stay tuned for when this version lands.

Anyway, as you continue working with POM files and your projects grow in complexity you'll notice that some of them share similar configuration. It would make sense to refactor that duplication and put it at a place where it may be shared. Which brings us to the next POM type.

Parent POM

A Parent POM contains the same XML elements as a basic POM. A "child" POM may also contains the same XML elements with one particular addition: a <parent> section. Child POMs use this section to define which POM is their parent. As it happens any POM file may be a parent because a parent does not define the relationship, it's the children POMs who do that. Here's how it may look like, first a POM that looks like a basic POM, this will be the parent. I set "parent" as <artifactId> for clarity but it could be any value.

pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.acme</groupId>
    <artifactId>parent</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M7</version>
            </plugin>
        </plugins>
    </build>
</project>

Then comes the child POM which technically could be placed anywhere in the filesystem but if we put it inside a directory one level below the directory where the parent POM is located (if we have access to such file) then we skip the need to define an additional XML element (<relativePath>)

project/pom.xml

<project>
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.acme</groupId>
        <artifactId>parent</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>project</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.0</version>
        </dependency>
    </dependencies>
</project>

Notice that the child does not define 2 out of 3 members for its GAV coordinates. This is because these members are inherited from its parent. The child will also inherit the junit-jupiter dependency as well as the updated maven-surefire-plugin used for testing. Although we're relying on the convention that parent and child POMs are relative to one another within the filesystem nothings stops you from declaring a parent POM that exists at a remote repository because once again, it's the child the one that declares the parent-child relationship, not the parent.

Pro tip: if at any point you get confused with what goes where and from where it's coming, make sure to look at the effective POM. What is that? It's the fully resolved POM for a particular project. Invoke mvn help:effective-pom -Dverbose to get a copy of it.

The parent POM must be resolvable during the build of the child POM but also during the resolution of the child by any consuming POM. This means that both parent and child POMs must be available from an artifact repository. This is one of the reasons why parents and children are often found to be part of the same multi-project build that lets you install all artifacts by issue a single command such as install or deploy [in Maven parlance these two are actually lifecycle phases which trigger matching plugin bindings whose bound goals perform the actual job].

Also note that a parent POM may or may not produce binaries of its own. If it does not then its packaging type must be set to pom (as shown above). If it does then you may skip defining a packaging type in which case it defaults to jar.

How do we make parent and children become part of the same multi-project build? We have to have a look at aggregating POMs first.

Aggregating POM

This type of POM showcases yet another set of XML elements that indicate that a POM (any type of POM) has a relationship to others such that lifecycle phases and plugin goals invoked on the POM that defines these XML elements should also be invoked on the referred POMs. Here's how it may look like

pom.xml (aggregator)

<project>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.acme</groupId>
    <artifactId>aggregator</artifactId>
    <version>1.2.3</version>
    <packaging>pom</packaging>

    <modules>
        <module>project1</module>
        <module>project2</module>
        <module>project3</module>
    </modules>
</project>

The values for each <module> entry are actually relative paths to directories where other POM files are found regardless of their actual <artifactId> values. However, the convention is to use the same values for directory paths and artifactIds as that simplifies configuration. This means the following file structure should be in place to run a build from the directory that hosts the aggregator POM file:

.
├── pom.xml
├── project1
│   └── pom.xml
├── project2
│   └── pom.xml
└── project3
    └── pom.xml

An aggregating POM like this one do not produce artifacts hence why its <packaging> is set to pom. If the realization that module values are not artifactIds but paths shocks you then have a look at JaCoCo's build setup where you can find how different types of POMs are used.

As powerful as aggregating POMs are they are often found combined as parents such that many developers believe that parent POMs must be aggregators and that aggregators must be parents. This is farther from the truth. Aggregators explicitly list who their dependents are (their modules) while parent have no clue if they have children or not because it's the children POMs who claim them as parents. Only an explicit aggregating POM that's intended to be consumed as a parent (the most common case found in the wild hence the confusion) is aware of both relationships by design yet its POM file can only reflect the aggregating relationship, not the parent-child relationship.

Why is it important to remark that aggregators do not have to be parents? Because you may use aggregators to group a set of unrelated projects (at least unrelated by parentship) to become part of the same multi-project build. Whether that's a temporary union or a long lasting one it's up to your needs.

BOM POMs

BOM stands for Bill of Materials. This type of POMs are used to define a set of dependencies that belong together. These dependencies must be defined inside the <dependencyManagement> block so that when a BOM is consumed this block is seen by the consuming POM as if it were defined on the consumer itself. This increases reuse, reduces duplication, and indicates cohesion between dependencies. There are two kinds of BOM POMs that lack a formal naming so for know I'll refer to them as library BOM and stack BOM.

A library BOM is such that it only defines Maven modules that belong to the same multi-project build. Let's take for example the jackson-bom BOM POM as Jackson is quite the popular project:

pom.xml (library BOM POM)

<project>
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>com.fasterxml.jackson</groupId>
    <artifactId>jackson-parent</artifactId>
    <version>2.14-rc1-SNAPSHOT</version>
  </parent>

  <artifactId>jackson-bom</artifactId>
  <packaging>pom</packaging>

  <properties>
    <jackson.version>2.14.0-SNAPSHOT</jackson.version>
    <jackson.version.annotations>${jackson.version}</jackson.version.annotations>
    <jackson.version.core>${jackson.version}</jackson.version.core>
    <jackson.version.databind>${jackson.version}</jackson.version.databind>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-annotations</artifactId>
        <version>${jackson.version.annotations}</version>
      </dependency>
      <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-core</artifactId>
        <version>${jackson.version.core}</version>
      </dependency>
      <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>${jackson.version.databind}</version>
      </dependency>

     <!-- more dependency definitions follow -->

    </dependencies>
  </dependencyManagement>
</project>

The full file does list more XML elements as some of the them are required for publishing to Maven Central. However, the most important part of a BOM POM is the declaration of the <dependencyManagement> block and its contents. This particular POM only lists modules that belong to the Jackson project and that is why I call it a library POM, as it let's you consume any Jackson module that belong to the Jackson "library".

A stack BOM is a BOM POM that defines a set of Maven modules that may or may not belong to the same multi-project build, it may even include other library BOMs! Perhaps the most common case is found in the Spring Boot project where spring-boot-dependencies defines all Spring Boot dependencies plus every other managed dependency (Junit, Slf4j, Jackson, etc) that you might need when working with libraries that have been proven to work with Spring Boot.

A much cleaner distinction between library BOM and stack BOM can be found in Helidon. helidon-bom defines a library BOM while helidon-dependencies defines a stack BOM.

Regardless of the type of BOM POM you consume them in the same way, that is:

pom.xml (consumer)

<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.acme</groupId>
    <artifactId>project</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.fasterxml.jackson</groupId>
                <artifactId>jackson-bom</artifactId>
                <version>2.13.2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-core</artifactId>
        </dependency>
    </dependencies>
</project>

Flavor POMs

The last type of POM to be discussed which also does not have an official name perhaps because this is the least common type of POM I've encountered. A flavor POM is like a BOM POM in the sense that it groups dependencies that should go together. However, the main difference is found in two aspects:

  1. From the producer side this POM defines explicit dependencies.
  2. From the consumer side, the flavor POM must be consumed as any other dependency but the consumer must use <type>pom</type> inside the <dependency> definition.

Alright, so what does this all mean? Say you have a multi-project build with 10 Maven modules. These modules may or may not have inter-module dependencies. The basic case for consuming this modules is that project1 is standalone, you don't need other modules to consume it, that is the other 9 modules as seen as not required or optional. Then there's the "pro" flavor where you need project1 alongside 2 other modules. This means these 3 modules are required to fulfill the pro scenario. Not 1, not 2, but those 3, exactly. The "pro" scenario is different from the basic one. We can express this relationship between modules using a flavor POM like this one

pom.xml (pro flavor)

<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.acme</groupId>
    <artifactId>flavor-pro</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.acme</groupId>
                <artifactId>library-bom</artifactId>
                <version>${project.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>com.acme</groupId>
            <artifactId>project1</artifactId>
        </dependency>
        <dependency>
            <groupId>com.acme</groupId>
            <artifactId>project2</artifactId>
        </dependency>
        <dependency>
            <groupId>com.acme</groupId>
            <artifactId>project3</artifactId>
        </dependency>
    </dependencies>
</project>

A flavor POM may or may not consume a BOM POM (as shown above). The important bits are that its <packaging> is set as pom because this type of POM is not strictly associated with a single artifact (JAR) but to many. Consuming this flavor POM can be done in the following way:

pom.xml (consumer)

<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.acme</groupId>
    <artifactId>consumer</artifactId>
    <version>1.2.3</version>

    <dependencies>
        <dependency>
            <groupId>com.acme</groupId>
            <artifactId>flavor-pro</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
        </dependency>
    </dependencies>
</project>

The consumer then gains access to project1, project2, and project3 as transitive dependencies. On a closer look a flavor POM is just like any other POM with dependencies but, the key distinction is that it does not have a corresponding JAR (matching by GAV) associated with it.

Some may be thinking, what if we simply declare all other 9 modules as <optional> dependencies in project1's POM? Well, that will indicate that consumers of project1 may use any other the other 9 modules but it does not indicate that you need explicitly 3 modules (and which ones) to fullfill the pro scenario. There may be more than one scenario as well, such as observability, debug, all, there could be many combinations. Coming up with all of them with just the same POMs used to publish each individual module is neither enough nor a good idea. You need additional POMs hence the use of "flavors".

Summary

Knowing the characteristics associated with each POM type will let you be more effective:

  • when creating project layouts to clearly mark build constraints and exposing producers to consumers.
  • to unravel complex and/or complicated multi-project setups.
  • when fixing dependency resolution errors.

Keep on coding!

Image by Sergey Egorov from Pixabay

Liked it? Take a second to support aalmiray on Patreon!

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.

ˆ Back To Top