Skip to content

在真实项目里落地 DDD:精益切片与遗留改造 ​

DDD 推广最常见的失败,不是团队不会画模型,而是范围太大、只有架构组参与、模型没有进入代码,业务压力一来流程就被绕开。能落地的最小单位不是一个系统,而是一个两周左右就能闭环的业务切片。

一个运行了几年的报名系统,退款规则写在取消方法的中间,没有文档。团队决定把退款迁到新模型里,按直觉写出新规则:开场前 72 小时以上全额退,24 小时以上退一半,金额精确到分。影子比对 2007 个样本,1748 个与旧系统不一致。原因不是新规则写错了,而是旧系统有两条谁也没写下来的规则:剩余时间先截断成整小时再比较,退款金额向下抹到整元。恰好 72 小时的取消,旧系统只退一半。

本文讨论什么时候值得用 DDD、怎样选第一个切片,以及遗留系统怎样在不停机、可回滚的前提下一块一块迁出来。实验部分用字符化测试、接缝、影子比对和按比例分流,把上面的退款切片完整走了一遍。

一、先说结论 ​

  • 先判断值不值得:规则复杂、变化频繁、决定业务差异的核心域才值得深度建模;以增删改查为主的支撑域用简单分层就够了。
  • 第一个切片要小而真实:有真实痛点、两周左右能上线、有业务方参与、失败了代价可控。
  • 遗留系统先锁住行为,再动结构:实测字符化测试把 2007 个样本的旧输出固定下来,切出接缝之后输出完全一致。
  • 影子比对找出没写下来的规则:直觉实现与旧系统不一致 1748 个,其中 19 个是退款档位不同、1729 个只差不到一元;把两条隐含规则写成显式参数后,不一致 0 个。
  • 切换要能调回去:按比例分流 10%、50%、0%、100%,每一档的返回值都与旧算法一致;调回 0% 就是回滚。
  • 旧规则合不合理,是业务的决定:迁移时先原样保留,改变行为另开一个变更。

二、值不值得 ​

信号倾向深度建模倾向简单做法
业务规则多、互相影响,例如容量、候补、退款、优先级少,主要是字段校验
变化频率每个迭代都在改规则上线后很少变
业务价值决定产品与竞品的差异每家都一样
协作业务方愿意持续参与讨论需求一次性给出,之后不再参与
团队有人愿意维护模型、测试和词汇表只有临时人手

同一个系统里,不同子域的深度可以不同。报名与候补是核心域,值得事件风暴、聚合、验收测试这一整套;活动管理是支撑域,简单的分层和事务脚本就够了;支付和短信是通用域,优先接入成熟的服务,而不是用自研证明技术能力。把同样的建模精力平均投到所有模块,结果通常是核心规则写得不够好,边缘功能被过度设计。

三、第一个切片 ​

选择第一个切片的标准:

  • 有真实痛点:最近几次线上问题或需求返工都和它有关,团队和业务方都有动力改;
  • 两周左右能闭环:从讨论到上线在一个迭代内完成,而不是先做几个月的「大设计」;
  • 业务方能参与:至少有一个能拍板的人参加讨论、确认规则;
  • 失败代价可控:可以灰度、可以回滚,出问题影响的用户有限。

一个切片的闭环是这个专题前面几篇的缩影:

  1. 围绕这条业务链做一次 事件风暴,产出规则表、词汇表和热点;
  2. 把规则写成会失败的验收测试;
  3. 建立 模型,确定 聚合边界,让测试通过;
  4. 按 分层 接入存储和外部系统,写上架构规则;
  5. 上线,观察下一次需求变化是不是落在预期的边界里。

「低配版」的起步也可以:统一语言 + 一个边界 + 一个聚合 + 自动化测试。四层目录、领域事件、CQRS 都可以等到真正需要时再加。

四、遗留系统:两个模型之间的差距 ​

新系统可以从目标业务能力出发,自顶向下建立模型。遗留系统还需要自底向上地弄清楚它实际在做什么:

目标模型应该是什么:业务讨论、新需求现状模型实际是什么:代码、数据、日志差距清单术语冲突 · 规则位置隐含规则 · 数据所有权迁移切片可灰度 · 可回滚
图 1 · 目标模型来自业务讨论,现状模型来自代码、数据和线上行为;两者的差距要具体到术语、规则位置、隐含规则和数据所有权,选规则集中、依赖少的一块作为可回滚的切片

目标模型说的是「应该是什么」:来自业务方、事件风暴和新的需求。现状模型说的是「实际是什么」:来自代码、数据库、日志和线上数据。两者之间的差距,要具体到下面几类:

  • 术语冲突:同一个词在旧代码和新模型里含义不同,例如旧系统的 status=3;
  • 规则位置:规则散落在哪些类、哪些 SQL、哪些定时任务里;
  • 隐含规则:代码里有、文档里没有、业务方也不记得的行为,例如本文的截断和抹零;
  • 数据所有权:谁在写这张表,有没有其他系统直接读写;
  • 同步依赖与发布耦合:改这一块要不要同时改、同时发布别的系统;
  • 不可观测的失败:出错时有没有日志、告警,能不能知道影响了谁。

差距清单决定了迁移切片的边界:选一块规则集中、数据所有权清楚、外部依赖少的部分先迁。

五、实验:一个退款切片的迁移 ​

遗留的 LegacyRegistrationService 把报名、取消和退款计算写在一个类里,状态是整数,行是 Map:

java
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)。旧服务的输出保存为「已批准」文件,之后每次运行都必须完全一致:

text
旧服务 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 小时及以上全额,金额精确到分。它和旧算法并行计算,只比对、不生效:

