Project: A Multi-Module Build
Real-world Java applications are rarely a single flat source tree. Teams split large codebases into modules — discrete sub-projects that each own a coherent slice of the domain, expose a deliberate API to their neighbours, and are compiled, tested, and versioned independently. This lesson walks you through structuring a small but realistic multi-module project using Maven (with notes on the Gradle equivalent), explaining the trade-offs at every step.
Why Split into Modules?
Before writing a line of XML or Kotlin DSL, it is worth asking: should you even go multi-module?
- Encapsulation at scale. A separate module has its own
pom.xml; code in other modules can only use what you declare as a dependency. That prevents the "everything depends on everything" tangle that plagues large monoliths.
- Incremental builds. Both Maven and Gradle can skip recompiling a module whose inputs have not changed, slashing build times on large projects.
- Independent deployment cadence. A
domain module with stable contracts can be published to an internal registry once and consumed by multiple services without recompiling the whole repo.
- Clearer boundaries for teams. Team A owns the
payments module; team B owns notifications. Ownership is unambiguous.
The cost of splitting too early. Multi-module builds add ceremony — more POM files, explicit inter-module dependencies, and a root build file. Apply the pattern when a module boundary is genuinely stable and the coupling cost is real. Splitting a 2 000-line app into six modules is usually premature.
Project: E-Commerce Order Service
We will build a trimmed-down order service with three modules:
- domain — pure Java domain model (entities, value objects, repository interfaces). No framework dependencies; designed to be stable and independently testable.
- application — use-case layer. Depends on
domain. Contains service classes that orchestrate domain objects.
- web — delivery layer (thin HTTP adapter). Depends on
application. This is the entry point with main().
This is a deliberate layered architecture; the dependency arrow always points inward.
Maven: Root POM
Create the root directory order-service/. The root pom.xml is only a build descriptor — it declares packaging as pom, lists all child modules, and centralises dependency versions via <dependencyManagement>.
<!-- order-service/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>
<groupId>com.example</groupId>
<artifactId>order-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>domain</module>
<module>application</module>
<module>web</module>
</modules>
<properties>
<java.version>17</java.version>
<maven.compiler.release>17</maven.compiler.release>
<junit.version>5.10.2</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<!-- internal modules: also managed here so children pick up
the parent version without repeating it -->
<dependency>
<groupId>com.example</groupId>
<artifactId>domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>application</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
Always put version numbers in the root <dependencyManagement> block. Child POMs inherit from the parent, so they declare a dependency without a version — Maven resolves it from the managed set. This is the single most effective way to prevent version drift across a large project.
The domain Module
The domain/pom.xml declares the parent and nothing else — no third-party runtime dependencies, by design.
<!-- order-service/domain/pom.xml -->
<project>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>order-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>domain</artifactId>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<!-- version resolved from parent dependencyManagement -->
</dependency>
</dependencies>
</project>
The domain source code uses Java 17 records — immutable value objects at zero boilerplate:
// domain/src/main/java/com/example/domain/model/OrderId.java
package com.example.domain.model;
public record OrderId(String value) {
public OrderId {
if (value == null || value.isBlank())
throw new IllegalArgumentException("OrderId must not be blank");
}
}
// domain/src/main/java/com/example/domain/model/Order.java
package com.example.domain.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record Order(
OrderId id,
String customerId,
List<OrderLine> lines,
Instant placedAt
) {
public BigDecimal total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
// domain/src/main/java/com/example/domain/model/OrderLine.java
package com.example.domain.model;
import java.math.BigDecimal;
public record OrderLine(String sku, int quantity, BigDecimal unitPrice) {
public BigDecimal subtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
// domain/src/main/java/com/example/domain/port/OrderRepository.java
package com.example.domain.port;
import com.example.domain.model.Order;
import com.example.domain.model.OrderId;
import java.util.Optional;
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
The application Module
The application module depends on domain and wires together use cases. It knows nothing about HTTP or persistence details.
<!-- order-service/application/pom.xml -->
<project>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>order-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>application</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>domain</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
// application/src/main/java/com/example/application/PlaceOrderCommand.java
package com.example.application;
import com.example.domain.model.OrderLine;
import java.util.List;
public record PlaceOrderCommand(String customerId, List<OrderLine> lines) {}
// application/src/main/java/com/example/application/OrderService.java
package com.example.application;
import com.example.domain.model.Order;
import com.example.domain.model.OrderId;
import com.example.domain.port.OrderRepository;
import java.time.Instant;
import java.util.UUID;
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public OrderId place(PlaceOrderCommand cmd) {
var id = new OrderId(UUID.randomUUID().toString());
var order = new Order(id, cmd.customerId(), cmd.lines(), Instant.now());
repository.save(order);
return id;
}
}
Constructor injection, not field injection. OrderService receives OrderRepository through its constructor. This makes the dependency explicit, keeps the class framework-agnostic, and makes unit tests trivial — just pass a mock or in-memory implementation.
The web Module
The web module is the delivery layer. It depends on application (and transitively on domain), contains main(), and wires together concrete infrastructure (like an in-memory repository for this example).
// web/src/main/java/com/example/web/InMemoryOrderRepository.java
package com.example.web;
import com.example.domain.model.Order;
import com.example.domain.model.OrderId;
import com.example.domain.port.OrderRepository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
@Override
public void save(Order order) {
store.put(order.id().value(), order);
}
@Override
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(store.get(id.value()));
}
}
// web/src/main/java/com/example/web/App.java
package com.example.web;
import com.example.application.OrderService;
import com.example.application.PlaceOrderCommand;
import com.example.domain.model.OrderLine;
import java.math.BigDecimal;
import java.util.List;
public class App {
public static void main(String[] args) {
var repo = new InMemoryOrderRepository();
var service = new OrderService(repo);
var cmd = new PlaceOrderCommand(
"customer-42",
List.of(new OrderLine("SKU-001", 2, new BigDecimal("29.99")))
);
var id = service.place(cmd);
System.out.println("Order placed: " + id.value());
repo.findById(id)
.ifPresent(o -> System.out.println("Total: " + o.total()));
}
}
Building the Whole Project
From the root directory, a single command builds every module in the correct dependency order:
# install all modules into the local Maven repository
mvn install
# run only tests across all modules
mvn test
# build only the application module and its dependencies (Maven reactor)
mvn install -pl application -am
# skip tests during fast iteration (never in CI)
mvn package -DskipTests
Gradle Equivalent in Brief
The same structure in Gradle uses a settings.gradle.kts at the root to declare sub-projects, and each sub-project has its own build.gradle.kts. The root build.gradle.kts applies common plugins and a subprojects { ... } block for shared config. Inter-module dependencies use the same notation: implementation(project(":domain")).
// settings.gradle.kts (root)
rootProject.name = "order-service"
include("domain", "application", "web")
// application/build.gradle.kts
plugins { java }
dependencies {
implementation(project(":domain"))
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
}
Circular module dependencies are a build error, not a runtime error. If domain imports from application, Maven and Gradle both refuse to build. Use this as a forcing function: circular dependencies reveal a design problem that must be resolved in the model, not worked around in the build tool.
Professional Best Practices
- One
module-info.java per module (optional but recommended). Declare exports explicitly to enforce encapsulation at the JVM level, not just by convention.
- Keep the domain module dependency-free. If you find yourself adding a framework annotation to a domain class, that is a boundary violation worth fixing.
- Version all internal modules together. Use the parent POM version for every child; a single
mvn versions:set -DnewVersion=2.0.0 updates the whole reactor.
- Use the Maven Wrapper (
mvnw) or Gradle Wrapper (gradlew). Commit them to source control so every developer and every CI agent uses the exact same build tool version without a local install.
- Publish stable modules to an artifact registry (Nexus, Artifactory, GitHub Packages). Internal consumers then treat them like any third-party library, enabling independent release cycles.
Summary
A multi-module build gives you enforced boundaries, incremental compilation, and a clean separation of concerns that scales with team size. The pattern — root POM as reactor, child modules with explicit inter-module dependencies, dependency versions managed centrally — is the same whether you choose Maven or Gradle. The domain module is the core: keep it pure, and the rest of the system snaps around it.