《Spring Boot 实战》1.3 分层与包结构约定

讲清按功能分包与按层分包的取舍,给出图书借阅服务「模块边界 + 模块内按层」的分包方案,演示用 package-private 收窄暴露面、约束模块间依赖方向,并用 ArchUnit 把「禁止循环依赖」「领域层保持纯净」等架构约束固化成会在 CI 上失败的测试。

本节目标:把 1.1 的模块边界落到包结构上,掌握按功能与按层分包的取舍、用 package-private 收窄暴露面、用 ArchUnit 把「禁止循环依赖」这类架构约束变成会失败的测试。
适用版本:Spring Boot 4.1.x(Java 21)

1.3 分层与包结构约定

1.1 决定「拆不拆模块」,1.2 让骨架能构建。模块边界解决了「哪些代码不能互相看见」,但模块内部的包怎么分、类怎么暴露,还没有约定。这一层约定比模块边界更细,却同样重要——模块边界由 Maven 强制(没依赖就编译不过),包边界只能靠纪律和测试守住。本节把这条纪律变成可执行的代码。

1.3.1 按功能分包 vs 按层分包

两种主流分法,先看清各自解决什么问题。

按层分包(package by layer):顶层是技术层,web、service、repository、domain 各一个包,所有业务都往里塞。

com.example.library
├── web
├── service
├── repository
└── domain

按功能分包(package by feature):顶层是业务功能,每个功能内部自带它的各层。

com.example.library
├── book
│   ├── BookController.java
│   ├── BookService.java
│   └── BookRepository.java
├── member
│   ├── MemberController.java
│   ├── MemberService.java
│   └── MemberRepository.java
└── loan
    ├── LoanController.java
    ├── LoanService.java
    └── LoanRepository.java
维度按层分包按功能分包
定位代码想找「借书逻辑」要跨三个包相关代码集中在一个包内
新人上手结构一眼看懂,符合教科书需要先理解业务边界
边界腐化层与层之间容易互相引用功能之间容易互相引用
拆分演进未来按业务拆模块要大规模搬包一个功能包可直接升级为模块
适合规模小项目、CRUD 为主中大型、业务边界清晰

判断标准很简单:如果项目里「一个功能的代码散落在四个层包里」已经开始让改动变麻烦,就该转向按功能分包。 反之,一个只有三个实体、八个接口的服务,按层分包完全够用,强行按功能分只会得到三个各含三四个类的包。

1.3.2 本章的分包方案

本章采用模块边界 + 模块内按层的折中。理由是:模块这一层已经承载了「可复用库 vs 应用」的划分,模块内部的规模还不大,按层分包最省事;等到某个模块内部功能膨胀到需要按功能再分,那本身就是 1.1.4 里「该拆了」的信号,届时把它升级成独立模块即可。

book-loan-api/
└── com.example.library.api
    ├── dto              # 跨模块契约:BookResponse、LoanCreateRequest
    ├── error            # 业务码与异常类型
    └── client           # 供其他应用调用的接口定义

book-loan-core/
└── com.example.library.core
    ├── domain           # Book、Member、Loan 实体与领域方法
    ├── repository       # Spring Data Repository 接口
    └── service          # 业务规则与事务边界

book-loan-web/
└── com.example.library.web
    ├── controller       # REST 控制器
    ├── advice           # 全局异常处理
    └── config           # 应用配置、启动类

三条约定跟着这个结构走:

  • 包名用小写单数(domain 而非 domains),不用下划线,避免与类名风格混淆。
  • api 模块只放契约:DTO、错误码、接口定义,绝不放实现。它会被外部工程依赖,实现放进去等于把内部细节暴露成公开 API。
  • core 模块的实现类默认包私有,只把真正需要跨模块使用的东西标 public(见 1.3.3)。

1.3.3 包可见性:用 package-private 收窄暴露面

Java 的默认访问级别(不写修饰符,即 package-private)是收窄暴露面最省力的工具,但在 Spring 项目里长期被忽视——很多人习惯给每个类都写 public。

原则是:一个类只有在「被别的包使用」时才需要 public,否则一律 package-private。 看 book-loan-core 里的一个例子:

package com.example.library.core.service;

// 只在本包内被 LoanService 使用的协作类,不写 public
class LoanPolicy {

    boolean canBorrow(Book book, Member member) {
        return book.getAvailableCopies() > 0
                && member.isActive()
                && !hasOpenLoan(member, book);
    }

