2891 字
约 9 分钟
18
S18-B02 · 分层、模块与边界:职责怎么切

接手一个项目,最先让人困惑的往往不是业务复杂,而是"这段代码该放哪"。同一个校验,有人写在 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. 自测

  1. 判断项目有没有真正分层,看目录名还是看什么?
  2. 三层与四层的差别落在哪一层?什么情况下四层是浪费?
  3. 同层互相调用会造成什么具体后果?正确修法是什么?
  4. 三个边界信号里,哪一个最能同时暴露依赖方向与全局状态问题?
  5. 为什么顶层要"先业务后技术"?shared/ 该遵守什么规矩?
  6. 引入接口的三个正当理由是什么?只有一个实现时怎么做测试隔离?
S18-B02 · 分层、模块与边界:职责怎么切
https://www.clxhxhhr.top/posts/5993/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。