DDD 实战踩坑:合同模块聚合根重构与领域事件解耦实践
2026/9/23 4:36:18 网站建设 项目流程

1. 第10天就动手改架构,这个合同模块到底经历了什么

合同模块的代码写到第10天,我突然发现一个很难受的问题:每次往Contract这个聚合根里加字段,都要牵连六七张表一起改;每次拉一个合同详情,仓储要跨表组装一大堆数据;更离谱的是,合同变更单这个业务对象,居然因为"属于合同"就被塞进了同一个聚合里,导致一次简单变更都要锁整个合同。这个设计是我自己按 DDD 规划的,图纸画得很漂亮,代码落地却处处别扭。

当时项目刚进入第二个迭代,需求方开始提履约跟踪、变更审批、到期提醒这些真正的业务规则,我意识到如果继续在这个结构上硬叠功能,后面每一个需求都会变成"拆东墙补西墙"。于是我在第10天按下暂停键,花了两天把合同模块的 DDD 架构重新梳理了一遍。这篇文章就是这次踩坑和调整的完整复盘,给同样在做 contract-management 或类似"业务规则重、状态流转多、周边依赖杂"的模块的朋友一个参考。

先说结论:问题不是出在"用了 DDD",而是出在我把 DDD 的战术建模当成了"画 ER 图的进阶版"。限界上下文划成了数据库表分组,聚合根成了所有子表的挂载点,领域事件和最终一致性完全没用上。这样的设计,本质上还是面向数据库编程,只是换了一套 DDD 名词。

2. 立项时的真实动机:一个合同模块为什么值得上 DDD

2.1 合同业务的复杂度不在增删改查,在状态和关联

这套系统是企业内部的合同管理平台,合同类型有采购合同、销售合同、框架协议、补充协议,生命周期要经历草稿、审批中、待生效、履行中、变更中、已终止、已归档这么多状态。合同本身还要关联供应商/客户主数据、项目、预算科目、收付款计划、发票、附件文件,光是展示一个合同详情页,就要聚合至少五个业务对象的数据。

如果按传统的 CRUD 思路做,contract一张主表加若干张子表,service 层写一堆 getXxxByContractId,短期确实快。但业务规则一旦多起来——比如"合同履行中不允许直接删除条款""变更单审批通过后合同金额联动修改""合同到期前30天催办提醒"——这些规则散落在 controller 和 service 里,很快就没人说得清"合同的合法状态到底由谁维护"。

2.2 为什么选了 DDD 而不是微服务拆分

说实话,我一开始也犹豫过要不要直接上微服务。但结合这个项目的情况,我判断当前阶段不应该做分布式拆分。理由很简单:合同模块的核心业务流转在单个进程内就能完成,用户量也没有大到需要独立部署的水平。强行按子域拆微服务,只会引入分布式事务、服务间调用链、多环境部署等一系列本阶段不需要解决的问题。

DDD 更适合现在的处境:它不需要你拆物理服务,只需要你在代码结构上把业务边界理清楚。同一个应用里可以装多个限界上下文,模块之间通过接口和事件交互。等将来真的需要拆服务了,这些边界就是现成的拆分依据。所以 DDD 不是微服务的前置步骤,它本身就可以独立地改善单体应用的质量。

2.3 第一版架构的规划图纸

第1天到第3天,我做了一轮事件风暴(Event Storming),列了一些关键业务事件,比如ContractDraftedContractSubmittedContractApprovedContractActivatedContractChangedContractTerminated。然后画了限界上下文草图,当时画了这么几个:

  • 合同主数据上下文:管理合同基本信息和条款
  • 合同审批上下文:管理审批流程和审批记录
  • 合同履行上下文:管理收付款计划和履约进度
  • 合同变更上下文:管理变更单和变更记录

光看这个划分,其实方向是对的。但接下来我犯了一个致命错误:在细化聚合的时候,我没有按"业务不变量"去拆,而是按"数据库表的外键关系"去拆。结果就是,我把ContractClauseContractAttachmentContractApprovalRecordPaymentSchedule全部挂在了Contract聚合根下面。名义上我有四个限界上下文,实际上代码里的Contract聚合根横跨了所有上下文。