    private boolean hasOpenLoan(Member member, Book book) {
        // 查该会员是否已有未归还记录
        return false;
    }
}

LoanPolicy 没有 public 修饰符,意味着只有 com.example.library.core.service 包里的类能用它。这正是我们想要的:它是一个内部实现细节,不该被 web 模块或别的包直接引用。将来想重构它(改名、合并进 LoanService、换实现),都不用担心破坏外部调用——因为根本没有外部调用。

对照着看,需要跨包使用的类才标 public:

类包修饰符原因
LoanPolicycore.servicepackage-private只被同包 LoanService 使用
Bookcore.domainpublic被 repository、service、web 跨包引用
BookRepositorycore.repositorypublic被 service 包注入使用
BookResponseapi.dtopublic跨模块契约

这里有一个容易踩的点:Spring 的组件扫描能发现 package-private 的类吗? 能。Spring 通过反射扫描类,package-private 的 @Component、@Service 照样能被注册成 Bean;构造器注入也支持 package-private 构造器。所以「收窄可见性」和「能被 Spring 管理」并不冲突。真正会出问题的是跨包注入——如果 web 包想注入 core.service 里一个 package-private 的 Bean,编译就过不了,这恰恰说明它本就不该被跨包使用。

如果想让模块级的可见性也受强制约束(而不只是包级),Java 的 JPMS(module-info.java)是选项,但它与 Spring Boot 的自动配置、反射扫描配合起来相当繁琐,本章不采用。模块级约束改用 Maven 依赖方向(1.2 已做)+ ArchUnit(1.3.5)来守,成本低得多。

1.3.4 模块间依赖方向与禁止循环

1.2 定的依赖是一条直线:

book-loan-web  ──依赖──▶  book-loan-core  ──依赖──▶  book-loan-api

Maven 会在构建期替你守住模块级的循环依赖:如果 core 反过来依赖 web,而 web 又依赖 core,Maven 的 reactor 排序会直接报错 The projects in the reactor contain a cyclic reference,构建失败。所以模块级循环不用担心,构建立刻就暴露。

真正需要警惕的是包级循环,它不会让构建失败,却会让代码越来越难改。典型症状是「两个包互相 import」:

com.example.library.core.service  ──▶  com.example.library.core.repository
        ▲                                          │
        └──────────────────────────────────────────┘
                  (repository 里某个类反向引用了 service)

这种循环一旦出现,两个包就再也无法独立理解、独立测试、独立重构——它们变成了一个「逻辑上的单包」。治理手段有三步,按顺序用:

  1. 先看依赖方向对不对。 上例中 repository 反向引用 service 几乎总是错的:数据访问层不该认识业务层。多半是把业务逻辑写进了 Repository,应该上移到 Service。
  2. 抽第三方包打破环。 如果双方确实需要共享某个类型,把它抽到一个更底层的包(如 core.shared),让两个包都依赖它,环就断了。
  3. 用测试固定住。 前两步靠人看会漏,用 1.3.5 的 ArchUnit 规则把「禁止循环」变成会失败的测试。

1.3.5 用 ArchUnit 把架构约束变成测试

架构约定写在文档里没人看,写成测试才会在 CI 上拦住违规提交。ArchUnit 是一个纯 Java 的架构测试库,它读取编译后的字节码,用断言的方式检查包依赖、命名、注解等规则。

先加测试依赖:

        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit-junit5</artifactId>
            <scope>test</scope>
        </dependency>

archunit-junit5 不在 Spring Boot BOM 的管理范围内,需要自己指定版本(放进父 POM 的 <dependencyManagement>,或在子模块显式写 <version>)。<scope>test</scope> 保证它不进生产产物。

第一类规则:分层依赖方向。规定 web 可以调 service,service 可以调 repository,反过来一律不行:

package com.example.library.core;

import com.tngtech.archunit.junit.*;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "com.example.library.core")
class LayeringTest {

    @ArchTest
    static final ArchRule layeredDependencies = layeredArchitecture()
            .consideringAllDependencies()
            .layer("Service").definedBy("..core.service..")
            .layer("Repository").definedBy("..core.repository..")
            .layer("Domain").definedBy("..core.domain..")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository")
            .whereLayer("Service").mayNotBeAccessedByAnyLayer();
}

