Build Tools & Modules

Project: A Multi-Module Build

15 min Lesson 10 of 13

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.