3. 三个典型的踩坑场景,每一个都让我后悔画图时偷懒

3.1 场景一:合同详情页变成了"大查询工厂"

第一个让我难受的点是查询。合同详情页需要展示合同基本信息、所有条款、所有附件、审批历史、收付款计划。按照第一版的设计,这些实体全都挂在Contract聚合下,所以查询很简单:contractRepository.findBy(contractId),聚合根把下面所有子实体一口气拉出来。

代码确实好写,但问题马上就来了。一个合同如果积累了两年,可能有两三百条履约记录、几十份变更单附件、上百步审批历史。每次打开详情页,就要把这些数据全部 load 进内存。JPA 的@OneToMany默认还会连带加载,N+1 查询跑都跑不掉。更尴尬的是,用户进详情页往往只是想看合同状态和金额摘要,根本不需要那几百条明细趴在内核里。

这说明什么?说明"查询需求"和"聚合边界"是两码事。一个聚合根应该只保证它内部的核心不变量,不应该承担全量查询的职责。查询可以用单独的读模型,甚至用专门的查询仓储去组装,而不是让聚合根背上所有子数据。

3.2 场景二:变更一个合同签约主体,差点把整条数据链锁了

第二个让我绷不住的场景是合同变更。业务上有这样的需求:合同签订后,乙方公司主体变更了,需要发起一个变更单,审批通过后修改合同的乙方信息。

按照第一版的聚合设计,变更单ContractChangeOrder也在Contract聚合内部。我写变更功能的时候,逻辑变成:加载整个 Contract 聚合 → 往聚合里塞一条变更单 → 修改乙方字段 → 保存整个聚合。一次变更操作,reposiroty 会执行 contract、contract_clause、contract_attachment 等六七张表的写操作,哪怕真正改的只有乙方名称这一个字段。

这种设计直接导致两个问题。第一,数据库层面为了保持一致性,我不得不把所有相关表都加悲观锁,并发稍微一高,用户就感觉得到卡顿。第二,聚合根被当成了所有相关数据的"大事务管理器",变更单本来是独立业务对象,有自己的生命周期和审批规则,却被降级成了合同聚合里的一个集合元素,业务语义完全失真。

3.3 场景三:审批流推进和合同生效,两个状态机糊在一起

第三个场景更隐蔽,但后果最严重。合同审批这个流程,本质上是"审批上下文"的事,审批结果通过后,触发合同状态从"审批中"变成"待生效",这是合同主数据上下文的事。两个上下文的业务规则不同:审批上下文关心的是审批链路是否完整、是否所有节点都同意;合同主数据上下文关心的是合同状态迁移是否合法、能不能被激活。

第一版里,我把审批操作写成Contract聚合根的一个方法:contract.approve(node, comment)。这个方法内部既校验审批节点,又修改合同状态。表面看起来没问题,但等需求方提出"审批过程中允许撤回重提""加签""会签"这些规则时,我傻了。这些规则只在审批上下文里存在,与合同主数据完全没有关系。把审批规则硬塞进合同聚合根,导致合同聚合根里堆了一堆与合同本身的合法性无关的代码。

三个场景摆在一起,其实指向同一个根因:我把限界上下文画在了"对象归属关系图"上,而不是画在"业务规则边界"上

4. 根因复盘:从表面症状到 DDD 战术设计的三处硬伤

4.1 硬伤一:聚合边界的判断标准错了

DDD 里最经典的一句话是:聚合是一组强一致的对象,它们必须同生共死、一起变化。判断一个对象该不该属于某个聚合,标准只有一个——"离开聚合根,它能不能独立存在?它的变化是否需要与聚合根保持原子性?"

我用这个标准重新审视第一版设计,立刻发现问题。合同条款修改后,必须与合同基本信息保持一致,这个强一致没错,条款可以留在合同聚合里。但审批记录只是合同状态变化的"历史证据",它不需要跟合同状态同一条事务提交;履约计划是随着时间逐步更新的,不是每次改合同都要动它;变更单更是独立走完自己的审批流之后才会影响合同。这些对象都被我错误地拉进了Contract聚合。

提示:判断聚合边界时,别问"谁属于谁",要问"什么操作必须一起原子成功"。如果两个对象的写操作可以分两步完成且业务上可以接受短暂不一致,那它们大概率不在同一个聚合。

