聊到DDD,我心里最想谈的不是实体、聚合、领域服务这些战术组件,而是一个常常被轻轻带过的词组:深层模型。项目标题是“深层模型与重构”,但市面上的资料几乎都在讲Repository怎么封装、Event怎么发、CQRS怎么拆分,很少讲清楚一个问题:重构到底是在改代码结构,还是在改我们对业务领域的理解?我自己的经验是,很多团队改了一大批代码,DDD仍然只停留在“包名好看”的层面,核心原因就是模型太浅。模型太浅,重构不过是一场大规模搬砖;模型有一定深度,重构才有机会让系统真正贴近业务本质。
这篇文章不是DDD入门手册,而是一份关于“怎么靠重构逼近深层模型”的实战复盘。适合那些已经知道实体、值对象、领域服务、聚合这些概念,但在自己的老项目上不知道怎么付诸实践的开发者。我会从一个很具体的观察出发——大多数业务系统里,模型已经变成了“数据库表结构的倒影”——然后结合我自己做费用结算模块重构的经历,拆解深层模型到底长什么样、为什么它能抵抗业务复杂度、以及如何用重构一步步把它“挖”出来。
1. 先拆掉一个误解:DDD重构到底在重构什么
很多人一提到DDD重构,第一反应就是:把原先的Service层拆开,把Repository接上,把实体类从贫血改成充血,给类名换成领域术语。这套动作看起来确实很“DDD”,但做完之后代码还是那个代码,只是穿了一层新衣服。原因很简单,因为大家做的只是代码结构重构,不是模型重构。
代码结构重构的目标是改善可读性、可维护性,行为边界保持不变。你抽方法、拆类、调整继承关系,这些都是结构层面的事,业务规则怎么算还是怎么算。但DDD里更关键的是另一类重构,我称它为模型演进。模型演进不是把现有的行为保持不变,而是发现原来的模型根本没有表达业务真正的规则,然后让新模型带着不一样的行为去替换旧行为。这是两条完全不同的路。
举一个最典型的场景。很多系统里有个“状态”字段,比如订单有未提交、已提交、审批通过、已执行、已归档这些值。写查询的人可能觉得很直观,写业务逻辑的人就开始痛苦了,因为“审批通过且执行了一部分然后又被部分退回”这种事,一个状态字段根本表达不了。于是各种判断就开始在Service层里野蛮生长:
java复制if (order.getStatus() == Status.EXECUTED
|| order.getStatus() == Status.PARTIAL_SETTLED
|| order.getStatus() == Status.ARCHIVED) {
// 可以做什么
}
这种代码多了以后,团队会干一件事:制定一个“状态只能往前进不能往后退”的规矩,放在评审文档里,或者靠代码注释提醒后来的人。但模型本身没有承载这个约束。任何一个粗心的开发都可以直接调一个setStatus()把状态改回去,系统也拦不住。等到业务方提出“我们允许运营人工把已归档的单据重新打开”,这种约束就变成了一堵必须拆掉的墙。
DDD重构要解决的就是这个问题。它不是在代码层面加固“状态不能回退”这个规则,而是先质疑:“状态不能回退”是不是我们对业务流程的正确理解?真实世界里一张单据的撤销、退回、红冲,可能根本不是同一个状态的线性流转。这时模型重构的方向就不是用状态机把“状态不能回退”封装得更稳固,而是引入“单据版本”“有效区间”“作废标志”这些更符合真实业务的深层概念,让系统能够正确表达“历史记录不可改,但新版本可以产生”。
这就是为什么我最开始总是提醒团队:一次DDD重构,首先要想清楚这次重构的产出到底是“行为不变的代码结构更好看”,还是“行为变化了但因为新模型更接近领域事实所以业务是对的”。前者不需要DDD,普通的重构手法就够用,后者才是DDD真正发挥价值的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 看模型是不是浅了:我习惯先查这六个信号
不是所有系统都值得做大规模建模,但有些系统有明显的“浅模型病”。我在拿到一个旧模块开始评估时,不会急着看UML或者画架构图,我会直接按下面这几个信号去代码里找,找到两三个,基本可以判断模型是浅的。
信号一:实体类退化成了纯数据容器
类里只有一堆私有字段、getter/setter、equals/hashCode,没有任何体现业务意图的方法。这种类本质上是数据库表结构或者DTO的直接映射,有人叫它贫血模型。贫血本身不是罪,如果它只是持久化边界的载体,后面会有别的机制负责业务逻辑,那也说得过去。但如果系统的核心业务对象全是这种,业务规则就只能全部堆在Service层,Service层就会越来越臃肿。
信号二:Service层出现大量“策略判断链”
同一个动作,因为对象的类型、来源、状态不同,走完全不同的分支。比如一个“计算费用”的方法,里面十几行if-else,判断客户等级、订单状态、折扣类型、地区。这种逻辑很难测试,因为分支组合多到爆炸;也很难演进,因为新加一种客户类型就要改这个方法,所有人都害怕动它。
信号三:通用型数据表承载了过多的业务含义
为了“灵活”,团队成员把很多业务字段塞进一张通用表,比如business_data(id, biz_type, fields_json),或者把所有订单、所有的审批记录、所有的附件记录统一放在一张大表里。从数据库层面看,表非常稳定,永远不需要加字段。但从模型层面看,什么领域概念都没有,任何业务规则都需要靠biz_type配合JSON里的某个key才能解释。这种模型在技术层面消灭了数据库重构的成本,却把成本十倍地转移到代码可读性和业务正确性上。
信号四:业务状态被拍平成单个字段
刚才说到的订单状态就是这样。单据是不是已出账、是不是部分核销、是不是被红冲,全都用一个status字段表示。这类情况往往伴随一个现象:领域里的门卫检查逻辑要比字段数量多得多,“只有当status是XX且amount大于0且xxx为空时,才能执行YY”这种话,业务分析师必须找开发问才能知道完整约束。
信号五:名词是技术术语,不是业务语言
代码里叫ProcessInstance、DataRecord、MainTable,而不叫Contract、BillingCycle、ClaimEvaluation。团队开需求会时,业务专家说“这个计费周期不能重复出账”,开发脑子里却在映射“哪张表的哪个状态字段”代表已经出过账了。一旦业务语言和代码语言对不上,浅模型就出现了,因为模型没有捕获业务概念的边界。
信号六:大量DTO层层转换
Controller转Service对象,Service转Repository对象,Repository再转DO,每个转换都要手工set几十个字段。转换本身不是坏味道,但当一个对象从数据库到前端需要经过四五种名字完全不同、字段几乎一致的类时,说明模型没有找到稳定的领域身份,代码层面的每个层都在用自己的“世界语”描述同一个东西。
这六个信号看下来,你会发现一个共同的根源:不是代码写得差,而是建模的人用一种“以数据库记录为中心”的方式在看业务。这种模型下,一条记录从一个状态改到另一个状态,本质上是UPDATE一行,至于这个更新在业务上意味着什么,模型本身不解释。**浅模型真正的问题并不是“笨”,而是它对业务事件没有解释能力。**一旦你想回答“这笔账为什么能算出来”“这张单据当前能不能被删除”,你只能顺着代码一层层翻,而不是直接问模型。
3. 实例:从一张“万能明细表”里长出深层模型
说一个我以前做过的一个费用结算模块。业务不复杂,就是企业和客户之间签合同,每份合同下有产品、单价、数量,每个月按照合同条款给客户出账单、算折扣、开发票。这种系统看起来典型到不能再典型,但老代码硬是做到了10000多行核心逻辑都挤在一个Service里。
打开数据库,最主要的表是一张settlement_item,字段包括:合同ID、产品名称、数量、单价、总金额、税率、折扣率、出账月份、状态、备注。一眼就能看出这是面向报表设计的表,好像什么都能存,却不属于任何一个真正的业务对象。
我们顺着一个需求去读代码。需求是“同一个合同每个月只能出一次账,如果当月有退款,退款金额要从下个月的账单里抵扣”。听起来是个很小的约束,但代码里是怎么实现的?
java复制// 伪代码,实际比这更绕
public BigDecimal calculateSettlement(List<SettlementItem> items, Contract contract) {
for (SettlementItem item : items) {
if (item.getStatus().equals("EXECUTED")
|| item.getStatus().equals("PARTIAL_REFUND")) {
// 判断是否同一合同同一月份
if (sameContractAndMonth(item, contract, currentMonth)) {
throw new BillingException("同月不可重复出账");
}
}
if (item.getStatus().equals("REFUNDED")) {
totalRefund = totalRefund.add(item.getAmount());
}
// 还有更多分支……
}
// 按照客户级别和合同总额判断折扣
if (contract.getLevel().equals("VIP")) {
discount = calculateVipDiscount(contract.getTotalAmount());
} else if (contract.getTotalAmount() > 100_0000) {
discount = new BigDecimal("0.05");
}
// 还有更多分支……
}
这段代码最要命的不是缩进风格,而是它把“是否可重复出账”“退款如何冲抵”“折扣如何确定”这三件不同生命周期的事情,全部揉在一个方法里。测试它的时候,你必须准备一个塞满了各种状态的items列表,然后祈祷所有分支组合都覆盖到。我后来做了一个简单的圈复杂度统计,这个方法的复杂度超过了80。
重构这一步,我们做的第一件事不是写代码,而是停下来听业务方怎么说话。他们把每天挂在嘴边的词写下来:合同、计费周期、账单、退款单、出账规则、抵扣。很快一个关键差异浮出水面:代码模型里的核心对象是s settlement_item——一张无限大的明细表;而业务世界里的核心对象是“计费周期”——一个合同在某个时间段内的结算闭环。
于是我们把模型改成了这样(简化版):
Contract:合同主体,包含客户、合同有效期、默认结算方式。BillingCycle:一个合同在某个月内的一次完整计费周期,值对象,持有开始日期、结束日期、状态。ChargeItem:计费周期内的费用明细项。BillingRule:出账规则,比如“合同内的同一BillingCycle不能重复出账”、折扣规则、退款抵扣规则。
重构后,核心逻辑从巨型Service搬到了BillingCycle这个模型上,表达方式完全变了:
java复制public class BillingCycle {
private Contract contract;
private BillingPeriod period;
private List<ChargeItem> items;
private boolean settled;
private boolean refundApplied;
public Money calculateAmount() {
Money subtotal = Money.zero(contract.getCurrency());
for (ChargeItem item : items) {
subtotal = subtotal.add(item.getLineTotal());
}
return subtotal.minus(refundOffset()).plus(taxAmount());
}
private Money refundOffset() {
// 来自上一个周期的退款:它是模型的一部分,不再是一段无人认领的公共逻辑
return contractRefundAccumulator.offsetFor(this.period);
}
}
这个例子并不是要说聚合、实体这些词有多高级。关键差异在于:原来的逻辑里,“同一合同同月不可重复出账”是靠一段代码里的if判断来保证的,任何人调用这个Service只要忘了传参、或者换了一个入口,这个规则就可能被绕过。新模型里,“重复出账”这个概念本身就不存在了,因为系统不会为同一个合同创建两个相同的BillingCycle实体引用,规则已经成为模型的一部分。
重构完成以后,我们删掉了接近1400行Service代码,圈复杂度从80多降到了10以下,而且最关键的变化是——不懂技术栈的业务分析师已经能拿着BillingCycle这个类名和开发讨论问题了。他说“这个计费周期的退款抵扣算错了”,大家不用先翻译一遍“哪段代码的哪个状态”,直接就能定位领域对象。
4. 深层模型的重构方向:让业务约束长回模型内部
很多人看完上面的例子可能会说:这不就是把原先散在外面的if判断移到一个类里吗,有什么稀奇?表面上看确实是这样,但“移到哪个类”这个选择,恰恰是整个重构最难也最有价值的地方。如果只是把if代码搬进实体,很可能搬错家,搬完之后规则还是散的,只不过换了位置。
我的做法是先用三个步骤确定约束的“家”,再动手改代码。
第一步,从业务专家嘴里采集规则,写成不变量清单。
不变量听起来很高大上,其实就是“系统任何时候都必须成立的规则”。比如“同一合同同一计费周期内不能出两笔账单”“一笔退款只能被抵扣一次”“已对账周期的数据不可修改”。把业务专家口述的规则一条条写下来,去掉“我觉得”“好像”“大概”这类词,剩下的就是系统真正的硬约束。
第二步,找出每个不变量目前是靠什么机制保证的。
大致有四类:靠数据库唯一索引保证、靠某段Service代码的判断保证、靠前端的交互逻辑保证、靠团队“测试环境踩个雷之后再修”保证。你会发现,真正靠谱的不变量大概率只在前两类里,第三类是纸糊的,第四类根本不能叫约束。重构的第一步,就是先把那些“靠约定保证”的规则,想办法显性化到代码或数据库层面。
第三步,为每个不变量找到归属边界。
归属哪里有一个笨但好用的判断标准:如果一条规则只影响一个聚合内部的多个对象,那它就应该长在这个聚合的边界内部;如果一条规则涉及多个聚合,它应该用领域服务来编排,而不是放到任何一个实体的内部方法里;如果一条规则涉及不同限界上下文,那可能根本不该用同一个模型来处理,而是通过领域事件驱动后续动作。
拿上面的费用结算模块来说,“同一合同同一周期不能重复出账”这条规则,当时在旧代码里靠一个方法顶部的if判断保证。其实它天然该属于BillingCycle或者Contract这个聚合的内部逻辑,当系统要创建一个新的BillingCycle时,由聚合进行检查。但“退款只能抵扣一次”这条规则,会跨到上一个周期的退款单,同时影响当前周期,所以它需要借助一个ContractRefundAccumulator领域服务在聚合之间协调,而不是硬塞进当前实体的普通方法。
规则有了归属之后,实体就不再是一堆getter/setter的集合了。它变成了一个有能力说“不”的对象:你试图重复创建一个BillingCycle,它会拒绝;你试图关闭一笔还没完成退款抵扣的账期,它会拒绝;你试图删除已经归档的记录,它会拒绝。这就是我理解的深层模型:它主动承担了领域规则的维护职责,而不是被动地等待Service来操作它。
要注意,这一步最容易犯的错就是过度设计。有人一听说要“深层建模”,就想把所有可能的概念全部提炼出来,建出包含十几个类层次结构的模型。我在几个项目上见过这种产物,设计得非常漂亮,就是业务专家完全看不懂,代码里多绕了两层抽象,开发改需求时反而更费劲。深层模型不是复杂模型,它是刚好能顺利表达业务核心约束的模型。用一个算账模块举例,“价格表”和“商品目录”是不是一定要拆成两个对象,取决于你的业务里折扣到底怎么生效;如果你们就是一群产品跟着一个统一报价走,拆出来只会增加成本。
5. 落地节奏:小步重构建模,大型整体重写是最贵的幻觉
如果你手头正握着十万行甚至几十万行业务代码,打算用“DDD重构”彻底推翻重写,我的第一句劝告是:停一下,能不能不重写就尽量别重写。
我看到过不少团队受AI辅助编码能力快速提升的刺激,觉得“让AI两天重写两万行代码”是可行的。代码层面确实可能做到,尤其是前后端结构相对规整的项目,AI能生成一批格式统一的新代码。但代码重构和模型重构的差别在这里露出了真面目:如果代码里的业务理解本来就是错位的,AI快速重写一万行,只会把错位固化成一套格式漂亮的新系统。领域规则依然藏在毫无约束的Service里,甚至因为新结构更抽象,日后更难看懂。
我推荐的节奏是“蚕食式建模”,而不是“爆破式重建”。
拿一个老项目举例,第一步先不追求把整个系统都DDD化。挑一个业务价值最高、规则最混乱、验证成本最低的模块,比如计费、审批、开发票、库存扣减这种。对它做一次微型事件风暴——找三五个业务骨干,在会议室白板上把核心操作和事件写出来,不用画得很标准,只要能让所有人看到“同一件事在代码和业务口中叫法不一样”就够了。
第二步,把这个模块里所有涉及业务判断的状态字段、Service逻辑列出一个清单,标出哪些是核心不变量,哪些是临时性的折中逻辑。哪个不变量最让团队头疼,就先从它开始重构。比如你觉得“同一个月不能重复出账”的判断散落得太厉害,那就尝试把所有创建出账的入口收敛到一个模块里,先在这个模块内部建一个合理的聚合,把约束放进去。其他入口继续走老代码,但通过一个防腐层挡在中间。
第三步,完整过一遍那条核心链路的测试。这里说的测试不一定是自动化测试,如果是老系统没有测试覆盖,那就在重构前找业务方把典型场景列出来,用对比输出的方式做回归。费用计算这种模块没有测试就直接重构,和没有安全带就上赛道没区别,一旦算错钱,后果是业务信任崩塌而不是代码返工。
防腐层是非常关键的实践。它对外提供领域对象的标准接口,对内屏蔽老代码的复杂数据结构。最初几个迭代,老数据库表还在,老Service也还在,但新的业务模块不再直接依赖它们,而是通过防腐层翻译数据。翻译过程可能很啰嗦,但它是必要的痛,这个痛能让模型边界逐渐清晰,也让团队不至于一夜之间重写全系统。
还有一个经验值得单独拿出来说:**每次重构尽量就让一个领域概念“浮出水面”,不要贪多。**先让“计费周期”变成模型,扎实运行两个迭代,再考虑“折扣规则”要不要单独建模。概念不断出现、消失、合并是建模的正常过程,但一次塞进来太多新概念,开发团队消化不了,出了问题都不知道是模型问题还是实现问题。
6. 什么情况下不值得走这条路,我先说点不好听的
写到这里,全是讲DDD重构多有用的,但我也得讲点不好听的。不是所有系统都值得做DDD式的深层建模,判断错了,拆了几个月代码,业务既没有变快,稳定性也没有变好,最后团队反而怀疑DDD是个骗局。
第一种不值得做的情况是系统本质上是数据填报加报表展示。你有一堆表单,用户填,数据存下来,后台拉出来看,最多加个审批流。这类系统的核心复杂度在数据展示和校验,不在业务规则,用强类型加上清晰的Repository就足够了,强行引入聚合和领域事件属于给自己加戏。
第二种是生命周期明确不长的系统。比如某些对接项目,上下游已经决定下一年换平台,当前的中间模块只是过渡方案。这种情况下做任何DDD重构都是负资产,把时间花在扩展测试和监控上更划算。
第三种是只有技术团队自己焦虑,业务方并不觉得规则混乱的系统。业务方说“我们这挺简单的,加个字段就行了”,你再怎么看模型都觉得浅,也得先想想谁在为复杂度买单。如果业务专家无法描述规则背后的不变量,或者一遍遍说“这个加进去以后再说”,那也就是验证了当前模型还没有承载太多领域深度,此时硬要建模,模型很可能是技术人员自己脑补出来的学术模型,而不是领域模型。
反过来,什么系统特别值得做?三个特征:涉及多个业务实体之间的联动约束;同一概念在不同入口被不同的规则解释;改一个需求常常要依次改动四五个Service方法。典型的像结算、计费、风控、供应链承诺、仓配调度,一句话,凡是“错一步就会产生真金白银损失”的地方,都值得用深层模型把规则焊在合适的位置。
如果判断值得做,还有一个很实际的启动经验:从错误开始。不是从“我要把Entity建模搞起来”开始,而是把近半年线上出现的P0/P1问题翻出来,看看有多少问题是模型缺陷直接导致的。比如“运营把一个已开票的合同改了金额,导致下一个账期金额全错”,这种问题的根因基本都能追溯到“已开票合同”和“未开票合同”在模型里没有被区分开。从线上事故倒推出模型缺口,说服业务方配合重构,比任何架构师宣讲都有效。
最后说点实际操作层面的体会
我很少看到一次重构就能得到完美深层模型的,真实过程就像在泥地里挖东西:你觉得自己找到了核心概念,做了一个版本,让业务方确认,业务方看完说“不对,我们这里还有一种特殊情况”,于是你又得推翻重来。这种反复不是坏消息,这恰恰说明模型在靠近真实业务。好的领域模型不是一拍脑袋设计出来的,而是在一次次“这里好像不太对”的重构中慢慢长出来的。
如果你要开始,我的建议是先做小范围尝试。选一个真正让你头疼的业务模块,找业务专家聊一次,把“代码怎么叫的”和“业务怎么叫的”两个词表列在两边对照,先别写一行代码。你会发现很多代码坏味道背后,其实是概念命名和模型定位出了问题。把这个清单做出来,重构的技术选型自然就有了答案。
模型重构这件事,真正改变系统的是让核心领域概念显性化的过程,而不是Entity、DomainService这些类名长得多标准。同一个业务,别人用一张万能表加几十个if判断能跑,你用一套能和业务专家顺畅沟通的模型也能跑,两者的长期维护成本和演进自由度,差距会在第一次复杂需求到来时立刻显现。
