Maven Multi-Module Project: Inheritance, Aggregation and Reactor
An online shop project has three modules, and shop-web depends on shop-service. A developer builds only the web module: cd shop-web, then mvn package. The build fails with an error like Could not resolve dependencies for project com.examclouds:shop-web:jar:1.0-SNAPSHOT: ... Could not find artifact com.examclouds:shop-service:jar:1.0-SNAPSHOT, even though the shop-service directory is right next to it, and the same command succeeds from the root. From a subdirectory Maven sees only one module and looks for its sibling as an ordinary library in the local repository ~/.m2.
A Maven multi-module project is a project split into several modules, each with its own pom.xml and its own artifact, all built together with a single command from the root POM. It rests on two independent mechanisms: inheritance (a module names its parent in <parent> and receives the shared settings from it) and aggregation (the root POM lists the modules in <modules>, and Maven builds them in one run). The part of Maven that orders the modules by their dependencies and builds them in a single build is called the reactor. The examples target Maven 3.9.x and Java 25.
Why Split a Project into Modules and What the Structure Looks Like
A project is split into Maven modules to separate code by layer or feature with explicit boundaries: a module sees only the modules declared in its dependencies. Each module is built into its own artifact (jar, war), can be tested and reused on its own, and the shared versions and settings live in a single parent POM.
A typical example is an online shop with three modules under a common parent shop-parent:
shop-model— domain classes (Product,Order), no dependencies on other modules;shop-service— business logic, depends onshop-model;shop-web— the web layer or application entry point, depends onshop-service(and, transitively through it, onshop-model).
This split stops the web layer from going around the services straight to the database, as long as no dependency on a data-access module is declared: the compiler rejects such code during the build. The directory structure:
shop-parent/
+-- pom.xml # parent + aggregator, packaging pom
+-- shop-model/
| +-- pom.xml
| \-- src/main/java/com/examclouds/shop/model/Product.java
+-- shop-service/
| +-- pom.xml
| \-- src/main/java/com/examclouds/shop/service/OrderService.java
\-- shop-web/
+-- pom.xml
\-- src/main/java/com/examclouds/shop/web/App.java Inside each module the usual Maven project structure applies. The root project has no code: its pom.xml only ties the modules together and holds the shared settings, and build results appear in the target directory of each module (shop-model/target/shop-model-1.0-SNAPSHOT.jar and so on).
Modules make sense when parts of the project really have different dependencies, different consumers, or need to be built independently. A training project with a dozen classes does not need to be split: modules add POM files and build time without giving anything back.
Inheritance: the Maven Parent POM
Inheritance in Maven means that a module specifies the coordinates of a parent POM in the <parent> element and receives its settings: groupId, version, properties, dependencyManagement, pluginManagement, dependencies and build plugins. The parent must have <packaging>pom</packaging>, and each module sets its own artifactId. The basics of parent and the Super POM are in the section parent, Super POM and Effective POM.
The parent shop-parent/pom.xml (it is also the aggregator, covered in the next section):
<?xml version="1.0" encoding="UTF-8"?>
<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>com.examclouds</groupId>
<artifactId>shop-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- required for a parent and for an aggregator -->
<packaging>pom</packaging>
<modules>
<module>shop-model</module>
<module>shop-service</module>
<module>shop-web</module>
</modules>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<!-- versions of sibling modules: declared once -->
<dependency>
<groupId>com.examclouds</groupId>
<artifactId>shop-model</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.examclouds</groupId>
<artifactId>shop-service</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- inherited by every module -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<pluginManagement>
<!-- check the latest versions on Maven Central -->
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project> The POM of the shop-service/pom.xml module turns out short (the namespace attributes of <project> are the same as in the parent): groupId, version, the Java version, the encoding, JUnit and the plugin versions all come from the parent.
<project>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.examclouds</groupId>
<artifactId>shop-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- relativePath defaults to ../pom.xml -->
</parent>
<!-- groupId and version are inherited, artifactId is not -->
<artifactId>shop-service</artifactId>
<dependencies>
<dependency>
<groupId>com.examclouds</groupId>
<artifactId>shop-model</artifactId>
<!-- version comes from dependencyManagement of the parent -->
</dependency>
</dependencies>
</project> What Is Inherited and What Is Not
| Parent element | Inherited? | What the module gets |
|---|---|---|
groupId, version | Yes | Can be omitted if they match the parent (the usual case) |
properties | Yes | The properties of the parent; a property with the same name in the module overrides the parent one |
dependencyManagement, build/pluginManagement | Yes | Managed versions and settings; the dependencies and plugins themselves are not added |
dependencies | Yes | Every dependency of the parent is added to every module, whether the module needs it or not |
build/plugins | Yes | Plugins and their executions; an execution with the same id in the module is merged with the parent one |
repositories, distributionManagement, description, url, licenses, developers, scm | Yes | Shared metadata and repository addresses |
artifactId | No | Every module must declare its own |
name, prerequisites | No (according to the Maven documentation) | Without its own name, the module log shows the artifactId |
packaging, modules | No | A module without its own packaging gets jar, not the pom of the parent; the module list only applies in the POM where it is written |
Hence the practical rule: put into the <dependencies> of the parent only what truly every module needs (test libraries, sometimes a logging API). Everything else goes into dependencyManagement, and the modules declare what they need themselves, without a version. The final model of a module is shown by mvn help:effective-pom run in the module directory.
relativePath and How Maven Finds the Parent
Maven 3 looks for the parent first at the <relativePath> location (../pom.xml by default), checking that the file found there has the same groupId, artifactId and version as in <parent>; if the file is missing or the coordinates do not match, it searches the local and remote repositories. With the standard layout you do not need to write relativePath. If the parent lives elsewhere, specify the path explicitly; an empty <relativePath/> means "look only in repositories" — that is how external parents such as spring-boot-starter-parent are used.
<parent>
<groupId>com.examclouds</groupId>
<artifactId>shop-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- parent is two levels up, for example in a nested module -->
<relativePath>../../pom.xml</relativePath>
</parent> If the parent is found neither at the path nor in the repositories, the build stops while reading the model with a Non-resolvable parent POM error (see the mistakes table). If the parent has jar instead of pom packaging, Maven refuses to use it:
[ERROR] Invalid packaging for parent POM com.examclouds:shop-parent:1.0-SNAPSHOT, must be "pom" but is "jar" Aggregation: Maven Modules and the Reactor
Aggregation in Maven means listing modules in the <modules> element of the root POM so that a command run in the root executes for all modules in one build. Each <module> is the path to the module directory relative to the aggregator (not the artifactId). The build order is decided by the reactor from the dependencies between modules, not by the order of lines in <modules>.
The Maven reactor is the part of Maven that collects the modules from <modules> (recursively, including nested aggregators), builds a graph of the relationships between them and sorts the modules so that each one is built after the modules it depends on. The graph includes dependencies, parent POMs, and plugins and extensions coming from sibling modules. Within one build, the reactor passes the results of sibling modules to a module directly, without installing them into ~/.m2.
Let us check this with an example: the aggregator lists the modules "backwards".
<modules>
<module>shop-web</module>
<module>shop-service</module>
<module>shop-model</module>
</modules> mvn clean install At the start of the output Maven prints the computed order, and at the end a summary for each module. Approximate output for Maven 3.9.x (formatting depends on the version; if a module defines <name>, it is shown instead):
[INFO] Reactor Build Order:
[INFO]
[INFO] shop-parent [pom]
[INFO] shop-model [jar]
[INFO] shop-service [jar]
[INFO] shop-web [jar]
...
[INFO] Reactor Summary for shop-parent 1.0-SNAPSHOT:
[INFO]
[INFO] shop-parent ........................................ SUCCESS [ 0.210 s]
[INFO] shop-model ......................................... SUCCESS [ 1.874 s]
[INFO] shop-service ....................................... SUCCESS [ 1.102 s]
[INFO] shop-web ........................................... SUCCESS [ 0.951 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS Despite the reverse order in <modules>, the reactor built shop-model → shop-service → shop-web, as the dependencies require; the parent comes first because all modules depend on it. The order from <modules> only matters for modules that do not depend on each other. If a module fails, the Reactor Summary marks it FAILURE, and the modules after it SKIPPED.
The <modules> element is only allowed in a POM whose packaging is pom. If the aggregator keeps jar (the default), the build does not even start:
[ERROR] 'packaging' with value 'jar' is invalid. Aggregator projects require 'pom' as packaging. Modules can be nested: a module can itself be an aggregator with its own <modules> (for example, shop-web with the submodules shop-web-api and shop-web-ui), and the reactor builds the whole tree from the root.
The composition of the reactor can also depend on a condition: a <modules> element inside a profile adds a module only when the profile is active — for example, slow end-to-end tests in shop-e2e-tests are built only by mvn verify -P e2e. Profiles, including those declared in the parent POM, also switch properties, dependencies and plugins; how to declare and activate them is covered in the lesson Maven Profiles.
Maven Inheritance vs Aggregation: What Is the Difference
Inheritance and aggregation in Maven solve different problems and point in opposite directions: with inheritance a module refers to its parent (<parent>) to receive shared settings, and with aggregation the root POM refers to the modules (<modules>) to build them with one command. Most often the same root POM plays both roles, but that is not required.
| Aspect | Inheritance (parent) | Aggregation (modules) |
|---|---|---|
| Where it is declared | In the module: <parent> with the GAV of the parent | In the aggregator: <modules> with paths to directories |
| Direction of the reference | Module → parent | Aggregator → modules |
| What it gives | Shared groupId, version, properties, managed versions, plugins | Building all modules with one command in the right order (the reactor) |
| Who knows about the link | Only the module; the parent does not know its children | Only the aggregator; a module may not know it is being aggregated |
| Required packaging | pom for the parent | pom for the aggregator |
| Can exist on its own | Yes: the parent can be external, from a repository (spring-boot-starter-parent, a corporate POM) | Yes: an aggregator can build modules that have no common parent |
One POM in both roles is the most common setup, as with shop-parent: it both defines the shared settings and lists the modules.
Separate POMs appear when settings and build composition live apart. For example, a corporate parent com.examclouds:company-parent is published to a repository and used by dozens of projects, while each project has its own aggregator. Another variant is a shop-parent directory next to the modules with an aggregator in the root; then the modules need <relativePath>../shop-parent/pom.xml</relativePath>.
<!-- aggregator only: no shared settings, no parent required -->
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.examclouds</groupId>
<artifactId>shop-build</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>shop-parent</module>
<module>shop-model</module>
<module>shop-service</module>
<module>shop-web</module>
</modules>
</project> Dependencies Between Modules
A module uses a sibling module as an ordinary dependency — by groupId, artifactId and version. The easiest way to set the version of a sibling is the ${project.version} expression (all modules share one version), or to declare it once in the dependencyManagement of the parent, as in the example above; then modules declare siblings without a version.
<!-- shop-web/pom.xml, without dependencyManagement in the parent -->
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>shop-service</artifactId>
<version>${project.version}</version>
</dependency> A hard-coded sibling version (<version>1.0-SNAPSHOT</version>) works until the first version bump: the modules move to 1.1-SNAPSHOT while the dependency stays at 1.0-SNAPSHOT. The reactor does not recognize it and takes the old artifact from ~/.m2 — or fails if it is not there.
Modules depend on each other in one direction only. If shop-model starts depending on shop-service, which already depends on shop-model, you get a cycle, and the reactor refuses to compute an order:
[ERROR] The projects in the reactor contain a cyclic reference: Edge between 'Vertex{label='com.examclouds:shop-model:1.0-SNAPSHOT'}' and 'Vertex{label='com.examclouds:shop-service:1.0-SNAPSHOT'}' introduces to cycle in the graph ... The cycle is broken by moving the shared code into a separate module (for example, interfaces into shop-api) that both depend on.
dependencyManagement and pluginManagement in the parent are the main tool of a multi-module project: library and plugin versions are written once, and modules use them without version numbers. Details are in the sections dependencyManagement, Maven BOM and Maven Plugin Versions and pluginManagement.
Reactor Options: -pl, -am, -amd, -rf, -T
To build only part of a multi-module project, run the command from the root with reactor options: -pl selects modules, -am adds the modules they depend on, -amd adds the modules that depend on them, -rf resumes a failed build from the given module, and -T builds independent modules in parallel.
| Option | Long form | What it does | Example for the shop |
|---|---|---|---|
-pl | --projects | Builds only the listed modules (directory path or :artifactId, comma-separated) | mvn -pl shop-service test — only shop-service |
-am | --also-make | Adds the modules the selected ones depend on (upstream in the graph) | mvn -pl shop-web -am package — parent, model, service, web |
-amd | --also-make-dependents | Adds the modules that depend on the selected ones (downstream in the graph) | mvn -pl shop-model -amd verify — model, service, web |
-rf | --resume-from | Starts the build from the given module, skipping those earlier in the reactor order | mvn install -rf :shop-web |
-T | --threads | Parallel build of independent modules: number of threads or a per-core multiplier (1C) | mvn -T 1C verify |
A short cheat sheet of these options is also in the section Common Build Commands; here we look at when to choose which.
-pl without -am. mvn -pl shop-web package builds only shop-web and takes shop-service from ~/.m2 — the same situation as at the start of the lesson, only from the root. If the modules the selected one depends on have changed, add -am. Changed a shared module and want to check its consumers — -pl shop-model -amd.
Excluding a module. Since Maven 3.2.1 a module can be excluded with ! (in bash it has to be quoted):
mvn -pl '!shop-web' verify -rf after a failure. When a module fails, Maven itself suggests the command to resume. Approximate end of the output:
[INFO] shop-model ......................................... SUCCESS [ 1.874 s]
[INFO] shop-service ....................................... SUCCESS [ 1.102 s]
[INFO] shop-web ........................................... FAILURE [ 0.412 s]
[INFO] BUILD FAILURE
...
[ERROR] After correcting the problems, you can resume the build with the command
[ERROR] mvn <args> -rf :shop-web With -rf the skipped modules are not rebuilt, and shop-web gets their artifacts from ~/.m2. That is safe if the command was install and you changed only the failed module; after verify, or after edits in earlier modules, it is more reliable to rebuild with -am.
-T: parallel builds. mvn -T 4 install builds modules that do not depend on each other in four threads (the model → service → web chain gains almost nothing, a wide graph gains noticeably). Maven flags plugins not marked as thread-safe with a warning; if failures do not reproduce without -T, build the project sequentially. Module logs get interleaved.
Why a Module Does Not See Its Sibling
The reactor works only within a single build: a module gets its sibling "from the reactor" if the sibling takes part in the same build; otherwise Maven looks for it in the local repository and then in remote ones. That gives three outcomes:
- a build from the root (with
-amor without-pl) — the sibling is built in the same build, everything works; - a build from a subdirectory, or
-plwithout-am, and the sibling is not in~/.m2— theCould not find artifacterror, as at the start of the lesson; - the same, but an old installation of the sibling sits in
~/.m2— the build passes with old code (see the note below).
From the module directory you can point to the root POM explicitly: mvn -f ../pom.xml -pl shop-web -am package. Why verify is usually enough for checking and install is needed only for external consumers is explained in the section Common Build Commands; where ~/.m2 lives — in the section Local Repository: the .m2 Directory. In Maven 4 building from subdirectories changes; this lesson describes Maven 3.9.x.
Worse than an error: the build passes with stale code
A week ago you ran mvn install from the root. Today you added a method to OrderService, called it from shop-web, and build only the web module: cd shop-web, mvn package. Maven takes the week-old shop-service-1.0-SNAPSHOT.jar from ~/.m2, and compilation fails with cannot find symbol, although the method is right there in the sources. It is worse if you changed the logic rather than a signature: the build passes, and the shop-web tests run against the old version of the service. Build the module from the root with -am.
Multi-Module Project Version: ${revision} and versions:set
In a multi-module project all modules usually share one version: it is set in the parent, and the modules inherit it. The catch is that in Maven 3 the version in the <parent> block of every module is written as a literal, so a new release means changing it in every POM. For that there is the versions:set command and the CI-friendly ${revision} version.
versions:set
The versions:set goal of the versions-maven-plugin (MojoHaus), run in the root, changes the version of the parent and the <parent><version> references in the reactor modules:
mvn versions:set -DnewVersion=1.1.0-SNAPSHOT
# check the result, then remove backup files:
mvn versions:commit
# or roll back:
mvn versions:revert The plugin leaves a backup copy pom.xml.versionsBackup next to each POM (turned off with -DgenerateBackupPoms=false). It may not update literal sibling versions in <dependency> — one more reason to write ${project.version} there.
Maven CI-Friendly Versions: ${revision}
Since Maven 3.5.0 the version can be set with the ${revision}, ${sha1} and ${changelist} properties — the only exception to the "literals only in <parent>" rule. The version is written once, and a CI server passes its own via -D.
<!-- shop-parent/pom.xml -->
<groupId>com.examclouds</groupId>
<artifactId>shop-parent</artifactId>
<version>${revision}</version>
<packaging>pom</packaging>
<properties>
<revision>1.0-SNAPSHOT</revision>
</properties>
<!-- shop-service/pom.xml -->
<parent>
<groupId>com.examclouds</groupId>
<artifactId>shop-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>shop-service</artifactId> mvn clean package # version 1.0-SNAPSHOT
mvn clean deploy -Drevision=1.0.0 # release build on CI The trap is in installing and publishing: without extra configuration, POM files with the literal text ${revision} end up in ~/.m2 and in the remote repository. Inside the reactor that does no harm, but an external project that depends on shop-service cannot resolve its parent. That is why flatten-maven-plugin (MojoHaus) is added: it writes a .flattened-pom.xml with the version substituted, and that file is what gets installed.
<!-- shop-parent/pom.xml, build/plugins -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<!-- check the latest version on Maven Central -->
<version>1.6.0</version>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals>
<goal>flatten</goal>
</goals>
</execution>
<execution>
<id>flatten-clean</id>
<phase>clean</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin> The resolveCiFriendliesOnly mode replaces only the CI-friendly properties. The .flattened-pom.xml files are generated during the build and belong in .gitignore. Other modes are in the Maven CI Friendly Versions documentation. The default -Drevision value can also be kept in the .mvn/maven.config file in the project root.
Where Multi-Module Builds Break
Multi-module mistakes fall into two groups: some stop the build while the POM is being read (wrong packaging, a cycle, a parent that cannot be found), others pass silently and produce a build with old sibling code. The table lists the symptom, the cause and the fix; messages are shortened.
| Symptom | Cause | Fix |
|---|---|---|
Invalid packaging for parent POM ..., must be "pom" but is "jar" or Aggregator projects require 'pom' as packaging | The parent or aggregator uses the default packaging jar | Add <packaging>pom</packaging> to the root POM |
Only the parent is built from the root, BUILD SUCCESS in a second | <modules> is missing or the new module was not added: <parent> does not add a module to the build | List the module directory in <modules>; check the Reactor Build Order |
The projects in the reactor contain a cyclic reference | Two modules depend on each other (directly or through a third one) | Move the shared code into a separate module |
Non-resolvable parent POM for com.examclouds:shop-web:1.0-SNAPSHOT: ... 'parent.relativePath' points at wrong local POM | The parent is not at ../pom.xml, or the coordinates in <parent> do not match (often the version after a manual edit) | Set the correct <relativePath>, check the GAV; for an external parent use an empty <relativePath/> |
'dependencies.dependency.version' for com.examclouds:shop-model:jar is missing | A dependency on a sibling without a version, and the sibling is not in the dependencyManagement of the parent | Add the module to dependencyManagement with ${project.version} or set the version in the dependency |
Could not find artifact com.examclouds:shop-service:jar:1.0-SNAPSHOT | A build from a subdirectory or -pl without -am: the sibling is neither in the reactor nor in ~/.m2 | Build from the root: mvn -pl shop-web -am package |
| After a version bump a module is built against the old version of its sibling | The sibling version in <dependency> is a literal | ${project.version} or dependencyManagement of the parent |
An external project cannot use a module: the parent with version ${revision} cannot be resolved | A CI-friendly version without flatten-maven-plugin | Add flatten-maven-plugin to the parent (example) |
Frequently Asked Questions
How are Maven modules different from Java modules (module-info.java)?
They are different mechanisms that share a name. A Maven module is a unit of build: a separate pom.xml and artifact. A Java module (JPMS, since Java 9) is a unit of encapsulation at compile time and run time: module-info.java lists the exported packages and the required modules. They can be combined (often one Maven module = one Java module), but neither requires the other.
How do I use spring-boot-starter-parent if my project already has its own parent?
A POM has only one parent, but inheritance can span several levels: shop-parent inherits from spring-boot-starter-parent, and the modules inherit from shop-parent. The other option is to keep your own parent and import the spring-boot-dependencies BOM into its dependencyManagement (see Maven BOM); the plugins from starter-parent then have to be declared yourself.
Can a module have no parent?
Yes. The aggregator builds any module from <modules>, even without <parent> or with a different parent. Such a module sets its own groupId, version, Java version and plugin versions. The difference between the roles is in the section Maven Inheritance vs Aggregation.
How do I skip one module in a build?
Since Maven 3.2.1 — with -pl and an exclamation mark: mvn -pl '!shop-web' verify. If a module is needed only occasionally, move it out of the main <modules> into a profile (see the lesson Maven Profiles). A module that other modules in the build depend on can be excluded only if its artifact is already in ~/.m2.
Where should code shared by several modules go?
Into a separate module (for example, shop-common or shop-api) that the consumers depend on. Code cannot go into the parent POM: its packaging is pom, and no sources are compiled there. A shared module also breaks cyclic dependencies, which the reactor does not allow.
Comments