这条规则会在下面这些情况下让测试失败:repository 里出现对 service 的引用(反向依赖)、domain 直接依赖 repository(领域层被数据层污染)、任何包试图引用 service 的内部实现。它把 1.3.4 的「依赖方向」从口头约定变成了构建会拦截的红线。

第二类规则:禁止循环依赖。用 slices() 把每个直接子包当作一个切片,要求它们之间没有环:

import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

    @ArchTest
    static final ArchRule noCycles = slices()
            .matching("com.example.library.core.(*)..")
            .should().beFreeOfCycles();

matching("...( * )..") 会把 core 下的每个直接子包(domain、repository、service)识别为一个切片。一旦出现「A 依赖 B、B 又依赖 A」,测试立刻失败,并打印出环上的具体类。

第三类规则:领域层保持纯净。领域模型不该依赖任何框架层的东西:

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

    @ArchTest
    static final ArchRule domainHasNoFrameworkDeps = noClasses()
            .that().resideInAPackage("..core.domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..service..", "..repository..", "..web..");
}

这三条规则加起来不到三十行,却能拦住绝大多数「架构慢慢烂掉」的改动。它们跑在单元测试阶段,速度很快(读字节码,不启 Spring 上下文),适合放进每次 CI。

规则要少而准。 不要一上来写二十条 ArchUnit 规则——规则太多会让开发者频繁被无关的失败打断,最后大家学会的是「加 @ArchIgnore 跳过」,规则就形同虚设。先从「依赖方向 + 禁止循环 + 领域纯净」这三条开始,出现真实违规时再按需补充。

1.3.6 包结构与模块边界的配合

把本节和前面两节连起来看,三层防线各管一段:

层级靠什么强制拦得住什么拦不住什么
模块(Maven)<dependency> 声明未声明的模块间引用模块内的包依赖
包(ArchUnit)分层与循环规则包级反向依赖、循环同名类的语义混乱
类(可见性)public / package-private跨包使用内部实现同包内的过度耦合

三层是互补的:Maven 管不住包,ArchUnit 管不住「同一个包里塞太多东西」,可见性管不住「同一个包内两个类互相纠缠」。能靠上一层强制的,就不要退到下一层靠人自觉。 这是本节最该记住的一句话。

1.3.7 常见坑

坑一:每个类都写 public。 习惯了之后,LoanPolicy 这种内部实现也被暴露出去,外部包一旦引用就形成隐性依赖,重构时不敢动。默认不写 public,需要跨包再补。

坑二:按功能分包却把 DTO 也按功能散开。 契约型 DTO 往往被多个功能共用,如果每个功能包各放一份,很快出现重复定义。跨功能的契约收敛到 api 模块或一个共享的 dto 包。

坑三:ArchUnit 规则写在 core 模块却检查 web 的包。 @AnalyzeClasses(packages = ...) 的范围决定了它能看见哪些类。检查 core 的规则就限定在 core;要检查跨模块的分层,规则得放在能同时看见这些模块的测试模块里,并相应调整 packages。

坑四:把 ArchUnit 当成一次性任务。 规则写完没人跑,等于没有。要把它挂进 CI 的测试阶段——它本来就是 JUnit 测试,和普通单测一起执行即可。

小结

  • 按层分包结构直观、适合小项目;按功能分包定位方便、利于演进,适合中大型且业务边界清晰的项目。
  • 本章采用「模块边界 + 模块内按层」:模块承载可复用库与应用之分,模块内规模不大时按层最省事。
  • 包名用小写单数;api 模块只放契约;core 的实现类默认 package-private。
  • Spring 能扫描并注入 package-private 的类,收窄可见性与被 Spring 管理并不冲突。
  • 模块级循环 Maven 会直接拒绝构建;包级循环要靠 ArchUnit 的 beFreeOfCycles() 守住。
  • 用 ArchUnit 把「分层方向、禁止循环、领域纯净」写成测试,规则要少而准,挂进 CI。
  • 模块、包、类三层防线互补:能靠上层强制的,不要退到下层靠自觉。

至此第 1 章收尾:1.1 决定拆不拆,1.2 把骨架写成能构建的 POM,1.3 把边界落到包与测试上。下一章转入依赖治理的具体战场——版本冲突的诊断与收敛。

阅读导航:上一节:1.2 父子 POM 与依赖管理 · 下一节:2.1 依赖冲突诊断 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计