Skip to content

事务脚本还是领域模型:同一需求三次变化的代码演进 ​

报名功能第一版用一个服务类写完了:查名额、查重复、插入记录,三十行不到。有人提议「按 DDD 重写成领域模型」,另一个人说这是过度设计。两边都举得出例子,争论停在了「看情况」。

事务脚本和领域模型是组织业务逻辑的两种方式。事务脚本按用例组织:每个用例一个过程,从头到尾处理一个请求,规则写在过程里。领域模型按概念组织:规则放在拥有相关数据的对象上,用例只是调用这些对象。两者都能把功能做对,差别在于需求变化时,改动落在哪里、规则会不会散开。

「看情况」本身没错,但它需要可以观察的依据。本文把同一个报名需求分别用两种方式实现,让它们经历三次需求变化,每一步都跑同一组验收场景,并统计代码量、改动量和规则出现的位置数。实验在 JDK 25 上运行,见文末。

v0 名额v1 候补v2 调换v3 VIP 席位事务脚本领域模型19 行名额条件 ●23 行名额条件 ●31 行名额条件 ●●43 行名额条件 ●●●30 行名额条件 ●37 行名额条件 ●50 行名额条件 ●●61 行名额条件 ●+9 −5 / +11 −4+9 −1 / +14 −1+16 −4 / +28 −17改动(脚本/模型)验收场景:两种实现每个版本都全部通过(2、2、4、7 个)转折点在 v2:用例开始共享规则,调换不能直接复用报名(满员时语义不同),于是复制了名额检查v3 的规则变化要改三处、三种写法;领域模型把规则提取到场次聚合的一个方法里,代价是代码更长数字只描述这一组为实验编写的代码,说明的是改动与规则的分布方式
图 1 · 实测(JDK 25.0.4.1):两种实现每个版本都通过全部验收场景。领域模型一直更长;事务脚本里的名额条件随着用例增加从 1 处变成 3 处(报名、递补、调入),领域模型在 v2 也出现过一次重复,v3 提取成 canTake 后回到 1 处。圆点表示名额条件出现的位置数。

一、需求与两种实现 ​

版本需求
v0场次有名额;满员拒绝;同一个人不能重复报名
v1满员时进入候补;有人取消时,候补第一位递补
v2已报名的人可以调换到另一个场次;调入的场次满员时拒绝,原报名保留;调出的场次触发递补
v3场次可以为 VIP 保留若干席位:非 VIP 不能占用保留席位;递补和调入也要遵守

事务脚本版本是一个服务类,每个用例一个方法,直接读写内存里的「表」(名额表、报名表、候补表)。领域模型版本有一个场次聚合 Session,规则都在它的方法里,服务类只负责找到聚合、调用方法。两种实现都实现同一个用例接口,验收场景只通过这个接口调用,与实现方式无关。

二、结果 ​

text
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 加入保留席位时,三处名额检查都要改,而且三处的写法略有不同:

java
// 报名
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 把规则写成一个方法:

java
/** 名额规则只写在这一处:总数不超过名额,非 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 就可以先作为一个静态方法存在),再把相关的数据移进去,最后让用例方法只调用它。每一步都用同一组验收场景保护,这正是本实验里验收场景只依赖用例接口的原因。逐步提取的手法,见拆长方法时最容易改掉的行为。

六、检查清单 ​

  • 规则简单、用例之间不共享规则时,用事务脚本。
  • 记录每条规则出现在哪些用例里;出现两处以上、必须一起改时,考虑把它放到拥有相关数据的对象上。
  • 领域模型也会出现重复,区别在于重复集中在一个类里,更容易发现和合并。
  • 验收场景通过用例接口调用,不依赖实现方式,为迁移提供保护。
  • 迁移逐步进行:提取规则、移动数据、用例只做调用。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权