
1. 第10天就动手改架构这个合同模块到底经历了什么合同模块的代码写到第10天我突然发现一个很难受的问题每次往Contract这个聚合根里加字段都要牵连六七张表一起改每次拉一个合同详情仓储要跨表组装一大堆数据更离谱的是合同变更单这个业务对象居然因为属于合同就被塞进了同一个聚合里导致一次简单变更都要锁整个合同。这个设计是我自己按 DDD 规划的图纸画得很漂亮代码落地却处处别扭。当时项目刚进入第二个迭代需求方开始提履约跟踪、变更审批、到期提醒这些真正的业务规则我意识到如果继续在这个结构上硬叠功能后面每一个需求都会变成拆东墙补西墙。于是我在第10天按下暂停键花了两天把合同模块的 DDD 架构重新梳理了一遍。这篇文章就是这次踩坑和调整的完整复盘给同样在做 contract-management 或类似业务规则重、状态流转多、周边依赖杂的模块的朋友一个参考。先说结论问题不是出在用了 DDD而是出在我把 DDD 的战术建模当成了画 ER 图的进阶版。限界上下文划成了数据库表分组聚合根成了所有子表的挂载点领域事件和最终一致性完全没用上。这样的设计本质上还是面向数据库编程只是换了一套 DDD 名词。2. 立项时的真实动机一个合同模块为什么值得上 DDD2.1 合同业务的复杂度不在增删改查在状态和关联这套系统是企业内部的合同管理平台合同类型有采购合同、销售合同、框架协议、补充协议生命周期要经历草稿、审批中、待生效、履行中、变更中、已终止、已归档这么多状态。合同本身还要关联供应商/客户主数据、项目、预算科目、收付款计划、发票、附件文件光是展示一个合同详情页就要聚合至少五个业务对象的数据。如果按传统的 CRUD 思路做contract一张主表加若干张子表service 层写一堆 getXxxByContractId短期确实快。但业务规则一旦多起来——比如合同履行中不允许直接删除条款变更单审批通过后合同金额联动修改合同到期前30天催办提醒——这些规则散落在 controller 和 service 里很快就没人说得清合同的合法状态到底由谁维护。2.2 为什么选了 DDD 而不是微服务拆分说实话我一开始也犹豫过要不要直接上微服务。但结合这个项目的情况我判断当前阶段不应该做分布式拆分。理由很简单合同模块的核心业务流转在单个进程内就能完成用户量也没有大到需要独立部署的水平。强行按子域拆微服务只会引入分布式事务、服务间调用链、多环境部署等一系列本阶段不需要解决的问题。DDD 更适合现在的处境它不需要你拆物理服务只需要你在代码结构上把业务边界理清楚。同一个应用里可以装多个限界上下文模块之间通过接口和事件交互。等将来真的需要拆服务了这些边界就是现成的拆分依据。所以 DDD 不是微服务的前置步骤它本身就可以独立地改善单体应用的质量。2.3 第一版架构的规划图纸第1天到第3天我做了一轮事件风暴Event Storming列了一些关键业务事件比如ContractDrafted、ContractSubmitted、ContractApproved、ContractActivated、ContractChanged、ContractTerminated。然后画了限界上下文草图当时画了这么几个合同主数据上下文管理合同基本信息和条款合同审批上下文管理审批流程和审批记录合同履行上下文管理收付款计划和履约进度合同变更上下文管理变更单和变更记录光看这个划分其实方向是对的。但接下来我犯了一个致命错误在细化聚合的时候我没有按业务不变量去拆而是按数据库表的外键关系去拆。结果就是我把ContractClause、ContractAttachment、ContractApprovalRecord、PaymentSchedule全部挂在了Contract聚合根下面。名义上我有四个限界上下文实际上代码里的Contract聚合根横跨了所有上下文。3. 三个典型的踩坑场景每一个都让我后悔画图时偷懒3.1 场景一合同详情页变成了大查询工厂第一个让我难受的点是查询。合同详情页需要展示合同基本信息、所有条款、所有附件、审批历史、收付款计划。按照第一版的设计这些实体全都挂在Contract聚合下所以查询很简单contractRepository.findBy(contractId)聚合根把下面所有子实体一口气拉出来。代码确实好写但问题马上就来了。一个合同如果积累了两年可能有两三百条履约记录、几十份变更单附件、上百步审批历史。每次打开详情页就要把这些数据全部 load 进内存。JPA 的OneToMany默认还会连带加载N1 查询跑都跑不掉。更尴尬的是用户进详情页往往只是想看合同状态和金额摘要根本不需要那几百条明细趴在内核里。这说明什么说明查询需求和聚合边界是两码事。一个聚合根应该只保证它内部的核心不变量不应该承担全量查询的职责。查询可以用单独的读模型甚至用专门的查询仓储去组装而不是让聚合根背上所有子数据。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包下面直接放了Contract、ContractClause、ContractApprovalRecord、PaymentSchedule、ContractChangeOrder一堆实体类四个上下文的领域逻辑互相引用。这相当于把限界上下文当成了逻辑命名空间而不是业务规则隔离区。正确的做法是contract上下文里的代码不允许直接依赖approval上下文或fulfillment上下文里的领域对象。跨上下文的数据交互一律通过精简后的接口 事件完成。我在这个环节吃了大亏因为第一版我连这种依赖规则都没有在工程规范里约定IDE 一自动补全代码就串了。4.3 硬伤三领域事件画了箭头但代码里没有事件风暴的时候我在白板上写了一大堆领域事件比如ContractApproved、ContractActivated、ContractChanged。但在落地写代码时我全部做成了同步方法调用审批上下文的方法里直接调用合同主数据上下文的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 ListClause clauses; private ListContractEvent 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 跨上下文协作用领域事件和事务性发件箱解耦重新划分之后最重要的问题来了审批通过之后合同状态怎么变我的答案是领域事件。调整后的合同提交与审批流程变成了下面这样用户在合同主数据上下文发起提交Contract状态变为PENDING_APPROVAL同时发布ContractSubmitted事件。审批上下文订阅事件创建一组ApprovalTask开始流转审批节点。最后一个节点审批通过ApprovalTask聚合发布ContractApproved事件。合同主数据上下文监听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 图纸的时候多问自己一句为什么它属于这个聚合——这一句话能帮你少踩我这一路的坑。