How to structure a Spring Boot application as a multi-module Maven project — parent POM, dependency management, module layout, and keeping the domain module dependency-free.
Every substantial Java project I’ve joined mid-way through has had the same problem: a single module codebase where OrderController imports PaymentRepository, where infrastructure configuration bleeds into domain logic, and where the test for a core business rule requires spinning up a Spring context to run. The code works, mostly, but the architecture has no load-bearing walls — everything depends on everything.
Multi-module Maven solves this by making dependencies between layers explicit and enforceable. If your domain module has no Spring dependencies, it genuinely cannot accidentally depend on a Spring component. The module boundary is the enforcement mechanism, not a convention that someone might forget to follow at 4pm on a Friday.
This is the structure I reach for on Spring Boot services of any meaningful complexity, drawn from projects at DWP Digital and earlier.
A clean four-module layout for a Spring Boot service:
my-service/
├── pom.xml (parent POM)
├── my-service-domain/ (business logic, no framework dependencies)
├── my-service-application/ (use cases, orchestration, Spring-aware)
├── my-service-infrastructure/ (persistence, messaging, external APIs)
└── my-service-api/ (Spring MVC controllers, DTOs, HTTP layer)
The dependency direction flows one way: api → application → domain. infrastructure implements ports defined in domain. Nothing flows upward, and domain has no outward dependencies at all.
The parent POM manages versions and shared configuration. Its packaging is pom, and it never contains source code:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
<relativePath/>
</parent>
<modules>
<module>my-service-domain</module>
<module>my-service-application</module>
<module>my-service-infrastructure</module>
<module>my-service-api</module>
</modules>
<properties>
<java.version>21</java.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<testcontainers.version>1.19.7</testcontainers.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Internal modules — versions centralised here -->
<dependency>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service-infrastructure</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Third-party versions not managed by Spring Boot parent -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
Declare versions once in <dependencyManagement>. Child modules declare the <groupId> and <artifactId> only — no <version>. This is the single most impactful Maven discipline for keeping dependency versions consistent across modules.
The domain module contains your entities, value objects, domain services, and port interfaces. It has no Spring dependencies, no persistence annotations, no HTTP framework. This is not asceticism — it is deliberate. Domain logic that has no framework dependencies can be tested with plain JUnit, runs in milliseconds, and can be understood without knowing anything about the infrastructure around it:
<!-- my-service-domain/pom.xml -->
<parent>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>my-service-domain</artifactId>
<dependencies>
<!-- No Spring. No JPA. Deliberately nothing. -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
A port interface in the domain module:
// my-service-domain/src/main/java/.../domain/port/ClaimRepository.java
public interface ClaimRepository {
Optional<BenefitClaim> findById(String claimId);
BenefitClaim save(BenefitClaim claim);
}
The domain module defines what it needs from the outside world. It does not know how those needs are satisfied. That knowledge lives in the infrastructure module.
The application module contains use cases — the orchestration logic that calls domain services and talks to port interfaces. It knows about Spring (@Service, @Transactional) but not about specific infrastructure implementations:
<!-- my-service-application/pom.xml -->
<dependencies>
<dependency>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
// my-service-application/src/main/java/.../application/ClaimDecisionService.java
@Service
@RequiredArgsConstructor
@Transactional
public class ClaimDecisionService {
private final ClaimRepository claimRepository; // domain port — injected by Spring
private final PaymentInstructor paymentInstructor; // domain port — injected by Spring
public void processDecision(ClaimDecision decision) {
BenefitClaim claim = claimRepository.findById(decision.claimId())
.orElseThrow(() -> new ClaimNotFoundException(decision.claimId()));
claim.applyDecision(decision);
claimRepository.save(claim);
if (decision.isApproved()) {
paymentInstructor.instruct(PaymentInstruction.from(claim, decision));
}
}
}
The application module depends on domain interfaces, not infrastructure implementations. Spring’s dependency injection wires up the concrete infrastructure implementations at runtime.
The infrastructure module implements the port interfaces defined in the domain. It contains JPA entities, Kafka producers, HTTP clients, and everything else that touches external systems:
<!-- my-service-infrastructure/pom.xml -->
<dependencies>
<dependency>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
</dependencies>
// my-service-infrastructure/src/main/java/.../infrastructure/persistence/JpaClaimRepository.java
@Repository
@RequiredArgsConstructor
public class JpaClaimRepository implements ClaimRepository {
private final ClaimJpaRepository jpaRepository;
private final ClaimMapper mapper;
@Override
public Optional<BenefitClaim> findById(String claimId) {
return jpaRepository.findById(claimId).map(mapper::toDomain);
}
@Override
public BenefitClaim save(BenefitClaim claim) {
ClaimEntity entity = mapper.toEntity(claim);
return mapper.toDomain(jpaRepository.save(entity));
}
}
The JPA @Entity classes live here too — separate from the domain model. A ClaimMapper (MapStruct is ideal) handles conversion between the domain model and the persistence entity. This separation means your domain model is never polluted with @Column, @ManyToOne, or JPA-specific lifecycle concerns.
The API module contains the Spring MVC controllers, request/response DTOs, and the @SpringBootApplication main class. It depends on both application and infrastructure (so that Spring can find the infrastructure implementations to inject):
<!-- my-service-api/pom.xml -->
<dependencies>
<dependency>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service-application</artifactId>
</dependency>
<dependency>
<groupId>uk.co.trinitylogic</groupId>
<artifactId>my-service-infrastructure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
The Spring Boot plugin goes on the API module’s POM only — this is the executable module:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Testing utilities — custom Testcontainers configurations, base test classes, test fixtures — belong in a separate test utility module to avoid duplicating them across all modules:
<!-- my-service-test-support/pom.xml -->
<artifactId>my-service-test-support</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
</dependency>
</dependencies>
Other modules declare this as a test scope dependency so it doesn’t leak into production classpath.
Maven will fail the build with a CYCLE error if you introduce a circular module dependency — A depends on B which depends on A. This is actually a feature. If you find yourself wanting to create a cycle, it almost always means you have poorly separated responsibilities that need to be extracted into a new module or moved to a different layer.
The domain module is your north star: it should have zero dependencies on other internal modules. If you’re ever tempted to add an infrastructure or application dependency to domain, stop and reconsider the design. That direction of dependency means your domain logic is depending on implementation details, which undermines the entire point of the structure.
Build the project from the root and the module dependency order is resolved automatically:
mvn clean package -pl my-service-api -am
The -am flag builds all modules that my-service-api depends on, in the correct order. You don’t manage build order manually — Maven’s reactor handles it.
If you’re modernising a Spring Boot monolith or designing a new service and want to get the architecture right from the start, get in touch.