大家好,我是十三。
导言:边界画好了,代码怎么写
在第 30 篇中,我们用 DDD 的战略设计把业务领域拆成了一个个限界上下文。采购上下文、销售上下文、库存上下文……边界清晰,职责分明。
但划好了边界,只是解决了"哪里放什么"的问题。
如果你真正坐下来写代码,很快会遇到一个更具体的问题:一个采购订单(PO),它包含订单头信息、多个行项目、供应商地址、价格明细、审批记录。这些对象在代码里怎么组织?哪些可以独立存在,哪些必须依附于订单?修改的时候,哪些对象必须一起保存,哪些可以单独更新?
更棘手的是,当你打开一个典型的 ERP 代码库,很可能会看到一种"平铺"的结构:所有表都对应一个实体类,所有实体类都可以被任意 Service 直接修改。订单状态变了,库存扣减了吗?不知道,得去查另一个接口。发票校验通过了,应付账款生成没生成?也不确定,要看定时任务跑没跑。
这些问题,DDD 的战略设计回答不了,它属于战术设计的范畴。
今天这篇文章,我们就来回答:限界上下文内部,代码层面到底该怎么组织。
战术设计解决什么问题
业务定义:DDD 分为战略设计和战术设计两个层面。战略设计解决"业务如何划分"的问题,输出的是限界上下文和上下文映射图;战术设计解决"每个边界内部如何写代码"的问题,输出的是领域模型和代码结构。
打个比方:战略设计像建筑师画的平面图,告诉你客厅在哪、卧室在哪;战术设计像施工图,告诉你每面墙用多少砖、哪根柱子承重、水电线路怎么埋。
如果只有平面图没有施工图,房子盖不起来。同样,如果只有限界上下文没有领域模型,代码很快会变成一堆彼此纠缠的贫血实体和散落在各处的业务逻辑。
战术设计的核心构件有四个:聚合根、实体、值对象、领域事件。再加上仓储模式,构成了代码层面组织业务逻辑的基本框架。
这些概念听起来抽象,但它们解决的都是工程师每天面对的具体问题:这个字段该放在哪张表?这个逻辑该写在哪个类里?一次修改应该包多大事务?
聚合根:一组对象的"负责人"
业务定义
聚合(Aggregate) 是一组业务上高度内聚、数据上一致性要求高的对象的集合。聚合根(Aggregate Root) 是这组对象中唯一对外暴露的入口,所有对聚合内部对象的修改,都必须通过聚合根来完成。
通俗地说,聚合根就像一个家庭里的"户主"。家里有几口人、多少资产,外界不直接问每个家庭成员,而是问户主。想改户口信息,也得通过户主去办。
以采购订单为例
在 ERP 的采购模块中,采购订单(PO) 是一个非常典型的聚合根。
一个采购订单包含:
- 订单头:订单号、供应商、下单日期、总金额、状态
- 订单行项目:商品 SKU、数量、单价、行状态
- 收货地址:省市区、详细地址、联系人、电话
- 审批记录:审批人、审批时间、审批意见
这些对象在业务上高度相关:行项目的金额汇总等于订单头的总金额,订单状态变化会影响所有行项目,审批记录必须依附于某个具体订单。
如果把它们拆成四张独立的表、四个独立的服务,每次修改都要考虑"订单头改了,行项目有没有同步""状态变了,审批记录对不对"——复杂度会指数级上升。更危险的是,这种拆分会让业务规则散落在各个角落,一个新人入职很难搞清楚"改订单到底会触发多少连锁反应"。
所以 DDD 的做法是:以采购订单为聚合根,把行项目、收货地址、审批记录都放进同一个聚合里。 外界想操作这些对象,只能通过采购订单这个聚合根。
graph TD
PO[采购订单<br/>Aggregate Root] --> LINE[订单行项目<br/>Entity]
PO --> ADDR[收货地址<br/>Value Object]
PO --> PRICE[价格明细<br/>Value Object]
PO --> APPR[审批记录<br/>Entity]
LINE --> SKU[SKU 信息<br/>Value Object]
这张图展示了采购订单聚合的内部结构。采购订单处于中心位置,所有修改都要经过它。
为什么必须是"根"
有人可能会问:为什么不能让订单行项目独立存在?技术上不是更方便查询吗?
技术上确实可以,但业务上不行。订单行项目脱离了采购订单,就不知道自己属于哪个供应商、适用哪个价格协议、受哪个审批规则约束。它的业务意义,只有在聚合内部才完整。
聚合根的本质不是技术限制,而是业务一致性边界。在这个边界内,所有对象的状态变化必须同步、必须满足业务规则。
换句话说,聚合根是"事务的物理边界"。当你保存一个采购订单时,订单头、行项目、审批记录应该在一个事务里一起成功或一起失败。如果行项目保存成功但订单头失败,这个聚合就处于不一致状态,这是业务上不可接受的。
实体 vs 值对象:谁有"身份证"
业务定义
实体(Entity) 是有唯一标识(Identity)的对象。即使它的属性全部改变,只要标识不变,它仍然是同一个对象。
值对象(Value Object) 是没有唯一标识的对象。它的身份完全由属性值决定。如果属性值变了,它就是另一个对象。
这个区别听起来抽象,但用 ERP 里的例子一讲就透。
ERP 中的典型实例
客户是实体。 一个客户在系统里有唯一的客户主编码(如 CUST-000128)。即使这个客户改了名字、换了地址、调整了信用额度,只要编码没变,系统仍然认为它是同一个客户。我们在第 12 篇讨论 CRM 三层模型时,身份层的核心作用就是建立这种唯一识别。
客户的收货地址是值对象。 地址没有自己的独立编码,它的意义完全由"省市区 + 详细地址 + 联系人 + 电话"这些属性组成。如果一个客户把收货地址从"上海市浦东新区"改成了"杭州市西湖区",系统不会认为"这个地址对象被修改了",而是认为"这个客户换了一个新的地址值对象"。旧的地址值对象可以直接丢弃,不需要更新。
对比表
| 维度 | 实体 Entity | 值对象 Value Object |
|---|---|---|
| 唯一标识 | 有,如客户编码、订单号 | 无,身份由属性值决定 |
| 可变性 | 属性可变,标识不变 | 整体替换,不局部修改 |
| 生命周期 | 独立存在,有创建和消亡 | 依附于实体,随实体而存 |
| ERP 示例 | 客户、采购订单、库存批次 | 地址、金额、价格、税率 |
这个区别直接影响代码设计。实体会对应数据库里一张有主键的表;值对象通常作为实体的一个字段(或嵌入式文档),不需要独立的表和主键。
一个容易混淆的例子
订单里的"金额"是值对象吗?是的。100 元和 200 元是两个不同的金额值对象,不存在"把 100 元改成 200 元"的说法,只有"把订单的金额属性从 100 元替换为 200 元"。
同理,第 7 篇里我们讨论的暂估入账,"暂估金额"也是一个值对象。当发票到达后,系统不是去"修改暂估金额",而是冲销旧值、写入新值——这本质上就是值对象的替换思维。
领域事件:业务发生了什么的"广播"
业务定义
领域事件(Domain Event) 是领域中发生的有业务意义的、需要被其他部分知晓的事情。它是一个"已发生的事实",不可变更,通常以"XX 已 XX"的句式命名。
领域事件的作用,是把聚合内部的变化通知给聚合外部。聚合之间不直接调用,而是通过事件来解耦。
这一点在 ERP 中尤为重要。采购订单被确认后,库存需要预占、财务需要扣减预算、供应商需要收到通知。如果这些操作全部同步调用,任何一个环节失败都会导致整个下单流程回滚。用领域事件解耦后,采购聚合只负责发事件,其他聚合各自监听、各自处理,互不阻塞。
ERP 中的典型领域事件
| 事件名 | 发生场景 | 可能的监听方 |
|---|---|---|
| 采购订单已创建 | 采购部门正式下单 | 库存上下文(预占库存)、财务上下文(预算扣减) |
| 库存已扣减 | 销售出库完成 | 财务上下文(结转成本)、销售上下文(更新订单状态) |
| 发票已校验 | 三单匹配通过 | 财务上下文(生成应付账款)、采购上下文(更新 PO 状态) |
| 收款已确认 | 客户款项到账 | 财务上下文(核销应收账款)、销售上下文(释放信用额度) |
我们在第 27 篇讨论单据状态机时提到,状态变更通常由事件驱动。比如销售订单从"待发货"变为"已发货",这个状态变更本身就可以发布一个"商品已出库"的领域事件,库存上下文和财务上下文分别监听并执行各自的逻辑。
这就是领域事件与状态机的互文关系:状态机管理聚合内部的生命周期流转,领域事件负责把状态变化广播给聚合外部。
仓储模式:为什么不要直接操作数据库
业务定义
仓储(Repository) 是对数据持久化操作的抽象。它向上层领域代码暴露"保存聚合""查找聚合"的接口,屏蔽底层数据库的具体实现。
通俗类比
想象你去银行存钱。你不会直接走进金库,把现金塞进保险柜,然后再自己登记账本。你做的是:把现金交给柜员,柜员负责存进金库、更新系统、给你回执。
仓储就是那个"柜员"。领域代码(业务逻辑)只负责说"把这个采购订单保存下来",至于怎么生成 SQL、怎么建索引、怎么保证事务,那是仓储层的事情。
伪代码示例
// 领域层:只关心业务,不关心数据库
class PurchaseOrderRepository {
// 根据 ID 查找采购订单聚合
findById(id) { /* ... */ }
// 保存采购订单聚合(包含订单头、行项目、审批记录等)
save(purchaseOrder) { /* ... */ }
}
// 应用层:协调业务用例
function approvePurchaseOrder(poId, approver) {
const po = poRepository.findById(poId);
// 所有业务操作都通过聚合根完成
po.approve(approver);
// 交给仓储持久化
poRepository.save(po);
}
这段代码的核心思想是:领域代码只操作聚合根,不直接碰数据库。 这样,即使明天把 MySQL 换成 MongoDB,领域代码也不需要改动。
很多团队在早期会直接让 Service 层调用 mapper.insert() 或 dao.save(),这看起来省事,但隐患很大。业务逻辑和存储细节耦合在一起,一旦要分库分表、切换数据源或引入缓存,改动会蔓延到所有 Service 类。仓储模式的核心价值,就是把"业务要做什么"和"数据怎么存"分开。
聚合设计原则:三条铁律
聚合设计得好不好,直接决定代码的可维护性。设计过大的聚合会导致性能问题,设计过碎的聚合会让事务管理变得复杂。这里有三个最基本的原则。
原则一:聚合要尽量小
一个聚合只包含真正需要强一致性的对象。不要把整个模块的东西都塞进去。
反例:把"采购订单 + 供应商详情 + 库存记录 + 财务凭证"全放进一个聚合。这个聚合会变得巨大无比,每次加载都要 Join 很多表,事务范围过大,并发性能极差。
正解:采购订单聚合只包含订单头、行项目、地址、审批记录。供应商是另一个聚合,库存是另一个聚合,财务凭证也是另一个聚合。它们之间通过 ID 引用。
原则二:聚合之间通过 ID 引用
聚合 A 不要直接持有聚合 B 的对象实例,只持有一个 ID。
反例:采购订单聚合里直接嵌套一个完整的"供应商实体"对象。这样供应商改名时,系统要思考"要不要同步更新采购订单里的供应商名称"——这本不该是采购聚合关心的事。
正解:采购订单只存 supplierId: "SUP-001"。需要供应商详细信息时,通过仓储或查询服务另外去查。
原则三:事务边界等于聚合边界
一个事务内只修改一个聚合。这是保证数据一致性和并发性能的关键。
反例:在一个事务里同时修改采购订单的状态、扣减库存、生成财务凭证。事务范围横跨三个聚合,一旦并发量上来,锁竞争会让系统卡死。
正解:采购订单聚合的修改在一个事务内完成,然后通过领域事件通知库存聚合和财务聚合各自处理。库存和财务最终与采购订单保持一致,但不必在同一个事务里。
聚合设计清单
如果你在需求评审或代码设计时,不确定一个业务对象该不该放进某个聚合,可以用下面这张清单做第一轮检查。
| 聚合根 | 包含实体 | 包含值对象 | 领域事件 |
|---|---|---|---|
| 采购订单 | 订单行项目、审批记录 | 收货地址、价格、金额 | 采购订单已创建、采购订单已审批、采购订单已收货 |
| 销售订单 | 订单行项目、发货记录 | 收货地址、价格、税率 | 销售订单已确认、商品已出库、发票已开具 |
| 库存批次 | 出入库流水 | 数量、库位、成本 | 库存已入库、库存已出库、库存已盘点 |
| 客户 | 联系人、银行账户 | 地址、信用评级 | 客户已创建、信用额度已调整 |
这张表不是标准答案,但它提供了一个起点。实际项目中,聚合的划分需要根据业务复杂度和团队规模来调整。
建议的做法是:在需求评审阶段,先用这张表在白板上画一遍,确认每个聚合的边界。等团队对业务理解加深后,再逐步细化。不要一开始就追求完美的模型,DDD 是演进式设计,聚合边界可以随着业务变化而调整。
总结:战术设计是让战略设计落地
回到文章开头的问题:限界上下文画好了,代码怎么写?
DDD 战术设计给出的答案是:在每个限界上下文内部,用聚合根组织业务对象,用实体和值对象区分身份语义,用领域事件实现聚合间解耦,用仓储隔离持久化细节。
这四个构件并不复杂,但把它们用对位置,需要反复的业务理解和代码打磨。
从我自己的经验来看,很多团队推行 DDD 失败的根源,不在于概念没学懂,而在于一上来就想画一张完美的领域模型图。领域模型是业务理解的沉淀,不是架构师在会议室里拍脑袋的产物。只有真正写过代码、处理过异常、重构过模块之后,才能知道哪些对象应该绑在一起、哪些应该分开。
如果你刚开始接触 DDD,不必追求一次性设计出完美的聚合。更务实的做法是:先识别出聚合根,把强一致性的对象收拢进来,再通过 ID 引用与其他聚合保持松散耦合。随着业务理解加深,聚合的边界自然会越来越清晰。
在下一篇文章中,我们将继续往前走一步:当聚合之间需要通过事件协作时,系统该如何保证事件的可靠传递与最终一致性?这就是第 32 篇的主题——事件驱动架构。
往期回顾
- 业财通识30:DDD 战略设计——用限界上下文划分业务边界
- 业财通识25:业财对账——打通业务与财务的最后一公里
- 业财通识24:银行对账——银行流水与账本的逐笔核对
- 业财通识23:应付对账——管住每一笔该付的钱
- 业财通识22:应收对账——确保每一笔钱都收回来
- 业财通识21:库存成本计价——FIFO、加权平均与标准成本
- 业财通识20:安全库存——科学补货的数学基础
- 业财通识19:可用库存——为什么账上有货却不能卖
- 业财通识18:调拨——多仓协同的物流调度
- 业财通识17:盘点——账实一致的最后防线
- 业财通识16:出库——从销售发货到领料消耗
- 业财通识15:入库——四种场景下的库存增加
- 业财通识14:应收账款——从开票到回款的风险管控
- 业财通识13:价格策略——多维定价与动态调整
- 业财通识12:一个客户,为什么会有三条记录?——CRM 作为主数据底座的三层模型
- 业财通识11:从开票到收款,企业如何收回每一分钱?
- 业财通识10:当货物发出,系统里发生了什么?
- 业财通识09:订单确认前,系统如何防止坏账风险?
- 业财通识08:企业赚钱的第一步,从"潜在客户"到"销售合同"
- 业财通识07:业财难点之"暂估入账"与冲销
- 业财通识06:什么是采购在途?它对库存预测的价值
- 业财通识05:商品世界的基石——SPU 与 SKU
- 业财通识04:万事俱备,如何优雅地"打款"给供应商?
- 业财通识03:收到供应商账单,能直接付款吗?
- 业财通识02:当货物上门,系统里发生了什么?
- 业财通识01:企业花钱的第一步,从"购物清单"到"法律合同"
关于十三Tech
资深服务端研发工程师、架构师、AI 编程实践者。
专注分享真实的技术实践经验,持续记录企业系统、架构设计与 AI 编程实践。