Sitemap
devdomain

DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community.

Multi-Module Maven Projects Done Right

10 min readSep 1, 2026

--

Almost every Java codebase starts as a single module. That is the correct way to start — premature modularization is a real and expensive mistake. But there comes a point where one pom.xml and one src/main/java tree stops serving you: the build is slow because everything recompiles together, your domain logic has quietly grown dependencies on your web framework, and two teams keep stepping on the same files.

A multi-module Maven project is the tool for that moment. Done well, it gives you fast incremental builds, enforced architectural boundaries, and independently testable units. Done badly, it gives you a tangle of circular dependencies, copy-pasted version numbers, and a build that is slower than the monolith it replaced.

This article is about doing it well. We will cover when to actually split, how to structure modules for a layered or hexagonal application, how the parent POM centralizes everything (without rehashing BOM mechanics — this author has covered those separately), and the mistakes that turn a clean module graph into mud.

When to split — and when not to

Splitting modules has a cost: more pom.xml files, a dependency graph to reason about, and the discipline to keep the boundaries clean. Pay that cost when you get something concrete in return.

Split when:

  • You need to enforce an architectural boundary. A domain module that does not depend on Spring or JPA cannot accidentally import them — the compiler enforces it. This is the strongest reason of all.
  • You have genuinely reusable artifacts. A shared common library consumed by several services, or a client SDK you publish separately.
  • Build and test time has become painful. Module-level granularity lets Maven (and CI) rebuild only what changed.
  • Independent release cadence is required. A library that other teams depend on may need its own version and release rhythm.
  • Multiple deployables share code. A web app and a batch job that both use the same domain and persistence code.

Do not split when:

  • The project is small and cohesive. One module is fine, often for years.
  • You are splitting by technical layer “because that is what you do.” Modules should reflect dependencies and boundaries, not folders.
  • You would create modules that always change together. If service and web never change independently, the split adds ceremony with no payoff.

A useful litmus test: a module boundary should correspond to a dependency boundary. If module A and module B always change together and always deploy together, they probably want to be one module.

The shape of a parent (aggregator) POM

The parent POM in a multi-module build plays two distinct roles that people often conflate:

  1. Aggregation — the <modules> list that tells Maven, "build these together, in dependency order." This is reactor behavior.
  2. Inheritance — being the <parent> of child modules so they inherit plugin configuration, properties, and <dependencyManagement>.

These are independent. A POM can aggregate without being inherited, and vice versa. In practice you usually want one POM that does both. Its packaging is pom, and it has no source code of its own.

<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>net.veritech.orders</groupId>
<artifactId>orders-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<!-- Aggregation: the reactor build order is derived from these -->
<modules>
<module>orders-domain</module>
<module>orders-application</module>
<module>orders-persistence</module>
<module>orders-web</module>
<module>orders-bootstrap</module>
</modules>
<properties>
<java.version>21</java.version>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Single source of truth for internal + external versions -->
<spring-boot.version>3.3.2</spring-boot.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Import the Spring Boot BOM so child modules omit versions -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Manage versions of OUR OWN modules in exactly one place -->
<dependency>
<groupId>net.veritech.orders</groupId>
<artifactId>orders-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>net.veritech.orders</groupId>
<artifactId>orders-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>net.veritech.orders</groupId>
<artifactId>orders-persistence</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>

The single most valuable habit here: manage the versions of your own internal modules in <dependencyManagement>, keyed off ${project.version}. When child modules depend on each other, they reference the artifact without a version. There is exactly one place a version lives, and bumping the project bumps everything coherently.

A hexagonal module structure

Layered and hexagonal (ports-and-adapters) architectures map beautifully onto modules because their whole point is controlling the direction of dependencies. The rule is: dependencies point inward, toward the domain. Modules let the compiler enforce that rule instead of relying on code review.

Here is the structure for our orders service:

orders-parent/                 (pom)        aggregator + dependency management
├── orders-domain/ (jar) entities, value objects, port interfaces
├── orders-application/ (jar) use cases / services, orchestrates ports
├── orders-persistence/ (jar) JPA adapter — implements repository ports
├── orders-web/ (jar) REST adapter — controllers, DTOs
└── orders-bootstrap/ (jar) @SpringBootApplication, wiring, deployable

The dependency direction:

web ─┐
├──> application ──> domain
persistence ─┘
bootstrap ──> (web, persistence, application, domain) // composition root

Notice what the domain depends on: nothing. Not Spring, not JPA, not Jackson. That is the entire value proposition — your business rules are a plain-Java island that cannot be polluted because the dependency simply is not on its classpath.

orders-domain — the pure core

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>net.veritech.orders</groupId>
<artifactId>orders-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>orders-domain</artifactId>
  <dependencies>