4.2 硬伤二:限界上下文被当成了"模块分层"

我最初画的四个限界上下文,ps 图上看着有边界,代码里却没有。contract包下面直接放了ContractContractClauseContractApprovalRecordPaymentScheduleContractChangeOrder一堆实体类,四个上下文的领域逻辑互相引用。这相当于把限界上下文当成了"逻辑命名空间",而不是"业务规则隔离区"。

正确的做法是:contract上下文里的代码不允许直接依赖approval上下文或fulfillment上下文里的领域对象。跨上下文的数据交互一律通过"精简后的接口 + 事件"完成。我在这个环节吃了大亏,因为第一版我连这种依赖规则都没有在工程规范里约定,IDE 一自动补全,代码就串了。

4.3 硬伤三:领域事件"画了箭头但代码里没有"

事件风暴的时候,我在白板上写了一大堆领域事件,比如ContractApprovedContractActivatedContractChanged。但在落地写代码时,我全部做成了同步方法调用:审批上下文的方法里直接调用合同主数据上下文的contractService.activate(contractId)。同步调用意味着强耦合,意味着审批上下文的代码必须知道"审批通过后合同主数据要做什么"。

而正确的方式是用领域事件解耦:审批上下文只负责发布ContractApproved事件,合同主数据上下文监听到事件后,再决定是否更新状态。这样即使将来有第三个上下文(比如办税模块)也需要响应审批通过事件,它也只需要订阅同一个事件,不需要改动审批上下文的任何代码。

5. 调整方案:从"合同大聚合"切成"事件驱动的多聚合协作"

5.1 重新划分限界上下文:按"业务规则变化频率"切

我重修架构时,核心动作是先忘掉数据库表关系,回到业务规则本身。把合同模块重新切成了下面几个上下文,每个上下文只负责自己这一摊业务规则:

限界上下文核心职责典型业务规则独立聚合
合同主数据上下文合同基本信息的创建、状态迁移草稿才能提交;履行中不能直接删除条款Contract(含条款)
合同审批上下文审批流配置、审批节点流转所有节点同意才能通过;支持撤回、加签ApprovalFlow、ApprovalTask
合同变更上下文变更单生成、校验、审批联动变更签约主体需重走审批;变更提交后原合同冻结ChangeOrder
合同履行上下文履约计划、收付款跟踪履约完成才能发起终止;收付款金额不能超合同总额PaymentSchedule、PerformanceRecord

这个表看起来很常规,但关键是每个上下文里不再有"跨上下文的实体引用"。合同主数据上下文里的Contract聚合根,不再挂在审批记录、变更单、履约计划下面。它只保留合同的基本字段、条款列表、当前状态。其他上下文各自维护自己的聚合,需要关联合同信息的时候,只保存contractId和必要的快照字段。

5.2 聚合重构的具体形态

调整后的Contract聚合根简化成最小状态集合,核心代码大致是这个形态:

@AggregateRoot public class Contract { private ContractId id; private String contractCode; private PartyInfo buyer; private PartyInfo seller; private Money totalAmount; private ContractStatus status; private List<Clause> clauses; private List<ContractEvent> domainEvents; public void activate() { if (!status.canActivate()) { throw new IllegalStateException("当前状态不能激活"); } status = ContractStatus.ACTIVE; registerEvent(new ContractActivated(id)); } }

Contract内部只保留和它自身强一致的数据。审批记录被挪到审批上下文里,变成ApprovalTask聚合的一部分;变更单变成ChangeOrder聚合根,自己走自己的状态机。Contract聚合根不再有"审批"方法,只有submitForApproval()activate()这种自身状态迁移方法。

相应地,仓储接口也按聚合拆分:

public interface ContractRepository { Contract find(ContractId id); void save(Contract contract); } public interface ApprovalTaskRepository { ApprovalTask findPendingTask(ContractId contractId); void save(ApprovalTask task); } public interface ChangeOrderRepository { ChangeOrder find(ChangeOrderId id); void save(ChangeOrder order); }

每个仓储只负责自己聚合的持久化,不允许跨聚合直接操作表。这个约束直接切断了第一版那种"一次保存一整棵树"的做法。

