system column十三Tech
← 返回业财专栏
ERP

业财通识31:DDD 战术设计——聚合根、实体与值对象

2026/4/917 min read
ERPDDD领域驱动设计聚合根实体值对象十三Tech

大家好,我是十三。

导言:边界画好了,代码怎么写

在第 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 篇的主题——事件驱动架构。


往期回顾


关于十三Tech

资深服务端研发工程师、架构师、AI 编程实践者。
专注分享真实的技术实践经验,持续记录企业系统、架构设计与 AI 编程实践。