Enforcing Clean Architecture in Spring Boot 3 with Maven Multi‑Module and ArchUnit
Spring Boot 3 projects quickly outgrow single‑module structures, so this guide shows how to split a codebase into Maven multi‑module layers, define clear dependency rules, configure parent and child POMs, and use ArchUnit tests to automatically enforce architectural boundaries during builds.
Why Adopt Maven Multi‑Module Layered Architecture
Most projects start as a single module with few files and a small team, which works well initially. As business evolves and the team grows, a monolithic module suffers from blurred package boundaries, controllers directly injecting mappers, circular dependencies, duplicated utilities, and unrestricted cross‑layer calls, leading to a "spaghetti" codebase that is hard to compile, debug, and maintain.
Relying on verbal conventions and manual code reviews is fragile; a single violation can quickly break the rules. A production‑grade solution is to combine Maven multi‑module physical separation with ArchUnit automated architecture checks . The modules enforce responsibility boundaries at the project structure level, while ArchUnit unit tests fail the build when code violates layering rules.
Four Pain Points of a Single‑Module Project
Completely blurred responsibility boundaries – Controllers, services, mappers, and utilities are mixed, allowing newcomers to inject a mapper directly in a controller and scatter business logic.
Circular dependencies are hard to eliminate – Packages reference each other (A calls B, B calls A), embedding hidden compile‑time risks.
Low compilation and reuse efficiency – Any change triggers a full project recompilation; shared utilities cannot be versioned independently and are often copied.
Architecture guidelines become ineffective – Manual code reviews cannot cover every commit; once a rule is broken, the “broken‑window” effect spreads quickly.
Core Design Principles for Layered Architecture
Dependency direction principle : Upper layers may depend on lower layers, but lower layers must never depend on upper layers (e.g., Service may call DAO, DAO must not reference Service).
Single‑responsibility principle : Each module does one thing – persistence only handles data access, the interface layer only handles HTTP, and business logic resides in the Service layer.
Isolation of change principle : Volatile business logic stays in inner layers, while stable common capabilities sink to the bottom; protocol and database changes do not affect each other.
Reusability principle : Common tools, data models, and base components live in a shared module, allowing multiple projects to depend on them without duplication.
Standard Maven Multi‑Module Five‑Layer Split
Module Name | Responsibility | Dependency Scope | Exposed Capability
--------------|--------------------------|--------------------------------------|-------------------------------
xxx-common | Common base layer | No business modules, only third‑party libs | Global constants, common exceptions, utils, DTOs, enums
xxx-dao | Data persistence layer | Depends only on common | DB entities, Mapper/Repository, DAO services, persistence config
xxx-service | Business logic layer | Depends on dao + common | Service interfaces, implementations, business rules, transaction control
xxx-web | Interface layer | Depends on service + common | Controllers, interceptors, parameter validation, request context, global exception
xxx-boot | Startup packaging layer | Depends on web + all sub‑modules | Main class, configuration files, packaged as executable JARKey design notes :
common is the foundation – pure generic capabilities, never import business code, ensuring universal reuse.
dao only performs data read/write; all business rules are lifted to the Service layer, making database swaps painless.
web only adapts protocols – parameter validation, request wrapping, and response handling, keeping business logic out of HTTP concerns.
boot is the entry point – assembles all modules, loads configuration, and produces the executable JAR without containing business code.
Module Dependency Flow
boot → web → service → dao → commonWeb layer must not depend directly on DAO; it must go through Service.
DAO must never import any class from Service or Web.
Common must not depend on any upper business module.
Package Naming Conventions
Persistence: com.xxx.dao.entity (entities), com.xxx.dao.mapper (mapper interfaces), com.xxx.dao.config (data source config).
Business: com.xxx.service (interfaces), com.xxx.service.impl (implementations), com.xxx.service.dto (business DTOs).
Interface: com.xxx.web.controller, com.xxx.web.interceptor, com.xxx.web.vo (response objects).
Common: com.xxx.common.utils, com.xxx.common.exception, com.xxx.common.constants.
Parent POM – Unified Dependency & Version Management
<project>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-multi-module</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>demo-common</module>
<module>demo-dao</module>
<module>demo-service</module>
<module>demo-web</module>
<module>demo-boot</module>
</modules>
<properties>
<java.version>17</java.version>
<mybatis.version>3.0.3</mybatis.version>
<archunit.version>1.3.0</archunit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-common</artifactId>
<version>${project.version}</version>
</dependency>
... (other modules) ...
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>Child Module POM Examples
demo-common – only generic utilities, no Spring business dependencies:
<project>
<parent>...</parent>
<artifactId>demo-common</artifactId>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
</project>demo-dao – depends on common and MyBatis, no service or web imports:
<project>
<parent>...</parent>
<artifactId>demo-dao</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-common</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>demo-service – depends on dao and Spring Boot starter:
<project>
<parent>...</parent>
<artifactId>demo-service</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-dao</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
</dependencies>
</project>demo-web – depends on service and Spring MVC:
<project>
<parent>...</parent>
<artifactId>demo-web</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>demo-boot – the only module that produces an executable JAR and runs ArchUnit tests:
<project>
<parent>...</parent>
<artifactId>demo-boot</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.example.DemoApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>Main Application Class and Scan Configuration
package com.example;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.example")
@MapperScan("com.example.dao.mapper")
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}Architectural Constraints That Must Be Enforced
Layer dependency rules
Controller may only inject Service, never Mapper/DAO directly.
Service must not reference any class from Controller or Web layer.
DAO must not depend on Service or Web classes.
Data object boundaries
Database entity DOs may only flow within DAO and Service; they must never be returned to the front‑end.
Front‑end VO objects are confined to the Web layer and cannot be passed into DAO.
Each layer defines its own data objects; cross‑layer mixing is prohibited.
Module responsibility constraints
Transactional annotations belong exclusively to Service classes.
Parameter‑validation annotations stay on Web‑layer VO classes; business validation lives in Service.
Database configuration resides only in DAO; upper layers remain unaware of persistence details.
Circular‑dependency iron law
Modules must not have circular dependencies.
Packages must not reference each other in a cycle.
Service‑to‑Service calls are allowed only in one direction; mutual calls are forbidden.
ArchUnit – Automated Architecture Guard
What is ArchUnit?
ArchUnit is a Java testing library that lets you write unit tests to verify architecture rules such as layer dependencies, naming conventions, package boundaries, circular dependencies, and annotation usage. A failing rule makes the test fail, causing Maven to abort the build, which is far more reliable than manual code review.
Core Architecture Tests (example)
package com.example.arch;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Test;
import org.springframework.stereotype.Controller;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
public class ArchitectureGuardTest {
// Import all business classes
private final JavaClasses classes = new ClassFileImporter()
.importPackages("com.example");
// 1. Layered dependency rule (core)
@Test
void layered_dependency_rule() {
ArchRule rule = layeredArchitecture()
.consideringAllDependencies()
.layer("Common").definedBy("com.example.common..")
.layer("Dao").definedBy("com.example.dao..")
.layer("Service").definedBy("com.example.service..")
.layer("Web").definedBy("com.example.web..")
.layer("Boot").definedBy("com.example.boot..")
// Upper layers may depend on lower layers
.whereLayer("Boot").mayOnlyBeAccessedByLayers()
.whereLayer("Web").mayOnlyBeAccessedByLayers("Boot")
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web", "Boot")
.whereLayer("Dao").mayOnlyBeAccessedByLayers("Service", "Boot")
.whereLayer("Common").mayOnlyBeAccessedByLayers("Dao", "Service", "Web", "Boot");
rule.check(classes);
}
// 2. Class naming conventions
@Test
void class_naming_rule() {
// Controllers must end with 'Controller'
classes().that().areAnnotatedWith(RestController.class)
.and().resideInAPackage("..web.controller..")
.should().haveSimpleNameEndingWith("Controller")
.check(classes);
// Service implementations must end with 'ServiceImpl'
classes().that().resideInAPackage("..service.impl..")
.should().haveSimpleNameEndingWith("ServiceImpl")
.check(classes);
// Mappers must end with 'Mapper'
classes().that().resideInAPackage("..dao.mapper..")
.should().haveSimpleNameEndingWith("Mapper")
.check(classes);
// Entity classes must not end with VO or DTO
classes().that().resideInAPackage("..dao.entity..")
.should().haveSimpleNameNotEndingWith("VO")
.andShould().haveSimpleNameNotEndingWith("DTO")
.check(classes);
}
// 3. Prohibit Controller -> Mapper direct calls
@Test
void controller_not_access_mapper_directly() {
noClasses().that().resideInAPackage("..web.controller..")
.should().dependOnClassesThat().resideInAPackage("..dao.mapper..")
.because("Controller must go through Service")
.check(classes);
}
// 4. Annotation usage rules
@Test
void annotation_usage_rule() {
// Controllers must be annotated with @RestController
classes().that().resideInAPackage("..web.controller..")
.and().haveSimpleNameEndingWith("Controller")
.should().beAnnotatedWith(RestController.class)
.check(classes);
// Transactional only on Service layer
noClasses().that().resideInAPackage("..web..")
.should().beAnnotatedWith(org.springframework.transaction.annotation.Transactional.class)
.because("Transactional belongs to Service")
.check(classes);
}
// 5. No circular dependencies
@Test
void no_cycle_dependency() {
noClasses().should().dependOnClassesThat().dependOnThemselves()
.because("Circular dependencies are forbidden")
.check(classes);
}
// 6. Utility classes should not be Spring beans
@Test
void util_class_rule() {
noClasses().that().resideInAPackage("..common.utils..")
.should().beAnnotatedWith(Service.class)
.andShould().beAnnotatedWith(org.springframework.stereotype.Component.class)
.because("Utility classes should be static and not managed by Spring")
.check(classes);
}
}CI/CD Integration Points
Run mvn test automatically on merge‑request submissions; any failing ArchUnit rule aborts the pipeline.
All new architectural rules must be added as test cases, ensuring the whole team follows them.
Failure of a single rule prevents code from being merged, guaranteeing compliance.
Common Pitfalls & Solutions
Mapper not scanned – The default scan only covers the package of the main class; add scanBasePackages and @MapperScan with the full mapper path.
Inconsistent module versions – Declare all third‑party versions in the parent dependencyManagement and let child modules inherit them.
Circular service dependencies – Extract shared logic to the common module or use event‑driven communication to break the cycle.
Main class missing after packaging – Configure spring-boot-maven-plugin with mainClass only in the boot module; other modules remain plain JARs.
ArchUnit tests miss classes – Import the root package (e.g., com.example) so all sub‑modules are included; update the import path when new modules are added.
Conclusion
Maven multi‑module layering is the first step to governing the architecture of medium‑to‑large Spring Boot applications. It physically separates responsibilities, making the codebase clearer and more maintainable. ArchUnit acts as an automated guard, turning manual conventions into executable tests that block builds on violations, thereby eliminating architectural decay at its source.
Combined, they ensure flexibility, maintainability, and consistent code quality across the team. This practice scales from small team code consistency to enterprise‑level platform and component evolution, making it a high‑ROI engineering approach for backend engineers.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
