Spring Boot 3 整合 Maven 多模块:分层架构设计与 ArchUnit 架构守护

0 阅读4分钟

前言

单体项目随着业务增长,代码臃肿、模块间横向调用、边界模糊等问题会逐渐显现。Maven 多模块架构是应对这一问题的经典方案:强制物理边界,让依赖关系可见、可控

但仅靠目录拆分远远不够——没人能保证工程师不会在 controller 里直接注入另一个模块的 MapperArchUnit 正是解决这一问题的工具:将架构规则编写成测试,在每次 mvn test 时自动校验,一旦有人违反分层约束,CI 直接失败。

本文结合 personal-blog-backend 项目,完整讲解:

  1. Maven 多模块的结构设计与依赖管理
  2. -api / -service 的分层边界原则
  3. ArchUnit 如何将架构规则变成可执行的测试
  4. 实际运行效果与踩坑记录

本文所有代码均来自开源项目 personal-blog-backend,基于 Spring Boot 3.5 + Java 21 构建,欢迎 ⭐ Star。


一、项目整体模块结构

personal-blog-backend/          ← 根模块(packaging: pom)
│
├── blog-common/                ← 公共组件(统一响应、异常、工具类)
├── blog-modules/               ← 领域业务模块聚合
│   ├── blog-module-system/     ← 认证与用户管理
│   │   ├── blog-system-api/    ← 接口层(DTO/VO/Service 接口,无 Spring 依赖)
│   │   └── blog-system-service/← 实现层(Controller/ServiceImpl/Mapper/Entity)
│   ├── blog-module-article/
│   ├── blog-module-comment/
│   ├── blog-module-file/
│   └── blog-module-ai/
├── blog-application/           ← 启动入口(聚合所有模块,端口 8080)
└── blog-admin-server/          ← 独立监控中心(端口 9000

关键设计原则:每个业务模块拆分为 -api-service 两层:

  • -api 模块:只包含 DTO、VO、Service 接口(interface)、Enum。零 Spring 依赖,可以被任意模块引用而不引发循环。
  • -service 模块:包含 ControllerServiceImplMapperEntity 等实现。只对外暴露 -api 接口,其他模块不得直接依赖 -service 的实现类。

二、根 POM 统一版本管理

2.1 BOM(Bill of Materials)依赖管理

项目采用根 pom.xml 作为唯一版本来源(Single Source of Truth),所有子模块不单独声明版本号。

<!-- personal-blog-backend/pom.xml -->
<properties>
    <java.version>21</java.version>
    <spring-boot.version>3.5.12</spring-boot.version>
    <mybatis-plus.version>3.5.14</mybatis-plus.version>
    <jjwt.version>0.13.0</jjwt.version>
    <springdoc.version>2.8.16</springdoc.version>
    <caffeine.version>3.2.2</caffeine.version>
    <langchain4j.version>1.12.2</langchain4j.version>
    <archunit.version>1.4.1</archunit.version>
</properties>

<dependencyManagement>
    <dependencies>
        <!-- Spring Boot BOM:自动管理 Spring 全家桶版本 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!-- MyBatis-Plus BOM -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-bom</artifactId>
            <version>${mybatis-plus.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

为什么用 BOM 而不是直接声明版本? Spring Boot 的依赖体系极其复杂,手动对齐版本极易出现兼容性冲突(NoSuchMethodErrorClassCastException)。使用 BOM 后,同一框架的所有组件自动保持版本一致。


三、-api / -service 分层边界原则

blog-module-system 为例,其内部依赖关系如下:

blog-system-api         ← 无 Spring 依赖,纯 Java
  └── xxxService (interface)
  └── xxxDTO / xxxVO

blog-system-service     ← 引用 blog-system-api
  └── xxxServiceImpl    ── 实现 xxxService
  └── xxxController     ── 注入 xxxService(接口,非实现)
  └── xxxMapper         ── MyBatis-Plus
  └── xxxEntity

其他模块需要使用用户信息时,只能引用 blog-system-api,不能直接 import blog-system-service 的实现类。这条规则由 ArchUnit 强制守护。


四、ArchUnit 架构守护

4.1 是什么,为什么用

ArchUnit 是一个基于 JUnit 的 Java 架构测试框架。原理:在测试时扫描 classpath 下的所有 .class 文件,构建依赖图,然后用定义的规则对图进行验证。

4.2 依赖引入

<!-- blog-application/pom.xml -->
<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <scope>test</scope>
</dependency>

4.3 配置类:集中管理包路径

// blog-application/src/test/java/com/blog/architecture/config/ArchUnitConfig.java
public final class ArchUnitConfig {
    public static final String CONTROLLER_PKG   = "com.blog..controller..";
    public static final String SERVICE_PKG      = "com.blog..service..";
    public static final String SERVICE_IMPL_PKG = "com.blog..service.impl..";
    public static final String REPOSITORY_PKG   = "com.blog..repository..";
    public static final String ENTITY_PKG       = "com.blog..entity..";
    public static final String DTO_PKG          = "com.blog..dto..";

    public static final Map<String, String> MODULE_PACKAGES = ImmutableMap.of(
            "system",  "com.blog.system",
            "article", "com.blog.article",
            "comment", "com.blog.comment",
            "file",    "com.blog.file"
    );
    public static final Set<String> BUSINESS_MODULE_NAMES = MODULE_PACKAGES.keySet();
    private ArchUnitConfig() {}
}

4.4 分层规则:强制单向依赖

// blog-application/src/test/java/com/blog/architecture/rules/LayerRule.java
public static final ArchRule LAYERED_ARCHITECTURE = layeredArchitecture()
        .consideringAllDependencies()
        .layer("Controller").definedBy(CONTROLLER_PKG)
        .layer("Service").definedBy(SERVICE_PKG)
        .layer("Repository").definedBy(REPOSITORY_PKG)
        .layer("Entity").definedBy(ENTITY_PKG)
        .layer("DTO").definedBy(DTO_PKG)
        .whereLayer("Controller").mayOnlyBeAccessedByLayers("Controller")
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Service")
        .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
        .whereLayer("Entity").mayOnlyBeAccessedByLayers("Repository", "Service")
        .whereLayer("DTO").mayOnlyBeAccessedByLayers("Controller", "Service", "Repository", "DTO");

4.5 模块隔离规则

// 禁止模块间循环依赖
public static final SliceRule NO_CYCLE_BETWEEN_MODULES = slices()
        .matching("com.blog.(*)..").should().beFreeOfCycles()
        .because("模块间循环依赖会导致微服务拆分失败");

// 禁止跨模块调用实现层(只能调用 -api 接口)
public static void checkNoCrossModuleImplDependency() {
    ArchitectureTest.BUSINESS_MODULES.forEach(current -> {
        String currentService = String.format("com.blog.%s.service..", current);
        ArchitectureTest.BUSINESS_MODULES.stream()
                .filter(other -> !other.equals(current))
                .forEach(other -> {
                    String otherImpl = String.format("com.blog.%s.service.impl..", other);
                    noClasses().that().resideInAPackage(currentService)
                            .should().dependOnClassesThat().resideInAPackage(otherImpl)
                            .because(String.format("%s 模块不应依赖 %s 的实现层", current, other))
                            .check(ArchitectureTest.CLASSES);
                });
    });
}

五、踩坑记录

5.1 consideringAllDependencies() vs 默认模式

加了后会检查传递依赖,规则更严格但可能误报。建议先用默认模式,等稳定后再启用。

5.2 Lombok 生成的类被误判

ArchitectureTest 中过滤生成类:

ClassFileImporter importer = new ClassFileImporter()
    .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
    .withImportOption(location -> !location.contains("$Builder"));

5.3 多模块 classpath 扫描路径

ArchUnit 测试必须写在 blog-application 的 test 目录下,否则扫描不到其他模块的类。


六、效果演示

违规时 mvn test 输出:

Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package 
'com.blog.article.service..' should depend on classes that reside in a package 
'com.blog.comment.service.impl..' was violated (1 times)

CI 失败,架构腐化被拦截在源头。


总结

实践价值
根 POM 统一版本管理消除版本冲突,单一升级入口
-api / -service 分层模块边界物理可见,便于未来微服务拆分
BOM 依赖管理Spring 全家桶版本一致性保障
ArchUnit 架构守护架构规则即测试,CI 自动拦截违规

参考资料

📌 专栏:Spring Boot 3 整合实战:主流技术栈深度整合 🔗 源码github.com/liusxml/per…