<!-- Tests only. No framework dependencies on purpose. -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
// orders-domain
public record Money(BigDecimal amount, String currency) { }
public class Order {
private final OrderId id;
private final List<OrderLine> lines;
private OrderStatus status;
// invariants enforced here — pure domain logic, no annotations
public Money total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(new Money(BigDecimal.ZERO, "EUR"), Money::plus);
}
}
// The PORT — an interface the domain owns, the persistence adapter implements
public interface OrderRepository {
Optional<Order> findById(OrderId id);
Order save(Order order);
}

orders-application — use cases

<artifactId>orders-application</artifactId>
<dependencies>
<dependency>
<groupId>net.veritech.orders</groupId>
<artifactId>orders-domain</artifactId>
<!-- no version: it comes from the parent's dependencyManagement -->
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
</dependencies>
// orders-application — depends on the domain PORT, not any concrete DB
@Service
public class PlaceOrderUseCase {
private final OrderRepository orders; // a domain port
    public PlaceOrderUseCase(OrderRepository orders) {
this.orders = orders;
}
public OrderId place(NewOrderCommand command) {
Order order = Order.from(command);
return orders.save(order).id();
}
}

orders-persistence — the JPA adapter

// orders-persistence — IMPLEMENTS the domain port using JPA
@Repository
class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository jpa;
private final OrderEntityMapper mapper; // MapStruct, e.g.
    public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
public Order save(Order order) {
return mapper.toDomain(jpa.save(mapper.toEntity(order)));
}
}

The persistence module depends on orders-domain (to see the port and the domain types) and on Spring Data JPA. The domain has no idea JPA exists.

Get Marcelo Domingues’s stories in your inbox

Join Medium for free to get updates from this writer.

A subtle but important detail: the JPA @Entity (OrderEntity) lives in orders-persistence, not in orders-domain. The domain has its own Order aggregate with its own invariants; the persistence entity is a separate, annotated, mutable shape that exists only to satisfy Hibernate. The OrderEntityMapper translates between them. Newcomers often resist this duplication and try to annotate the domain Order directly with @Entity — at which point the domain module needs JPA on its classpath, and the boundary collapses. The two-model cost is real but it is the price of keeping the core pure, and it pays off the first time you change your persistence technology without touching a line of business logic.

orders-web — the REST adapter

The web module owns controllers and their DTOs, depends on orders-application to invoke use cases, and never touches persistence directly:

// orders-web
@RestController
@RequestMapping("/orders")
class OrderController {
private final PlaceOrderUseCase placeOrder;
    OrderController(PlaceOrderUseCase placeOrder) {
this.placeOrder = placeOrder;
}
@PostMapping
ResponseEntity<OrderResponse> create(@RequestBody @Valid NewOrderRequest request) {
OrderId id = placeOrder.place(request.toCommand());
return ResponseEntity.created(URI.create("/orders/" + id.value()))
.body(new OrderResponse(id.value()));
}
}

Web DTOs (NewOrderRequest, OrderResponse) stay in the web module. They are wire contracts, not domain types, and leaking them inward would couple your business rules to your HTTP API shape.

orders-bootstrap — the composition root

This is the only module that knows about everything. It is the deployable: it carries @SpringBootApplication, the spring-boot-maven-plugin, and application.yml.

<artifactId>orders-bootstrap</artifactId>
<dependencies>
<dependency><groupId>net.veritech.orders</groupId><artifactId>orders-web</artifactId></dependency>
<dependency><groupId>net.veritech.orders</groupId><artifactId>orders-persistence</artifactId></dependency>
<dependency><groupId>net.veritech.orders</groupId><artifactId>orders-application</artifactId></dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Only the deployable repackages into an executable jar -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution><goals><goal>repackage</goal></goals></execution>
</executions>
</plugin>
</plugins>
</build>

A common anti-pattern is putting spring-boot-maven-plugin's repackage goal on the parent or on library modules. Library modules should produce plain jars so other modules can depend on them. Only the bootstrap/deployable module repackages into a fat, executable jar.

Sharing configuration the right way

What you want to share How to share it Avoid Dependency versions <dependencyManagement> in the parent Repeating <version> per module Plugin versions/config <pluginManagement> in the parent Configuring plugins per module Common properties <properties> in the parent Duplicated property values Reusable test fixtures A dedicated test-support module or test-jar Copy-pasting test helpers Shared static config files A common jar with resources, or external config Symlinks / build-time copying

<pluginManagement> is the counterpart to <dependencyManagement> for plugins: declare the version and default config once in the parent, and child modules activate the plugin with just <groupId>/<artifactId>. This keeps every module on the same Surefire, compiler, and Spring Boot plugin versions.

