事务脚本还是领域模型:同一需求三次变化的代码演进
报名功能第一版用一个服务类写完了:查名额、查重复、插入记录,三十行不到。有人提议「按 DDD 重写成领域模型」,另一个人说这是过度设计。两边都举得出例子,争论停在了「看情况」。
事务脚本和领域模型是组织业务逻辑的两种方式。事务脚本按用例组织:每个用例一个过程,从头到尾处理一个请求,规则写在过程里。领域模型按概念组织:规则放在拥有相关数据的对象上,用例只是调用这些对象。两者都能把功能做对,差别在于需求变化时,改动落在哪里、规则会不会散开。
「看情况」本身没错,但它需要可以观察的依据。本文把同一个报名需求分别用两种方式实现,让它们经历三次需求变化,每一步都跑同一组验收场景,并统计代码量、改动量和规则出现的位置数。实验在 JDK 25 上运行,见文末。
一、需求与两种实现
| 版本 | 需求 |
|---|---|
| v0 | 场次有名额;满员拒绝;同一个人不能重复报名 |
| v1 | 满员时进入候补;有人取消时,候补第一位递补 |
| v2 | 已报名的人可以调换到另一个场次;调入的场次满员时拒绝,原报名保留;调出的场次触发递补 |
| v3 | 场次可以为 VIP 保留若干席位:非 VIP 不能占用保留席位;递补和调入也要遵守 |
事务脚本版本是一个服务类,每个用例一个方法,直接读写内存里的「表」(名额表、报名表、候补表)。领域模型版本有一个场次聚合 Session,规则都在它的方法里,服务类只负责找到聚合、调用方法。两种实现都实现同一个用例接口,验收场景只通过这个接口调用,与实现方式无关。
二、结果
script.v0 非空行 19,名额条件写了 1 处;验收场景 2/2 通过
script.v1 非空行 23,名额条件写了 1 处;相对上一版改动 1 个文件,+9 −5 行;验收场景 2/2 通过
script.v2 非空行 31,名额条件写了 2 处;相对上一版改动 1 个文件,+9 −1 行;验收场景 4/4 通过
script.v3 非空行 43,名额条件写了 3 处;相对上一版改动 1 个文件,+16 −4 行;验收场景 7/7 通过
model.v0 非空行 30,名额条件写了 1 处;验收场景 2/2 通过
model.v1 非空行 37,名额条件写了 1 处;相对上一版改动 1 个文件,+11 −4 行;验收场景 2/2 通过
model.v2 非空行 50,名额条件写了 2 处;相对上一版改动 2 个文件,+14 −1 行;验收场景 4/4 通过
model.v3 非空行 61,名额条件写了 1 处;相对上一版改动 2 个文件,+28 −17 行;验收场景 7/7 通过几个直接的观察:
- 两种实现都是对的。每个版本都通过了全部验收场景。领域模型不是「正确」的那个,事务脚本也不是「错误」的那个。
- 领域模型的代码一直更多。v0 多 11 行(一个单独的聚合类、一层委托),v3 多 18 行。
- v3 的改动,领域模型反而更大:+28 −17 对 +16 −4。原因是 v3 不只加了保留席位的规则,还把 v2 里重复的名额判断提取成了一个方法,这次重构的改动也计算在内。
- 规则的分布不同。事务脚本里,名额条件随着用例增加从 1 处变成 3 处:报名、递补、调入各写一遍,并且每一处都要加上 VIP 的判断。领域模型在 v2 也出现了重复,v3 提取后回到 1 处。
需要说明,这两种实现都由同一个人为这个实验编写,数字反映的是这一组代码,不能推广成「领域模型多多少代码」的一般结论。它能说明的是改动和规则的分布方式。
三、事务脚本的优势在前两步
v0 和 v1 时,事务脚本更短、更直接:读这一个方法就能看懂报名的全部逻辑。每个用例一个过程,和需求文档的结构一一对应,新人上手快,调试时调用栈也浅。
这时把它改写成领域模型,换来的是一个只有两条规则的聚合和一层委托,代码变多,却没有解决什么问题。对于规则简单、用例之间不共享规则的功能(大部分 CRUD 后台、报表、数据同步),事务脚本就是合适的选择。
四、转折点:用例开始共享规则
v2 加入「调换场次」时,事务脚本的调换方法需要再做一遍名额检查(调入的场次是否满员)。代码作者可以选择调用报名方法,但报名方法在满员时会把人放进候补,而调换要求的是直接拒绝,语义不同,于是复制了一份检查。v3 加入保留席位时,三处名额检查都要改,而且三处的写法略有不同:
// 报名
if (r.size() >= capacity.get(s) || (!vip && nonVip >= capacity.get(s) - reserved.get(s))) { … 进候补 }
// 递补
if (r.size() < capacity.get(s) && (vips.contains(w) || nonVip < capacity.get(s) - reserved.get(s))) { … }
// 调入
if (r.size() >= capacity.get(to) || (!vips.contains(p) && nonVip >= capacity.get(to) - reserved.get(to))) return "REJECTED:已满";同一条规则的三种写法,一处取反,一处用参数、一处查集合。漏改其中一处不会有编译错误,只有专门覆盖那条路径的验收场景能发现。本实验为每条路径都写了场景,所以 7/7 通过;场景少一个,漏改就可能上线。
领域模型 v3 把规则写成一个方法:
/** 名额规则只写在这一处:总数不超过名额,非 VIP 不占用保留给 VIP 的席位 */
private boolean canTake(boolean vip) {
long nonVip = registered.values().stream().filter(v -> !v).count();
return registered.size() < capacity && (vip || nonVip < capacity - reservedForVip);
}报名、递补、调入都调用它。下一次规则变化(比如「每个公司最多占 3 个名额」),只改这一处。
值得注意的是,领域模型本身并不保证规则不重复:v2 的 transferIn 也复制了一份名额检查,直到 v3 才被提取。领域模型提供的是一个规则应该待的地方:所有修改场次状态的操作都在 Session 里,重复出现在同一个类的几个方法之间,很容易被看到和合并;事务脚本里,重复分布在不同的用例方法里,随着用例增多,往往分布在不同的类甚至不同的服务里。
五、怎样判断该换了
根据上面的观察,几个可以检查的信号:
- 同一条规则出现在两个以上的用例里,并且它们必须一起改。
- 用例之间互相调用,但又因为语义略有不同而无法直接复用(调换不能直接调用报名)。
- 改一个需求要修改的用例方法越来越多,而且漏改一处不会报错。
- 规则开始互相影响:保留席位影响递补,递补影响调换。
只出现第一条时,提取一个共用的校验方法就够了,不必改变整体结构。几条同时出现,说明规则围绕某个概念(这里是场次)聚在一起,值得让这个概念成为拥有规则的对象。
迁移可以逐步进行,不必重写:先把重复的规则提取到一个以概念命名的类里(v3 的 canTake 就可以先作为一个静态方法存在),再把相关的数据移进去,最后让用例方法只调用它。每一步都用同一组验收场景保护,这正是本实验里验收场景只依赖用例接口的原因。逐步提取的手法,见拆长方法时最容易改掉的行为。
六、检查清单
- 规则简单、用例之间不共享规则时,用事务脚本。
- 记录每条规则出现在哪些用例里;出现两处以上、必须一起改时,考虑把它放到拥有相关数据的对象上。
- 领域模型也会出现重复,区别在于重复集中在一个类里,更容易发现和合并。
- 验收场景通过用例接口调用,不依赖实现方式,为迁移提供保护。
- 迁移逐步进行:提取规则、移动数据、用例只做调用。
配套实验
- codesphere-labs/ddd/script-or-domain-model:JDK 25.0.4.1:同一报名需求的事务脚本与领域模型实现,各四个版本的验收场景、行数、改动量与名额条件位置数(验证记录)
参考资料
- Martin Fowler,《Patterns of Enterprise Application Architecture》:Transaction Script、Domain Model
- Martin Fowler,AnemicDomainModel