接手一个项目,最先让人困惑的往往不是业务复杂,而是"这段代码该放哪"。同一个校验,有人写在 Controller,有人写在 Service。三个月后改一条规则,要翻五个包。
分层的全部目的只有一个:让一次改动落在尽量少的地方,并且落下的位置是可预期的。 它不解决性能,也不解决分布式复杂度——那是别的工具的事。
这篇按"先定义 → 给判据 → 最小可用做法"讲:层是什么、边界怎么验证、越界怎么修。
1. 分层的本质:一条依赖方向
层不是文件夹,是依赖方向上的顺序。判断一个项目有没有分层,不看目录名,看 import:如果 repository 包里出现 controller 的类型,分层已经破了。
| 层 | 职责(一句话) | 允许依赖 | 禁止依赖 |
|---|---|---|---|
| Controller / Handler | 协议转换:HTTP → 内部调用 | Service、DTO | Repository、SQL、持久化实体 |
| Application / Service | 编排用例、控制事务边界 | 领域对象、Repository 接口 | Web 对象(HttpServletRequest) |
| Domain | 业务规则与不变量 | 自身、抽象接口 | 框架注解、SQL、HTTP |
| Infrastructure | 存取与外部适配 | Domain 的抽象接口 | 上层业务编排 |
三层与四层的差别只在于:领域逻辑有没有独立的落点。
| 切法 | 组成 | 什么时候够用 | 什么时候不够 |
|---|---|---|---|
| 三层 | Controller → Service → DAO | CRUD 为主、规则集中在少数几处 | 规则一多,Service 变巨型类 |
| 四层 | 上述三层 + Domain | 有不变量、多个入口共用规则 | 规则极少时只产出一堆 getter |
别把"四层"当成"更专业"。领域层没有真规则可放时,它只是一层转发。
最小可用的包结构:
com.example.order
├── interfaces/rest/OrderController.java
├── interfaces/dto/CreateOrderRequest.java
├── application/OrderAppService.java
├── domain/Order.java # 规则与不变量
├── domain/OrderRepository.java # 接口,定义在 domain
└── infrastructure/persistence/OrderRepositoryImpl.java
依赖方向永远指向内:interfaces → application → domain,infrastructure → domain。domain 不 import 任何外层包,这是硬标准。
2. 分层解决什么、不解决什么
| 分层能解决 | 分层不能解决 |
|---|---|
| 隔离变化:换数据库只动 infrastructure | 性能:多一层就多一次调用与分配 |
| 控制依赖方向:domain 不被框架绑架 | 分布式复杂度:分层在单进程内成立 |
| 改动局部化:改规则只进 domain | 部署粒度:分层与拆服务无关 |
| 测试可隔离:domain 可以纯单测 | 团队边界:分层不划分人 |
右列更重要:"分了层所以架构好"是误判,分层只管可维护性一个维度。
3. 分层的三条判据
不用背原则,用三个可执行的问题验证:
分层自检(每次评审跑一遍,全通过才算分层成立)
[ ] 上层能调下层:Controller 里只出现对 application 的调用
[ ] 下层不知道上层:domain / repository 包没有 import 上层类型
[ ] 同层不互相调用:Service 之间不直接互调;
需要复用时把共同规则下沉,或由上层编排
第三条最常被违反,也最容易造成"改一个 Service 崩三个接口":OrderService → UserService → CouponService 一链套一链,任何一环改签名全链跟着改。修法不是加接口,而是把共同规则下沉到 domain。
4. 模块边界的三个信号
层解决纵向依赖,模块解决横向切分。边界画得好不好,用三个信号测,不用感觉。
| 信号 | 怎么测 | 不合格的表现 |
|---|---|---|
| 改动是否集中 | 挑最近 10 个需求,看 diff 落在几个模块 | 一个需求改 6 个模块,边界与业务不对齐 |
| 能否独立测试 | 单独编译该模块,写不启动容器的单测 | 测试必须起 Spring 容器 |
| 能否替换实现 | 假设要换掉它(换存储、换供应商) | 换一次要改调用方代码 |
"能否独立测试"最实用:一个模块必须启动整个应用才能测,说明依赖是从环境抓的,不是从参数拿的。
5. 按技术分层 vs 按业务分层
这是新手最常选错的一刀:切错之后加业务和改业务都会变慢,但代码本身看得过去。
| 维度 | 按技术分层 | 按业务分层 |
|---|---|---|
| 顶层目录 | controller/ service/ dao/ |
order/ user/ payment/ |
| 加业务 / 改业务 | 三个顶层目录各加一个文件,改动跨目录跳转 | 新建一个业务目录,自包含,改动在一个目录内完成 |
| 边界在哪 | 层与层之间(很薄) | 业务之间(较厚),能独立演进 |
两者不是二选一,正确做法是先业务后技术:顶层按业务切(order/、payment/),每个业务内部再放 interfaces/、application/、domain/、infrastructure/;跨业务共享的 shared/ 单独标出,越薄越好。
shared/ 是这套结构里最危险的地方,会退化成第二个技术分层垃圾桶。规矩:进去的东西必须没有业务语义(金额类型、ID 生成、时间处理),带业务含义的类进去就是一次跨业务耦合。
6. "贫血的 service 层"是怎么形成的
不是有人故意设计的,是三步演化出来的:先按表建 Entity(只有字段和 getter)→ 在 Service 里写流程(顺手在此判断)→ 规则越加越多,Service 变成三千行。判据只有一句:Service 里出现对同一对象多个字段的组合判断,这条规则就该搬进对象。
// 反例:规则散落在 Service,对象只是数据袋
public void pay(Long orderId) {
Order o = repo.find(orderId); // Order 只有 getter
if (o.getStatus() == 1 && o.getAmount().signum() > 0) {
o.setStatus(2); // 状态机规则在这里
repo.save(o);
}
}
// 正例:规则回到对象,Service 只编排
public void pay() {
if (status != CREATED) throw new IllegalStateException("状态不允许支付");
if (amount.signum() <= 0) throw new IllegalStateException("金额非法");
this.status = PAID;
}
后果事后都难补:规则无法被第二个入口复用、状态机只能靠人记、验一条规则要构造整个 Service 的依赖。
7. 跨层调用的三种越界与修复顺序
| 越界 | 症状 | 修复顺序 |
|---|---|---|
| Controller 直写 SQL / 调 Mapper | 同一查询多处重复,改表要改多处 | ① 抽 Repository 方法 ② 改调 Service ③ 删直连 |
| Service 依赖 Web 对象 | 签名出现 HttpServletRequest、ResponseEntity |
① 取值转普通参数 ② 改签名 ③ 删依赖 |
| DAO 感知业务逻辑 | Mapper XML 里出现状态判断、业务分支 | ① 判断上移 domain ② SQL 只取数 ③ 加单测锁行为 |
顺序不能反:先建正确路径,再迁调用方,最后删旧路径;反过来会得到半迁移状态,两套逻辑并存。
8. 边界的强制手段
边界靠自觉一定会退化。四层防线,从弱到强:
| 手段 | 成本 | 能拦住什么 |
|---|---|---|
| 包结构约定 | 极低 | 只拦住愿意遵守的人 |
| 评审清单 | 低 | 明显越界,靠人 |
| 编译期依赖检查 | 中 | import 层面的越界 |
| 架构测试(随单测跑) | 中 | import + 命名 + 注解使用 |
# ArchUnit:把分层规则写成测试,CI 里跑
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..interfaces..")
.because("domain 不依赖外层")
# 退路:mvn dependency:analyze、Java module-info、Python __all__
关键是规则必须可执行;写在文档里的规则等于没有。
9. 何时该引入接口,何时不该
| 判断 | 该引入 | 不该引入 |
|---|---|---|
| 实现数量 | 已有或将有两个实现(如 OrderRepository 的 MySQL 与内存实现) |
只有一个实现且接口与实现一一对应、同步修改 |
| 测试需求 | 实现依赖外部系统,测试要绕开(如支付网关客户端) | 没有第二实现,也没有 mock 需求 |
| 调用方 | 三方以上调用,且不想随实现变 | 接口不提供信息增量,方法名与实现完全一致 |
不该建接口时,常见代价是"改签名改两处、多一层跳转、读一件事要开两个文件"。一个实现但需要 mock 的中间地带,用测试替身解决。
10. 常见错误
为分层而分层,会出现只做一行转发的 XxxServiceImpl,此时该合并层或把规则填进 domain;把分层当模块化,目录分了四层但业务之间仍互相 import,此时顶层要先按业务切;边界画在错误维度,比如按"读/写"切模块,应按业务能力切(读写分离是部署策略);共享包无限膨胀,common/ 堆到两百个类,只允许无语义工具进去。
11. 排错:五种"分了层但还是乱"的情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 改一个小需求要动 5 个包 | 顶层按技术分层,业务横跨各层 | 改为顶层按业务切 |
| 单测必须起 Spring 容器 | 依赖从环境抓取(静态工具、ContextHolder) |
改构造器注入,纯逻辑下沉 domain |
domain 里出现 @Entity、@Table |
ORM 注解直接绑在领域对象上 | 拆持久化对象,或用不依赖框架的映射 |
| ArchUnit 规则一加就红 | 存量代码已越界 | 先 FreezingArchRule 冻结存量,只拦新增 |
架构测试的价值是阻止继续恶化,不是一天修完历史债。
12. 边界:什么情况不该再分层
| 场景 | 做法 | 理由 |
|---|---|---|
| 单文件脚本、数据修复任务 | 直接顺序写 | 生命周期短,分层成本高于收益 |
| 纯 CRUD 后台(无规则) | 两层足够:接口 + 数据访问 | 中间层只会转发 |
| 极小内部工具(<10 个接口) | 一个包,扁平结构 | 维护成本超过收益 |
一句话:分层是对"会变化的东西"的投资;没有变化预期,分层只是多一层要维护的间接。
13. 自测
- 判断项目有没有真正分层,看目录名还是看什么?
- 三层与四层的差别落在哪一层?什么情况下四层是浪费?
- 同层互相调用会造成什么具体后果?正确修法是什么?
- 三个边界信号里,哪一个最能同时暴露依赖方向与全局状态问题?
- 为什么顶层要"先业务后技术"?
shared/该遵守什么规矩? - 引入接口的三个正当理由是什么?只有一个实现时怎么做测试隔离?