本节目标:把 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:
| 类 | 包 | 修饰符 | 原因 |
|---|---|---|---|
LoanPolicy | core.service | package-private | 只被同包 LoanService 使用 |
Book | core.domain | public | 被 repository、service、web 跨包引用 |
BookRepository | core.repository | public | 被 service 包注入使用 |
BookResponse | api.dto | public | 跨模块契约 |
这里有一个容易踩的点: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)
这种循环一旦出现,两个包就再也无法独立理解、独立测试、独立重构——它们变成了一个「逻辑上的单包」。治理手段有三步,按顺序用:
- 先看依赖方向对不对。 上例中
repository反向引用service几乎总是错的:数据访问层不该认识业务层。多半是把业务逻辑写进了 Repository,应该上移到 Service。 - 抽第三方包打破环。 如果双方确实需要共享某个类型,把它抽到一个更底层的包(如
core.shared),让两个包都依赖它,环就断了。 - 用测试固定住。 前两步靠人看会漏,用 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 依赖冲突诊断 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。