在真实项目里落地 DDD:精益切片与遗留改造
DDD 推广最常见的失败,不是团队不会画模型,而是范围太大、只有架构组参与、模型没有进入代码,业务压力一来流程就被绕开。能落地的最小单位不是一个系统,而是一个两周左右就能闭环的业务切片。
一个运行了几年的报名系统,退款规则写在取消方法的中间,没有文档。团队决定把退款迁到新模型里,按直觉写出新规则:开场前 72 小时以上全额退,24 小时以上退一半,金额精确到分。影子比对 2007 个样本,1748 个与旧系统不一致。原因不是新规则写错了,而是旧系统有两条谁也没写下来的规则:剩余时间先截断成整小时再比较,退款金额向下抹到整元。恰好 72 小时的取消,旧系统只退一半。
本文讨论什么时候值得用 DDD、怎样选第一个切片,以及遗留系统怎样在不停机、可回滚的前提下一块一块迁出来。实验部分用字符化测试、接缝、影子比对和按比例分流,把上面的退款切片完整走了一遍。
一、先说结论
- 先判断值不值得:规则复杂、变化频繁、决定业务差异的核心域才值得深度建模;以增删改查为主的支撑域用简单分层就够了。
- 第一个切片要小而真实:有真实痛点、两周左右能上线、有业务方参与、失败了代价可控。
- 遗留系统先锁住行为,再动结构:实测字符化测试把 2007 个样本的旧输出固定下来,切出接缝之后输出完全一致。
- 影子比对找出没写下来的规则:直觉实现与旧系统不一致 1748 个,其中 19 个是退款档位不同、1729 个只差不到一元;把两条隐含规则写成显式参数后,不一致 0 个。
- 切换要能调回去:按比例分流 10%、50%、0%、100%,每一档的返回值都与旧算法一致;调回 0% 就是回滚。
- 旧规则合不合理,是业务的决定:迁移时先原样保留,改变行为另开一个变更。
二、值不值得
| 信号 | 倾向深度建模 | 倾向简单做法 |
|---|---|---|
| 业务规则 | 多、互相影响,例如容量、候补、退款、优先级 | 少,主要是字段校验 |
| 变化频率 | 每个迭代都在改规则 | 上线后很少变 |
| 业务价值 | 决定产品与竞品的差异 | 每家都一样 |
| 协作 | 业务方愿意持续参与讨论 | 需求一次性给出,之后不再参与 |
| 团队 | 有人愿意维护模型、测试和词汇表 | 只有临时人手 |
同一个系统里,不同子域的深度可以不同。报名与候补是核心域,值得事件风暴、聚合、验收测试这一整套;活动管理是支撑域,简单的分层和事务脚本就够了;支付和短信是通用域,优先接入成熟的服务,而不是用自研证明技术能力。把同样的建模精力平均投到所有模块,结果通常是核心规则写得不够好,边缘功能被过度设计。
三、第一个切片
选择第一个切片的标准:
- 有真实痛点:最近几次线上问题或需求返工都和它有关,团队和业务方都有动力改;
- 两周左右能闭环:从讨论到上线在一个迭代内完成,而不是先做几个月的「大设计」;
- 业务方能参与:至少有一个能拍板的人参加讨论、确认规则;
- 失败代价可控:可以灰度、可以回滚,出问题影响的用户有限。
一个切片的闭环是这个专题前面几篇的缩影:
- 围绕这条业务链做一次 事件风暴,产出规则表、词汇表和热点;
- 把规则写成会失败的验收测试;
- 建立 模型,确定 聚合边界,让测试通过;
- 按 分层 接入存储和外部系统,写上架构规则;
- 上线,观察下一次需求变化是不是落在预期的边界里。
「低配版」的起步也可以:统一语言 + 一个边界 + 一个聚合 + 自动化测试。四层目录、领域事件、CQRS 都可以等到真正需要时再加。
四、遗留系统:两个模型之间的差距
新系统可以从目标业务能力出发,自顶向下建立模型。遗留系统还需要自底向上地弄清楚它实际在做什么:
目标模型说的是「应该是什么」:来自业务方、事件风暴和新的需求。现状模型说的是「实际是什么」:来自代码、数据库、日志和线上数据。两者之间的差距,要具体到下面几类:
- 术语冲突:同一个词在旧代码和新模型里含义不同,例如旧系统的
status=3; - 规则位置:规则散落在哪些类、哪些 SQL、哪些定时任务里;
- 隐含规则:代码里有、文档里没有、业务方也不记得的行为,例如本文的截断和抹零;
- 数据所有权:谁在写这张表,有没有其他系统直接读写;
- 同步依赖与发布耦合:改这一块要不要同时改、同时发布别的系统;
- 不可观测的失败:出错时有没有日志、告警,能不能知道影响了谁。
差距清单决定了迁移切片的边界:选一块规则集中、数据所有权清楚、外部依赖少的部分先迁。
五、实验:一个退款切片的迁移
遗留的 LegacyRegistrationService 把报名、取消和退款计算写在一个类里,状态是整数,行是 Map:
public int cancel(String id, long nowMillis) {
...
long hours = ((long) row.get("start") - nowMillis) / 3_600_000; // 整数除法:截断成整小时
int percent;
if (hours > 72) percent = 100; // 大于 72,不含 72
else if (hours >= 24) percent = 50;
else percent = 0;
int refund = fee * percent / 100;
return refund / 100 * 100; // 抹去不足一元的部分
}迁移按四步走。
5.1 字符化测试:先把现在的行为锁住
用固定种子生成 2000 个样本(费用 1—999 元含分,距开场 0—200 小时按分钟),再加 7 个边界值(72:00、72:30、72:59、73:00、24:00、23:59、0:00)。旧服务的输出保存为「已批准」文件,之后每次运行都必须完全一致:
旧服务 2007 个用例与已批准的输出一致
边界样本(费用 19950 分):
距开场 72:00 → 9900 72:59 → 9900 73:00 → 19900 23:59 → 0字符化测试(characterization test)不判断行为对不对,只记录它是什么。它的作用是让之后的每一步重构都有一张安全网。
5.2 切出接缝:修缮的第一步
把退款计算原封不动地移到 RefundCalculator 接口后面,旧服务通过这个接口调用它。字符化测试的 2007 个样本输出完全一致。
接缝是修缮式改造的第一个动作:不改变任何行为,只在遗留代码里创造一个可以替换实现的位置。有了它,新模型可以在不改动调用方的情况下接进来。
5.3 影子比对:让新模型在旁边算
新模型的 RefundPolicy 按直觉实现:用 Duration 比较剩余时长,72 小时及以上全额,金额精确到分。它和旧算法并行计算,只比对、不生效:
按直觉实现的新规则影子比对 2007 个用例:不一致 1748 个
退款档位不同 19 个:距开场 72 小时 47 分:旧 25700 分,新 51410 分
只差不足一元的零头 1729 个这 1748 个不一致揭示了两条隐含规则:剩余时间截断成整小时,再用「大于 72」比较,所以 72 小时 59 分以内都只退一半;退款金额抹去零头。把它们写成新模型的显式参数:
public static RefundPolicy legacyCompatible() {
return new RefundPolicy(Duration.ofHours(72), Duration.ofHours(24), Rounding.DOWN_TO_YUAN, HourBoundary.TRUNCATE_TO_HOURS);
}把「截断到整小时」和「抹去零头」写成显式参数后:不一致 0 个这两条规则合不合理,是业务方的决定,不是迁移时顺手就能「修好」的 bug:也许抹零是早年和财务约定的,也许截断是有意给运营留出处理时间。迁移的目标是行为不变,改变行为另开一个变更,单独评审、单独上线。
5.4 按比例分流:绞杀的方式
RefundRouter 按报名 id 的哈希把一部分退款交给新模型,其余走旧算法,同时继续影子比对:
每一档 2007 次退款,交给新模型的数量:
10% → 202 个;50% → 989 个;0% → 0 个;100% → 2007 个
返回值全部与旧算法一致同一笔报名每次都落在同一侧,出问题时容易定位。比例可以随时调回 0,这就是回滚:旧算法一直保留,直到新模型在 100% 流量下稳定运行足够长的时间。
这个切片是无状态的计算,新旧两侧读同一份报名数据。需要迁移数据的切片还要处理双写或回填,以及回滚时数据往哪个方向同步,复杂度高得多。通知路由服务的演化 里有另一个用影子比对迁移遗留逻辑的例子。
六、绞杀式与修缮式
| 策略 | 适用信号 | 第一个安全动作 | 停止条件 |
|---|---|---|---|
| 绞杀式 | 某块业务能力可以独立承接流量,新旧之间能用契约隔离 | 建立新的入口或路由,迁移一个可以回滚的切片 | 旧能力没有流量,数据迁移完成,旧代码删除 |
| 修缮式 | 系统整体仍然可用,只有局部高变、高负载或高风险 | 用字符化测试固定行为,切出接缝,把一块抽成模块 | 风险和交付瓶颈已经解除;不为拆而拆 |
两者可以组合:先修缮,在遗留代码里切出接缝和模块;某个模块稳定之后,再用绞杀的方式把它迁到新服务。
「冻结需求、停掉旧系统、一次性重写」不应该是默认方案。只有同时具备这些条件时才值得讨论:业务能接受冻结期;数据迁移方案经过演练;有明确的回滚手段;团队对旧系统的隐含规则有足够的了解。本文的实验说明,最后一条通常最难满足:1748 个不一致,来自两条没有人记得的规则。
七、把模型放进交付流程
模型和规则如果只存在于一次讨论的记录里,几个月后就会和代码对不上。几个可以写进完成标准(Definition of Done)的项目:
- 新规则进入规则表,并有以规则编号命名的验收测试;
- 新术语进入词汇表,写明「不是什么」和代码名;
- 架构规则(依赖方向、模块边界)在构建中执行;
- 跨上下文的契约变化有契约测试,不兼容的变化升级版本。
衡量效果,看的不是画了多少张图,而是:
- 一个需求变化波及几个模块、几个团队,是否在变少;
- 规则类缺陷(超卖、重复、顺序错误)的数量;
- 从需求到上线的周期;
- 跨团队协调的次数,新成员理解一块业务所需的时间。
八、什么时候退出
如果一块业务的规则很少,模型只是在搬运字段;或者团队没有人愿意维护模型和测试,规则表已经很久没更新,就应该退回到简单的分层和事务脚本。DDD 是管理复杂性的工具,复杂性不在那里时,它的结构本身就是负担。
九、常见误区
- 「先把全系统的模型建完再开始写代码」:几个月后需求已经变了,模型从未经过代码和测试的检验。
- 「重写比改旧代码快」:重写要先弄清旧系统的全部行为,而隐含规则恰恰最难发现。
- 「新模型发现旧系统的行为不合理,顺手修掉」:迁移和行为变更混在一起,出了问题分不清是哪一个造成的。
- 「全项目统一用富领域模型」:支撑域和通用域用简单做法,精力留给核心域。
- 「DDD 由架构组推动就行」:没有业务方参与,统一语言和规则都无从谈起。
小结
落地 DDD 从一个小而真实的切片开始,跑通「讨论—规则—模型—测试—上线」的闭环,再决定下一步扩到哪里。遗留系统的改造先用字符化测试锁住行为,切出接缝,让新模型在影子里比对,直到把所有隐含规则都显式化,再按比例分流、保留回滚。闭环跑通以后,更多的聚合、事件和服务才有意义;跑不通时,增加结构只会放大问题。这个专题从 从统一语言到限界上下文 开始,到这里结束,整条路线见 领域驱动设计专题。
需要迁移数据的切片,表结构怎样分步变更才能随时回退,见 能重新部署旧版本,不等于能回滚。
配套实验
- codesphere-labs/ddd/legacy-modernization-slice:字符化测试、切出接缝、影子比对找出两条隐含规则、按比例分流与回滚(验证记录)
参考资料
- Michael Feathers,《Working Effectively with Legacy Code》:字符化测试与接缝
- Martin Fowler,StranglerFigApplication
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 15 章(精炼:核心域)
- Eric Evans,Getting Started with DDD When Surrounded by Legacy Systems