Multi-Module Projects
Split a code base into modules built together, share settings through a parent, and build only what changed.
<packaging>pom</packaging>
<modules><module>shop-domain</module><module>orders-api</module></modules>
mvn -pl orders-api -am verifyA multi-module project is several artifacts in one repository, built by one command. A typical back-end shape is a shared domain module, an HTTP API service and a background worker that both use it. Each module has its own pom.xml or build.gradle.kts and produces its own JAR.
When to split
Split when you need separate artifacts: two deployables that share code, or a client library other teams consume. Split to enforce layering, too: if shop-domain does not depend on the web framework, nothing in it can import a controller class by accident, and the compiler enforces that for you. Do not split a single service into modules just to organise it; packages do that with far less build configuration. Avoid a common or utils module that everything depends on and everything is dumped into: every change to it rebuilds and retests the world.
Maven: aggregator and parent
The root POM usually plays two roles. As an aggregator, its <modules> list says which modules to build together. As a parent, it holds shared properties, <dependencyManagement> and <pluginManagement>, and each child names it in <parent>. Both roles need <packaging>pom</packaging>.
The reactor sorts modules so each is built after the modules it depends on, whatever order <modules> lists them in, and fails on a cycle. Modules reference each other with ${project.version} so the versions move together.
To build a subset, use -pl (projects list) with -am (also make the modules it needs). Without -am, Maven resolves sibling modules from ~/.m2/repository, which may hold an old build or nothing at all, and you get errors about classes you can see in the IDE. -amd builds the modules that depend on yours, which is what to run after changing shop-domain.
Gradle: settings and project dependencies
settings.gradle.kts lists the sub-projects with include(...), and a module depends on another with implementation(project(":shop-domain")). Share build logic through convention plugins: small plugins in a build-logic included build that each module applies by id. Gradle discourages allprojects { } and subprojects { } blocks, which couple every project's configuration to the root and get in the way of parallel and cached builds.
Versions
Keep one version for all modules of a repository that is released together. Maven supports a ${revision} placeholder for CI-set versions, as in mvn -Drevision=1.4.0 verify; when you install or deploy such modules, add the flatten-maven-plugin so the published POMs contain the real version.
Example
<!-- shop/pom.xml: parent and aggregator in one -->
<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.shop</groupId>
<artifactId>shop-parent</artifactId>
<version>1.4.0-SNAPSHOT</version>
<packaging>pom</packaging>
<!-- Aggregation: build these together; Maven orders them by dependency -->
<modules>
<module>shop-domain</module>
<module>orders-api</module>
<module>orders-worker</module>
</modules>
<!-- Inheritance: every module gets these settings -->
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>shop-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>When to use it
- Share a domain module between an orders HTTP API and a payment worker while deploying them separately.
- Stop the domain layer from importing web framework classes by keeping it in a module without that dependency.
- Cut CI time by building only the changed module and its dependents with -pl and -amd.
More examples
A child module's POM
The parent's default relativePath is ../pom.xml, so a child in a sub-folder finds it without extra configuration. The child repeats only its artifactId and the dependencies it really uses.
<!-- shop/orders-api/pom.xml -->
<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.shop</groupId>
<artifactId>shop-parent</artifactId>
<version>1.4.0-SNAPSHOT</version>
</parent>
<!-- groupId and version are inherited -->
<artifactId>orders-api</artifactId>
<dependencies>
<!-- Version comes from the parent's dependencyManagement -->
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>shop-domain</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>The same layout in Gradle with a convention plugin
The file name of a precompiled script plugin becomes its id, so every module applies the shared settings with one line. This replaces a subprojects block in the root build and keeps each module's configuration readable on its own.
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
}
rootProject.name = "shop"
include("shop-domain", "orders-api", "orders-worker")
// build-logic/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
}
// build-logic/src/main/kotlin/shop.java-conventions.gradle.kts
plugins {
java
}
java {
toolchain { languageVersion = JavaLanguageVersion.of(25) }
}
repositories {
mavenCentral()
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
// orders-api/build.gradle.kts
plugins {
id("shop.java-conventions")
application
}
dependencies {
implementation(project(":shop-domain"))
}Building part of the reactor
-am follows dependencies downwards and -amd follows them upwards, which together let CI build only what a change can affect. In Gradle, building a project already compiles and packages the projects it depends on.
# Everything, in dependency order
./mvnw -B verify
# Only orders-api, plus the modules it needs (shop-domain)
./mvnw -pl orders-api -am verify
# shop-domain changed: rebuild it and everything that depends on it
./mvnw -pl shop-domain -amd verify
# Resume a failed build from a module instead of starting over
./mvnw verify -rf :orders-worker
# Gradle equivalents
./gradlew :orders-api:build
./gradlew :shop-domain:buildDependents
Discussion