从领域对象到领域事件:一次 DDD 概念梳理

最近重新梳理了一遍 DDD 里几个容易混在一起的概念:领域对象、实体、值对象、聚合、应用服务、领域服务、限界上下文和领域事件。

真正难的不是背定义,而是在代码里判断:一段逻辑到底该放在实体里、聚合根里、领域服务里,还是应用服务里?两个服务之间到底该直接调用,还是用事件解耦?

1) 领域对象不是只有实体

领域对象是一个统称。实体和值对象都属于领域对象,只是它们用来表达不同类型的业务概念。

实体关注的是“是谁”。它有稳定身份标识,有生命周期,属性可以变化,但只要身份没变,它仍然是同一个对象。比如两个用户即使姓名、年龄、头像完全一样,只要用户 ID 不同,在业务上就是两个不同用户。

值对象关注的是“是什么”。它没有独立身份,通常由属性组合定义等价性,也更适合设计成不可变对象。比如两个收货地址的省、市、街道、门牌号完全一致,在发货业务里就可以被理解为同一个地址值。

所以可以先用一个简单判断:

  • 业务关心对象的连续身份和生命周期,用实体。
  • 业务只关心属性值本身,用值对象。

2) 聚合是保护业务规则的边界

聚合不是一个随便把对象放在一起的集合,它是“保证业务规则一致性的最小边界”。

以订单为例,订单和订单明细不是两个可以被外部随意操作的对象。订单明细属于订单这个完整业务概念的一部分。外部不应该直接 new OrderItem 然后塞进数据库,而应该通过订单这个聚合根完成操作:

1
order.addItem(productId, quantity, price);

如果业务规定“一个订单最多只能有 50 件商品”,这个不变量也应该由订单聚合根守住。原因很简单:所有添加商品的入口都经过 order.addItem(...),规则写在这里,外部应用服务、Controller、任务调度都绕不过去。

这也是聚合根的核心价值:它不只是数据容器,而是对外暴露业务动作,并保护聚合内部的一致性。

3) 应用服务和领域服务不能混成一个大 Service

最容易混淆的是应用服务和领域服务。

应用服务负责一个用例的流程编排。它可以开事务、查仓储、拿领域对象、调用领域对象或领域服务、保存结果。它应该尽量“笨”,不承载核心业务判断。

领域服务负责那些不适合放进单个实体或值对象里的业务规则。尤其是当一条核心业务规则天然跨越多个领域对象或多个聚合,而且放在任何一个对象里都别扭时,才需要领域服务。

比如提交订单并扣减库存,通常是一个应用服务用例:

1
2
3
4
5
6
提交订单应用服务
-> 查询订单相关数据
-> 查询库存聚合
-> 调用订单聚合生成订单
-> 调用库存聚合扣减库存
-> 保存并提交事务

这里的重点是“流程串联”和“事务边界”,所以它更像应用服务。

但如果要根据会员等级和促销活动计算最终折扣,这个计算规则同时依赖会员聚合和促销聚合,而且本身是纯业务规则,就更适合放在领域服务里:

1
2
3
4
5
应用服务
-> 读取会员聚合
-> 读取促销聚合
-> 调用折扣领域服务计算折扣
-> 用折扣结果继续生成订单

一个实用判断是:如果代码在回答“这个用例下一步做什么”,多半是应用服务;如果代码在回答“这个业务规则如何成立”,多半是领域对象或领域服务。

4) 限界上下文解决同名概念的歧义

限界上下文是战略设计概念,它解决的是语义边界问题。

同一个词在不同业务里可能不是同一个模型。比如“商品”:

  • 在交易上下文里,商品关注名称、价格、上下架状态、活动信息。
  • 在仓储上下文里,商品关注重量、体积、库位、易碎品标记。

如果强行建一个超级大的 Product,把交易字段、仓储字段、营销字段都塞进去,短期看是复用,长期看就是模型污染。每个团队都往里面加字段,最后谁也说不清这个对象到底代表什么。

