Multi-Module Projects

Split a code base into modules built together, share settings through a parent, and build only what changed.

Syntax<packaging>pom</packaging> <modules><module>shop-domain</module><module>orders-api</module></modules> mvn -pl orders-api -am verify

A 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

Example · xml
<!-- 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.

Example · xml
<!-- 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.

Example · kotlin
// 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.

Example · bash
# 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

  • Be the first to comment on this lesson.