5.3 跨上下文协作:用领域事件和事务性发件箱解耦

重新划分之后,最重要的问题来了:审批通过之后,合同状态怎么变?我的答案是领域事件。

调整后的合同提交与审批流程变成了下面这样:

  1. 用户在合同主数据上下文发起提交,Contract状态变为PENDING_APPROVAL,同时发布ContractSubmitted事件。
  2. 审批上下文订阅事件,创建一组ApprovalTask,开始流转审批节点。
  3. 最后一个节点审批通过,ApprovalTask聚合发布ContractApproved事件。
  4. 合同主数据上下文监听ContractApproved事件,调用contract.activate(),将状态从PENDING_APPROVAL推进到ACTIVE

事件发布不能做成"事件发出去了就当成功",否则进程中途挂了事件就丢了。这里我引入了事务性发件箱(Transactional Outbox)模式:ContractApproved事件和ApprovalTask的状态变更在同一个本地事务里写入 outbox 表,后台有一个轮询任务把 outbox 里的事件可靠地投递到消息总线或直接调用订阅方接口。

-- outbox 表的核心结构 CREATE TABLE outbox_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, event_type VARCHAR(128) NOT NULL, payload JSON NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, published_at DATETIME NULL );

这个方案带来的直接好处是:审批上下文和合同主数据上下文不需要双方同时在线,也不需要引入分布式事务框架。事件投递失败、进程重启、网络抖动,都不会导致数据不一致,最多是合同状态晚几秒更新,业务上完全可以接受。

5.4 同一个事务里的强一致和跨上下文的最终一致放一起,才是完整的 DDD

调整过程中我学到的一个重要经验是:不要神化最终一致性。聚合内部仍然必须保持强一致,合同修改条款必须和条款列表同步保存。但跨上下文之间的协作,除非业务强制要求实时响应,否则用最终一致性是更合理的。

我碰到一个具体例子:合同终止。业务规则是"履约事项全部完成后才能终止合同"。最初我把它设计成:合同主数据上下文直接读取履约上下文的数据来判断,两个上下文产生了跨聚合查询。后来我改成了"履约上下文处理完最后一笔履约记录时,自动发布FulfillmentCompleted事件,合同主数据上下文订阅后允许用户发起终止操作"。这样既保证了规则成立,又没有跨上下文直接访问对方内部数据。

6. 调整落地的实际操作:我是怎么把代码迁过去的

6.1 迁移策略:先冻结需求,按聚合逐个拆

架构调整最忌讳的就是"推倒重来"。我在 Day10 到 Day11 做了两天的渐进式重构,没有一次性重写所有代码。策略是按聚合边界拆迁移任务:

第一阶段先把Contract聚合里明显不属于它的对象挪出去——审批记录挪到审批上下文,履约计划挪到履行上下文。这个阶段主要是代码搬移和调整仓储接口,业务行为不变。

第二阶段引入领域事件的骨架。先在审批上下文里增加ApprovalTask聚合,把原来的contract.approve()迁移为approvalTaskService.approve(taskId, operatorId, comment),同时在通过时发布ContractApproved事件。合同主数据上下文订阅该事件,调用contract.activate()。这一步做完,两个上下文才算真正在代码上解耦。

第三阶段加上 outbox 机制。先把事件发布从"同步调用"改成"写 outbox 表 + 后台投递"。这一步会引入异步性,我特意在测试环境跑了线上数据回放,确认事件投递没有重复也没有丢失才切生产。

6.2 迁移中容易漏掉的三个细节

细节一:跨上下文的"数据快照"。原合同详情页要展示审批人和审批时间,迁移后这些数据在审批上下文里。我的做法是在合同主数据上下文的读模型里冗余一份审批结果快照,审批完成时通过事件把"审批人、审批时间、审批结论"带过去。查询走快照,不跨上下文查库。

细节二:事务注解的粒度。重构前@Transactional大刺啦啦写在 service 方法上,一个方法管所有表。重构后我把事务边界收敛到"唯一聚合的操作方法"上。比如contractRepository.save(contract)自己是一整个事务,而事件投递由 outbox 机制另行保证。这个收敛过程让我被迫思考"哪些操作是真正的强一致边界",想清楚了,事务代码自然就少了。