更合理的做法是拆出各自上下文内的模型:

1
2
3
商品主 ID
-> 交易上下文:TradeProduct
-> 仓储上下文:WarehouseProduct

它们可以通过同一个商品主 ID 产生关联,但在代码、表结构和业务语言上保持独立。这样每个上下文里的模型都只服务自己的业务规则。

5) 领域事件用于弱依赖,不是替代所有同步调用

当交易上下文里的商品下架后,仓储上下文可能也要调整相关状态。最直接的做法是交易服务同步调用仓储服务。但如果大促期间仓储服务很慢甚至短暂不可用,交易服务的下架流程就会被拖住,原本能独立完成的业务反而被下游拖死。

这种“我自己的核心动作已经完成,下游只是基于这个结果做后续反应”的场景,更适合领域事件:

1
2
3
4
5
6
7
交易上下文
-> 商品已下架
-> 发布 ProductUnpublishedEvent

仓储上下文
-> 订阅 ProductUnpublishedEvent
-> 调整仓储侧商品状态

事件表达的是已经发生的业务事实,所以命名通常是过去时。它不是“请你修改仓储状态”的命令,而是“商品已经下架了”的事实。

事件消息体也要克制。只带 ID 的通知事件最轻,但下游可能需要反查,流量大时会造成 API 风暴。把上游所有字段都塞进去则会形成胖事件,浪费资源,也让下游偷偷依赖上游内部结构。

更稳妥的边界是:事件只包含描述这个历史事实所必需的上下文信息。

以商品下架为例,可以包含:

  • 商品主 ID
  • 下架时间
  • 下架原因
  • 操作人

这些字段是在描述“商品下架”这个事实,而不是把交易商品对象完整复制给下游。

6) 同步调用仍然合理

DDD 并不禁止同步调用。领域事件适合弱依赖,不适合强行替代所有跨服务交互。

判断标准是:A 服务的核心流程是否必须依赖 B 服务的返回结果。

如果用户提交订单时输入了优惠券码,交易服务必须知道券码是否有效、能抵扣多少钱,才能生成真实正确的订单价格。订单价格是订单创建时的核心字段,这一步被优惠券校验结果阻塞,所以同步调用营销服务是合理的。

可以粗略分成两类:

交互类型 场景 常见方式
强依赖 Query / Command A 没有 B 的结果,自己的核心流程走不下去 同步 HTTP / RPC
弱依赖 Reaction A 的核心动作已经完成,B 只是后续联动 异步领域事件

所以 DDD 不是要求所有服务都发布订阅,也不是要求所有对象都不能直接调用。它真正要求的是:把业务规则放在正确的边界里,把不该强耦合的联动解开,把必须同步获得结果的地方明确承认下来。

7) 一个落地判断表

最后可以把这次梳理收敛成几条判断规则:

问题 优先落点
逻辑只影响单个实体自身状态 实体方法
逻辑需要保护聚合内不变量 聚合根方法
规则跨多个领域对象,且不适合放进任一对象 领域服务
用例流程、事务、仓储读写、权限校验入口 应用服务
同名概念在不同业务里含义不同 拆限界上下文
上游事实发生后,下游只是后续反应 领域事件
当前流程必须拿到另一个服务的结果才能继续 同步调用

这套判断不保证每次都能一步到位,但能避免两个常见问题:一是把所有业务都塞进应用服务,退回贫血模型;二是把所有服务交互都事件化,让系统复杂度失控。

DDD 的价值不在于术语,而在于边界判断。对象边界、聚合边界、上下文边界和服务交互边界理顺了,代码结构才会开始变得稳定。


从领域对象到领域事件:一次 DDD 概念梳理
https://willfordzhan.github.io/2026/06/04/ddd-domain-object-domain-service-events/
作者
詹文杰
发布于
2026年6月5日
许可协议