从领域对象到领域事件:一次 DDD 概念梳理
最近重新梳理了一遍 DDD 里几个容易混在一起的概念:领域对象、实体、值对象、聚合、应用服务、领域服务、限界上下文和领域事件。
真正难的不是背定义,而是在代码里判断:一段逻辑到底该放在实体里、聚合根里、领域服务里,还是应用服务里?两个服务之间到底该直接调用,还是用事件解耦?
1) 领域对象不是只有实体
领域对象是一个统称。实体和值对象都属于领域对象,只是它们用来表达不同类型的业务概念。
实体关注的是“是谁”。它有稳定身份标识,有生命周期,属性可以变化,但只要身份没变,它仍然是同一个对象。比如两个用户即使姓名、年龄、头像完全一样,只要用户 ID 不同,在业务上就是两个不同用户。
值对象关注的是“是什么”。它没有独立身份,通常由属性组合定义等价性,也更适合设计成不可变对象。比如两个收货地址的省、市、街道、门牌号完全一致,在发货业务里就可以被理解为同一个地址值。
所以可以先用一个简单判断:
- 业务关心对象的连续身份和生命周期,用实体。
- 业务只关心属性值本身,用值对象。
2) 聚合是保护业务规则的边界
聚合不是一个随便把对象放在一起的集合,它是“保证业务规则一致性的最小边界”。
以订单为例,订单和订单明细不是两个可以被外部随意操作的对象。订单明细属于订单这个完整业务概念的一部分。外部不应该直接 new OrderItem 然后塞进数据库,而应该通过订单这个聚合根完成操作:
1 | |
如果业务规定“一个订单最多只能有 50 件商品”,这个不变量也应该由订单聚合根守住。原因很简单:所有添加商品的入口都经过 order.addItem(...),规则写在这里,外部应用服务、Controller、任务调度都绕不过去。
这也是聚合根的核心价值:它不只是数据容器,而是对外暴露业务动作,并保护聚合内部的一致性。
3) 应用服务和领域服务不能混成一个大 Service
最容易混淆的是应用服务和领域服务。
应用服务负责一个用例的流程编排。它可以开事务、查仓储、拿领域对象、调用领域对象或领域服务、保存结果。它应该尽量“笨”,不承载核心业务判断。
领域服务负责那些不适合放进单个实体或值对象里的业务规则。尤其是当一条核心业务规则天然跨越多个领域对象或多个聚合,而且放在任何一个对象里都别扭时,才需要领域服务。
比如提交订单并扣减库存,通常是一个应用服务用例:
1 | |
这里的重点是“流程串联”和“事务边界”,所以它更像应用服务。
但如果要根据会员等级和促销活动计算最终折扣,这个计算规则同时依赖会员聚合和促销聚合,而且本身是纯业务规则,就更适合放在领域服务里:
1 | |
一个实用判断是:如果代码在回答“这个用例下一步做什么”,多半是应用服务;如果代码在回答“这个业务规则如何成立”,多半是领域对象或领域服务。
4) 限界上下文解决同名概念的歧义
限界上下文是战略设计概念,它解决的是语义边界问题。
同一个词在不同业务里可能不是同一个模型。比如“商品”:
- 在交易上下文里,商品关注名称、价格、上下架状态、活动信息。
- 在仓储上下文里,商品关注重量、体积、库位、易碎品标记。
如果强行建一个超级大的 Product,把交易字段、仓储字段、营销字段都塞进去,短期看是复用,长期看就是模型污染。每个团队都往里面加字段,最后谁也说不清这个对象到底代表什么。
更合理的做法是拆出各自上下文内的模型:
1 | |
它们可以通过同一个商品主 ID 产生关联,但在代码、表结构和业务语言上保持独立。这样每个上下文里的模型都只服务自己的业务规则。
5) 领域事件用于弱依赖,不是替代所有同步调用
当交易上下文里的商品下架后,仓储上下文可能也要调整相关状态。最直接的做法是交易服务同步调用仓储服务。但如果大促期间仓储服务很慢甚至短暂不可用,交易服务的下架流程就会被拖住,原本能独立完成的业务反而被下游拖死。
这种“我自己的核心动作已经完成,下游只是基于这个结果做后续反应”的场景,更适合领域事件:
1 | |
事件表达的是已经发生的业务事实,所以命名通常是过去时。它不是“请你修改仓储状态”的命令,而是“商品已经下架了”的事实。
事件消息体也要克制。只带 ID 的通知事件最轻,但下游可能需要反查,流量大时会造成 API 风暴。把上游所有字段都塞进去则会形成胖事件,浪费资源,也让下游偷偷依赖上游内部结构。
更稳妥的边界是:事件只包含描述这个历史事实所必需的上下文信息。
以商品下架为例,可以包含:
- 商品主 ID
- 下架时间
- 下架原因
- 操作人
这些字段是在描述“商品下架”这个事实,而不是把交易商品对象完整复制给下游。
6) 同步调用仍然合理
DDD 并不禁止同步调用。领域事件适合弱依赖,不适合强行替代所有跨服务交互。
判断标准是:A 服务的核心流程是否必须依赖 B 服务的返回结果。
如果用户提交订单时输入了优惠券码,交易服务必须知道券码是否有效、能抵扣多少钱,才能生成真实正确的订单价格。订单价格是订单创建时的核心字段,这一步被优惠券校验结果阻塞,所以同步调用营销服务是合理的。
可以粗略分成两类:
| 交互类型 | 场景 | 常见方式 |
|---|---|---|
| 强依赖 Query / Command | A 没有 B 的结果,自己的核心流程走不下去 | 同步 HTTP / RPC |
| 弱依赖 Reaction | A 的核心动作已经完成,B 只是后续联动 | 异步领域事件 |
所以 DDD 不是要求所有服务都发布订阅,也不是要求所有对象都不能直接调用。它真正要求的是:把业务规则放在正确的边界里,把不该强耦合的联动解开,把必须同步获得结果的地方明确承认下来。
7) 一个落地判断表
最后可以把这次梳理收敛成几条判断规则:
| 问题 | 优先落点 |
|---|---|
| 逻辑只影响单个实体自身状态 | 实体方法 |
| 逻辑需要保护聚合内不变量 | 聚合根方法 |
| 规则跨多个领域对象,且不适合放进任一对象 | 领域服务 |
| 用例流程、事务、仓储读写、权限校验入口 | 应用服务 |
| 同名概念在不同业务里含义不同 | 拆限界上下文 |
| 上游事实发生后,下游只是后续反应 | 领域事件 |
| 当前流程必须拿到另一个服务的结果才能继续 | 同步调用 |
这套判断不保证每次都能一步到位,但能避免两个常见问题:一是把所有业务都塞进应用服务,退回贫血模型;二是把所有服务交互都事件化,让系统复杂度失控。
DDD 的价值不在于术语,而在于边界判断。对象边界、聚合边界、上下文边界和服务交互边界理顺了,代码结构才会开始变得稳定。