text
按直觉实现的新规则影子比对 2007 个用例:不一致 1748 个
  退款档位不同 19 个:距开场 72 小时 47 分:旧 25700 分,新 51410 分
  只差不足一元的零头 1729 个

这 1748 个不一致揭示了两条隐含规则:剩余时间截断成整小时,再用「大于 72」比较,所以 72 小时 59 分以内都只退一半;退款金额抹去零头。把它们写成新模型的显式参数:

java
public static RefundPolicy legacyCompatible() {
    return new RefundPolicy(Duration.ofHours(72), Duration.ofHours(24), Rounding.DOWN_TO_YUAN, HourBoundary.TRUNCATE_TO_HOURS);
}
text
把「截断到整小时」和「抹去零头」写成显式参数后:不一致 0 个

这两条规则合不合理,是业务方的决定,不是迁移时顺手就能「修好」的 bug:也许抹零是早年和财务约定的,也许截断是有意给运营留出处理时间。迁移的目标是行为不变,改变行为另开一个变更,单独评审、单独上线。

5.4 按比例分流:绞杀的方式 ​

RefundRouter 按报名 id 的哈希把一部分退款交给新模型,其余走旧算法,同时继续影子比对:

text
每一档 2007 次退款,交给新模型的数量:
  10% → 202 个;50% → 989 个;0% → 0 个;100% → 2007 个
返回值全部与旧算法一致

同一笔报名每次都落在同一侧,出问题时容易定位。比例可以随时调回 0,这就是回滚:旧算法一直保留,直到新模型在 100% 流量下稳定运行足够长的时间。

字符化测试2007 个样本一致1切出接缝输出不变2影子比对1748 → 0 个不一致3按比例分流10/50/0/100%4隐含规则:剩余时间截断成整小时再比较「大于 72」;退款金额抹去不足一元的部分旧规则合不合理由业务决定;迁移先保持行为不变,改变行为另开一个变更
图 2 · 字符化测试固定 2007 个样本的旧输出;切出接缝后输出不变;影子比对发现 1748 个不一致,来自两条隐含规则;写成显式参数后不一致 0 个,再按 10%、50%、0%、100% 分流,调回 0 就是回滚

这个切片是无状态的计算,新旧两侧读同一份报名数据。需要迁移数据的切片还要处理双写或回填,以及回滚时数据往哪个方向同步,复杂度高得多。通知路由服务的演化 里有另一个用影子比对迁移遗留逻辑的例子。

六、绞杀式与修缮式 ​

策略适用信号第一个安全动作停止条件
绞杀式某块业务能力可以独立承接流量,新旧之间能用契约隔离建立新的入口或路由,迁移一个可以回滚的切片旧能力没有流量,数据迁移完成,旧代码删除
修缮式系统整体仍然可用,只有局部高变、高负载或高风险用字符化测试固定行为,切出接缝,把一块抽成模块风险和交付瓶颈已经解除;不为拆而拆

两者可以组合:先修缮,在遗留代码里切出接缝和模块;某个模块稳定之后,再用绞杀的方式把它迁到新服务。

「冻结需求、停掉旧系统、一次性重写」不应该是默认方案。只有同时具备这些条件时才值得讨论:业务能接受冻结期;数据迁移方案经过演练;有明确的回滚手段;团队对旧系统的隐含规则有足够的了解。本文的实验说明,最后一条通常最难满足:1748 个不一致,来自两条没有人记得的规则。

七、把模型放进交付流程 ​

模型和规则如果只存在于一次讨论的记录里,几个月后就会和代码对不上。几个可以写进完成标准(Definition of Done)的项目:

  • 新规则进入规则表,并有以规则编号命名的验收测试;
  • 新术语进入词汇表,写明「不是什么」和代码名;
  • 架构规则(依赖方向、模块边界)在构建中执行;
  • 跨上下文的契约变化有契约测试,不兼容的变化升级版本。

衡量效果,看的不是画了多少张图,而是:

  • 一个需求变化波及几个模块、几个团队,是否在变少;
  • 规则类缺陷(超卖、重复、顺序错误)的数量;
  • 从需求到上线的周期;
  • 跨团队协调的次数,新成员理解一块业务所需的时间。

八、什么时候退出 ​

如果一块业务的规则很少,模型只是在搬运字段;或者团队没有人愿意维护模型和测试,规则表已经很久没更新,就应该退回到简单的分层和事务脚本。DDD 是管理复杂性的工具,复杂性不在那里时,它的结构本身就是负担。

九、常见误区 ​

  • 「先把全系统的模型建完再开始写代码」:几个月后需求已经变了,模型从未经过代码和测试的检验。
  • 「重写比改旧代码快」:重写要先弄清旧系统的全部行为,而隐含规则恰恰最难发现。
  • 「新模型发现旧系统的行为不合理,顺手修掉」:迁移和行为变更混在一起,出了问题分不清是哪一个造成的。
  • 「全项目统一用富领域模型」:支撑域和通用域用简单做法,精力留给核心域。
  • 「DDD 由架构组推动就行」:没有业务方参与,统一语言和规则都无从谈起。

小结 ​

落地 DDD 从一个小而真实的切片开始,跑通「讨论—规则—模型—测试—上线」的闭环,再决定下一步扩到哪里。遗留系统的改造先用字符化测试锁住行为,切出接缝,让新模型在影子里比对,直到把所有隐含规则都显式化,再按比例分流、保留回滚。闭环跑通以后,更多的聚合、事件和服务才有意义;跑不通时,增加结构只会放大问题。这个专题从 从统一语言到限界上下文 开始,到这里结束,整条路线见 领域驱动设计专题。

需要迁移数据的切片,表结构怎样分步变更才能随时回退,见 能重新部署旧版本,不等于能回滚。


配套实验

参考资料

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