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.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Enforcing Clean Architecture in Spring Boot 3 with Maven Multi‑Module and ArchUnit

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 JAR

Key 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 → common

Web 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Backend DevelopmentMavenSpring BootLayered ArchitectureMulti-moduleArchUnit
Java Tech Workshop
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.