Maven multi-module projects
last modified November 9, 2026
In this article we build a Maven project that consists of several modules. We create a parent POM that aggregates the modules and shares their configuration, let the reactor work out the build order, build single modules, and package a runnable application. The examples use Maven 3.10 and Java 25.
A multi-module project splits an application into parts that are compiled, tested, and packaged separately, but built together. Each module has its own POM and produces its own artifact. Modules make dependencies between the parts explicit: a module can use only the code of the modules it depends on. The underlying mechanisms, inheritance and aggregation, are described in the Maven POM article.
The project layout
Our project manages the products of a small shop. It has three modules:
model contains the domain classes, service
contains the business logic, and cli is a command line
application. The parent POM lives in the root directory, and each module is
a subdirectory with its own pom.xml.
The dependencies go in one direction: cli depends on
service, which depends on model.
$ find . -type f | sort ./cli/pom.xml ./cli/src/main/java/com/example/cli/Main.java ./model/pom.xml ./model/src/main/java/com/example/model/Product.java ./model/src/test/java/com/example/model/ProductTest.java ./pom.xml ./service/pom.xml ./service/src/main/java/com/example/service/Inventory.java ./service/src/test/java/com/example/service/InventoryTest.java
The parent POM
The root POM serves two purposes. As an aggregator, it lists the modules that are built together. As a parent, it provides configuration that the modules inherit.
<?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.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>cli</module>
<module>service</module>
<module>model</module>
</modules>
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>model</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>service</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>versions-maven-plugin</artifactId>
<version>2.25.0</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
The packaging of a parent or an aggregator must be pom.
<modules>
<module>cli</module>
<module>service</module>
<module>model</module>
</modules>
The module elements contain directory names relative to the
POM. We list them in reverse order on purpose; we will see that the order
does not matter.
<dependency>
<groupId>com.example</groupId>
<artifactId>model</artifactId>
<version>${project.version}</version>
</dependency>
The dependencyManagement section fixes the versions of the
dependencies that the modules may use. The modules of the project are
managed with ${project.version}, so the modules always use
each other in the current version. The JUnit BOM provides the versions of
all JUnit artifacts.
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Dependencies in the dependencies section of the parent are
inherited by all modules. We use it only for the test API, which every
module needs. Real dependencies should be declared in the modules that use
them; otherwise every module gets every library.
The pluginManagement section works like
dependencyManagement for plugins: it pins the versions and can
provide configuration, but it does not add the plugins to the build. A
module that uses a managed plugin, or a plugin bound by default such as
Surefire, gets the managed version.
The module POMs
The module POMs are short, because most of the configuration comes from the parent.
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>model</artifactId>
</project>
The parent element refers to the parent POM by its
coordinates. Maven looks for it in the parent directory first; the
relativePath element, which defaults to
../pom.xml, changes the location. The module inherits the group
ID and the version, so it declares only its artifact ID.
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>service</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>model</artifactId>
</dependency>
</dependencies>
</project>
The service module depends on model. The version
comes from the dependency management of the parent.
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>cli</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>service</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>com.example.cli.Main</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-libs</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
The cli module depends on service and gets
model transitively. It configures the JAR plugin to write the
main class and a classpath into the manifest, and it copies its
dependencies into the target/lib directory. The plugin
versions come from the pluginManagement section of the parent.
The source code
package com.example.model;
import java.math.BigDecimal;
public record Product(String name, BigDecimal price, int quantity) {
public Product {
if (quantity < 0) {
throw new IllegalArgumentException("negative quantity: " + quantity);
}
}
public BigDecimal total() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}
A product has a name, a price, and a quantity. The compact constructor rejects negative quantities.
package com.example.service;
import com.example.model.Product;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Inventory {
private final List<Product> products = new ArrayList<>();
public void add(Product product) {
products.add(product);
}
public BigDecimal value() {
return products.stream().map(Product::total)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public List<Product> byValue() {
return products.stream()
.sorted(Comparator.comparing(Product::total).reversed())
.toList();
}
}
The inventory computes the total value of the products and sorts them by value.
package com.example.cli;
import com.example.model.Product;
import com.example.service.Inventory;
import java.math.BigDecimal;
public class Main {
public static void main(String[] args) {
var inventory = new Inventory();
inventory.add(new Product("pen", new BigDecimal("1.20"), 30));
inventory.add(new Product("book", new BigDecimal("12.50"), 4));
inventory.add(new Product("lamp", new BigDecimal("24.90"), 1));
for (var p : inventory.byValue()) {
System.out.printf("%-6s %3d x %6s = %7s%n",
p.name(), p.quantity(), p.price(), p.total());
}
System.out.printf("total value: %s%n", inventory.value());
}
}
The application fills an inventory and prints it. The model
and service modules also contain JUnit tests, which are not
listed here.
Building the project
A command run in the root directory is executed for every module. This set of projects is called the reactor. Before the build, Maven sorts the reactor so that every module is built after the modules it depends on, and after its parent.
The reactor build order is shop, model,
service, and cli, although the modules are
declared in the reverse order. The declaration order is kept only for
modules that do not depend on each other. Within the reactor, a module uses
the classes and JARs that the other modules have just built; nothing is
read from the local repository. The cli JAR runs with
java -jar, because its manifest lists the JARs in the
lib directory.
$ mvn package [INFO] Reactor Build Order: [INFO] shop [pom] [INFO] model [jar] [INFO] service [jar] [INFO] cli [jar] [INFO] Reactor Summary for shop 1.0.0-SNAPSHOT: [INFO] shop ............................................... SUCCESS [...] [INFO] model .............................................. SUCCESS [...] [INFO] service ............................................ SUCCESS [...] [INFO] cli ................................................ SUCCESS [...] [INFO] BUILD SUCCESS $ java -jar cli/target/cli-1.0.0-SNAPSHOT.jar book 4 x 12.50 = 50.00 pen 30 x 1.20 = 36.00 lamp 1 x 24.90 = 24.90 total value: 110.90 $ ls cli/target/lib model-1.0.0-SNAPSHOT.jar service-1.0.0-SNAPSHOT.jar
Building a single module
When we run Maven in the directory of a module, the reactor contains only that module. Its dependencies on the other modules must then come from the local repository.
The first build fails, because the service artifact has never
been installed. From the root directory, the -pl option selects
the module and -am (also make) adds the modules it depends on,
so the build works. Alternatively, we can install all modules with
mvn install; after that, the module builds alone, using the
installed JARs. Such a build does not see changes in the other modules
until they are installed again, which is a common source of confusion. The
Maven command line article covers the
reactor options in detail.
$ cd cli $ mvn package [WARNING] The POM for com.example:service:jar:1.0.0-SNAPSHOT is missing, no dependency information available [ERROR] BUILD FAILURE [ERROR] Failed to execute goal on project cli: Could not resolve dependencies for project com.example:cli:jar:1.0.0-SNAPSHOT [ERROR] dependency: com.example:service:jar:1.0.0-SNAPSHOT (compile) $ cd .. $ mvn package -pl cli -am [INFO] Reactor Build Order: [INFO] shop [pom] [INFO] model [jar] [INFO] service [jar] [INFO] cli [jar] [INFO] BUILD SUCCESS $ mvn -q install $ cd cli $ mvn package [INFO] Building cli 1.0.0-SNAPSHOT [INFO] BUILD SUCCESS
Cyclic dependencies
The modules of a reactor must form a directed acyclic graph. Suppose that a
developer makes the model module use a class from
service.
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>service</artifactId>
</dependency>
</dependencies>
Now model depends on service and
service depends on model.
Maven refuses to build the project before running any plugin. A cycle usually means that a class is in the wrong module; the fix is to move it, or to extract the shared code into a new module that both depend on.
$ mvn validate
[ERROR] The projects in the reactor contain a cyclic reference: Edge between 'Vertex{label='com.example:model:1.0.0-SNAPSHOT'}' and 'Vertex{label='com.example:service:1.0.0-SNAPSHOT'}' introduces to cycle in the graph ...
Changing the version
All modules share the version of the parent, but each module POM repeats it
in its parent element. The versions:set goal of the
Versions plugin changes the version in all of
them at once.
Run in the root directory, the goal updates the parent and the
parent elements of all modules. The inter-module dependencies
need no change, because they use ${project.version}.
$ mvn versions:set -DnewVersion=1.1.0-SNAPSHOT -DgenerateBackupPoms=false [INFO] Processing com.example:shop [INFO] Updating project com.example:shop [INFO] from version 1.0.0-SNAPSHOT to 1.1.0-SNAPSHOT [INFO] Processing com.example:cli [INFO] Updating parent com.example:shop [INFO] from version 1.0.0-SNAPSHOT to 1.1.0-SNAPSHOT [INFO] Processing com.example:model [INFO] Updating parent com.example:shop [INFO] from version 1.0.0-SNAPSHOT to 1.1.0-SNAPSHOT [INFO] Processing com.example:service [INFO] Updating parent com.example:shop [INFO] from version 1.0.0-SNAPSHOT to 1.1.0-SNAPSHOT [INFO] BUILD SUCCESS $ grep -n '1.1.0-SNAPSHOT' pom.xml */pom.xml pom.xml:9: <version>1.1.0-SNAPSHOT</version> cli/pom.xml:10: <version>1.1.0-SNAPSHOT</version> model/pom.xml:10: <version>1.1.0-SNAPSHOT</version> service/pom.xml:10: <version>1.1.0-SNAPSHOT</version>
Parent and aggregator
In our project, the root POM is both the parent and the aggregator, which
is the most common setup. The two roles can be separated. A separate parent
POM, often published as an artifact of its own, can be shared by many
independent projects, as the company-wide parent POMs and
spring-boot-starter-parent are. A separate aggregator, which
only lists modules, can build projects that do not share a parent. A module
refers to its parent with the parent element; an aggregator
refers to its modules with the modules element. Both
references are needed when a POM plays both roles.
Best practices
- Manage the versions of all third-party dependencies and plugins in the parent POM, and declare the dependencies themselves in the modules that use them.
- Refer to other modules of the project with
${project.version}. - Build from the root directory and select modules with
-pland-amrather than relying on installed snapshots. - Keep the dependency graph acyclic and the modules small and focused; split a module when parts of it change for different reasons.
- Change the version of all modules with
versions:setor use CI-friendly versions with${revision}.
Source
Guide to working with multiple modules
In this article we have built a multi-module Maven project with a parent POM, shared dependency and plugin management, and built the whole project and its single modules.
Author
List all Java tutorials.