细节三:测试策略。聚合边界调整后,领域层的单元测试变得更好写了。因为每个聚合不再依赖一坨外部数据,我可以直接构造Contract对象调用activate()验证状态迁移逻辑。跨上下文的协作测试用一个轻量级的"事件订阅记录器"来断言:给审批上下文发指令,断言合同主数据上下文收到了对应事件。

6.3 调整后的实际改进数据

我把这次调整前后的情况做了个简单对比:

对比项调整前调整后
Contract 聚合关联的子实体数7个2个(条款、合同自身属性)
合同详情查询加载的表数6~7张,全量 load主表 + 按需读取读模型
一次合同变更涉及的写事务6~7张表同事务变更上下文单事务,合同通过事件异步联动
领域事件的使用无,全部同步调用6种领域事件 + outbox 投递
与"审批流程"耦合的代码位置Contract 聚合根方法ApprovalTask 聚合根方法

代码量层面,总代码量没有显著减少,但每个类都变得"小而专"了。后来需求方提新需求——"审批中允许加签""合同变更审批通过后自动修改金额"——改动的范围都控制在单个上下文内,不像以前那样一动就全身动。

7. Day10之后再规划 DDD 时,我会死守的三条判断标准

这次踩坑之后,我把 DDD 的规划原则压缩成了三条非常"土"但非常好用的判断标准,每次画聚合、切上下文,我都会拿这三条来对答案:

7.1 问"什么必须一起原子成功",而不是"谁属于谁"

这条标准是防御"大聚合"的。如果两个对象之间的写操作可以分两条事务各自提交,并且业务上能接受短暂的中间状态,那它们就不应该在同一个聚合里。

拿合同和审批举例:合同被审批通过时,合同状态从"审批中"变"待生效",审批记录变成"已通过"。但这两个变化真的必须同一瞬间完成吗?不是。审批先落库,合同状态事件几秒后更新,用户根本感知不到差异。分开了,代码边界才清晰。

7.2 问"如果新增一个业务方也需要响应这个动作,改动面有多大"

这条标准是防御"同步调用"的。我重构后所有核心业务动作都用领域事件表达。一个动作如果未来可能有多个监听方,从设计的第一天就应该用事件而不是同步方法调用来传递结果。

举一个实际例子:合同到期前 30 天这个提醒,需求方说"先发站内信,之后可能加短信和邮件通知"。如果我在合同上下文里直接写死调通知模块的接口,将来每加一个渠道就要改合同模块代码。改成"到期前 30 天发布ContractDueSoon事件,通知上下文自行订阅",合同模块就再也不用变了。

7.3 看代码仓库,如果领域包里全是 service,说明设计出了问题

贫血模型是 DDD 最隐蔽的杀手。我第一版其实也是贫血模型:实体类全是 getter/setter,业务逻辑全堆在ContractServiceImpl里。重构的时候,我强制要求"领域行为放领域对象里,应用服务只做编排"。具体来说,能写成聚合根方法的逻辑,不允许堆在 service 里;service 只负责参数校验、获取聚合、调用聚合方法、发布事件。

这样调整之后,连代码 review 的标准都简单了:看到if (contract.getStatus().canChangeTo(X))这种校验逻辑出现在 service 里,直接就打回,要求把判断挪进Contract的领域方法里。这个约束让业务规则不再散落一地。

8. 最后留个实际收益的尾巴

这次调整到现在已经跑了两个迭代,最大感受不是代码变少,而是需求变更时的"波及范围"变小。上周需求方提了一个"合同变更支持换签约主体"的需求,我只需要改变更上下文里的ChangeOrder聚合和对应的事件监听逻辑,合同主数据上下文完全没动。放在 Day10 之前,这种需求至少要改四五个文件,还得担心审批记录、履约计划这些表被连带影响。

如果你也正处在初期开发阶段,合同模块刚刚开始变得难写,我建议你先别急着堆代码,用上面三条标准回头审一遍自己的聚合边界。画 DDD 图纸的时候多问自己一句"为什么它属于这个聚合"——这一句话能帮你少踩我这一路的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询