ZetCode

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.

pom.xml
<?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.

model/pom.xml
<?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.

service/pom.xml
<?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.

cli/pom.xml
<?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

model/src/main/java/com/example/model/Product.java
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.

service/src/main/java/com/example/service/Inventory.java
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.

cli/src/main/java/com/example/cli/Main.java
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.

model/pom.xml (dependencies)
<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

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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Java tutorials.