For shared test code, prefer a small test-support module that other modules depend on with <scope>test</scope>. The test-jar mechanism works but couples modules to each other's test sources in ways that surprise people; an explicit module is cleaner.

Building and releasing

The reactor builds the whole graph in dependency order from the parent:

mvn clean install            # builds every module, in correct order

For fast iteration, build a single module and the things it needs — -am ("also make") pulls in upstream dependencies, -pl selects the module:

# Build orders-web plus everything it depends on
mvn -pl orders-web -am install
# Build only changed modules since you already installed the rest
mvn -pl orders-persistence install

Resume from a failure point instead of rebuilding the world:

mvn install -rf :orders-web   # resume from orders-web onward

Run the reactor in parallel across CPU cores — independent modules build concurrently:

mvn -T 1C clean install       # one thread per core

For releasing, the cleanest modern approach is the Maven CI Friendly versions feature with a ${revision} property, which lets the whole reactor share one version without the heavyweight maven-release-plugin git dance. Set <version>${revision}</version> in the parent and pass -Drevision=1.2.0 at build time, then deploy:

<!-- parent pom -->
<groupId>net.veritech.orders</groupId>
<artifactId>orders-parent</artifactId>
<version>${revision}</version>
<packaging>pom</packaging>
<properties>
<revision>1.0.0-SNAPSHOT</revision>
</properties>
# CI overrides the version for a real release, no POM mutation needed
mvn -Drevision=1.2.0 clean deploy

For this to resolve in published artifacts, add the flatten-maven-plugin so consumers see the concrete version instead of the literal ${revision} placeholder. It plays far better with CI than mutating POMs and tagging mid-build, and it keeps every module on one coherent version automatically.

A note on the reactor and SNAPSHOTs

During development, inter-module dependencies resolve from the reactor itself — Maven uses the in-memory built artifacts, not your local .m2 cache, as long as the modules are part of the same build. This is why mvn -pl orders-web -am works: -am pulls the upstream modules into the same reactor so orders-web sees freshly built orders-domain classes rather than a stale installed jar. If you build orders-web alone (no -am) after changing orders-domain without installing it, you will silently compile against the old domain jar from .m2 — a classic source of "it works on my machine but not in CI" confusion.

Common mistakes

  • Circular module dependencies. If application depends on persistence and persistence depends back on application, the reactor cannot order the build and Maven fails. This is almost always a sign your ports are in the wrong module. Keep ports in the domain; let adapters depend inward.
  • Versions on internal dependencies. Writing <version>1.0.0-SNAPSHOT</version> on every inter-module dependency means a release bump touches dozens of lines and one will be wrong. Manage internal versions in the parent's <dependencyManagement>.
  • Putting repackage everywhere. Fat-jarring library modules breaks downstream consumers (they can't cleanly depend on a repackaged Spring Boot jar). Only the deployable repackages.
  • Over-splitting by layer. Five modules for a 2,000-line app is pure overhead. Modules earn their keep when they enforce a real boundary or enable independent build/release.
  • Leaking framework types into the domain. The whole point of the domain module is its zero-framework classpath. The moment a JPA annotation or a Spring type appears there, you have lost the enforcement and might as well not have split.
  • Forgetting pluginManagement. Configuring the compiler or Surefire plugin in each module guarantees drift. One module ends up on a different version and behaves subtly differently in CI.
  • Treating the parent as a place for code. The aggregator POM has packaging>pom and no src. Business code in the parent is a smell that you have not actually separated concerns.
  • Building the whole reactor in CI for every change. With -pl ... -am and good caching you can build only what changed. Rebuilding everything every time throws away the main speed benefit of modularizing.

Wrapping up

Multi-module Maven is not about having more files — it is about making dependency direction explicit and enforceable. The parent POM becomes your single source of truth for versions and plugin configuration. A hexagonal split gives you a pure domain core the compiler protects, adapters that depend inward, and a thin bootstrap module that wires it all into a deployable.

Start as a monolith. Split when a real boundary, a real reuse case, or a real build-time problem demands it. When you do split, manage internal versions centrally, keep repackage on the deployable alone, build incrementally with -pl/-am, and never let a framework type sneak into the domain. Get those right and the module graph stays a help, not a hindrance, as the codebase grows.

If this helped, follow devdomain for more deep Java build and platform engineering. Drop a comment with your hardest multi-module war story — circular deps, version drift, or a reactor build that mysteriously slowed to a crawl. Let’s compare notes.

--

--

Written by Marcelo Domingues

🚀 Lead Software Engineer for Siemens | Crafting Code & Words | Empowering Tech Enthusiasts ✨ 📲 LinkedIn: https://www.linkedin.com/in/marcelogdomingues/

Published in devdomain

DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community.