3410 字
约 11 分钟
9
S18-J06 · DDD 战术建模:实体、值对象、聚合

适用读者:服务层(Service)已经涨到 2000 行、改一个字段要跑一遍全链路回归的人;准备把"贫血模型"换成"充血模型"的人。
前置:S18-J05(原则与度量)、S04(Java 对象与集合)。限界上下文见下一篇 A01。
覆盖旧文:A-1533(充血模型、领域模型、实体/值对象/聚合、仓储与适配器)、A-1534(建模目的与四色建模)——合计 160 点。
扩展点:E-S18-010(充血边界)、E-S18-011(聚合四原则)、E-S18-012(值对象持久化)、E-S18-013(领域事件与发件箱)。
上一篇:S18-J05 · 设计原则与质量属性:从"模式"回到"度量"
下一篇:S18-A01 · 限界上下文与上下文映射(战略 DDD)。


1. 一句话结论

战术建模只做一件事:把业务不变式写进对象里,让"非法状态无法被构造出来"。 实体负责身份、值对象负责描述、聚合负责事务边界、仓储负责持久化隔离、领域服务负责无处安放的规则、领域事件负责对外通知。六个概念各管一段,不是六个标签。

A-1533 给的定义值得原样抄一遍:DDD 既不是 MVC 那样的工程结构,也不能直接等同于微服务架构,更不是一种设计模式——它是一种软件设计方法,分战略设计与战术设计两阶段。战术设计的原话是:贫血模型下"Service 扁平的、大量的、平铺出非常复杂的业务逻辑代码",行为对象与功能逻辑分离,是系统复杂度失控的根因。


2. 从贫血到充血

维度 贫血模型(Service + 数据模型) 充血模型(领域对象自带行为)
数据与行为 分离(PO 只有 getter/setter) 同一对象(校验、计算、状态迁移都在对象内)
校验位置 Service 里散落 if (x == null) 构造器/工厂/addLine() 内
谁能修改状态 任何拿到 PO 的代码 只能通过对象暴露的方法
复用方式 多个 Service 交叉使用同一个 PO 对象提供一整套语义方法,调用方不用重复处理
测试 要起 Spring 上下文测 Service 纯对象单测(new Order(...))

A-1533 对充血的边界说得很清楚:充血 = 属性 + 行为聚合到一个类,常见手段是对象内提供"信息校验""拼装缓存 Key""不含服务接口调用的逻辑处理"。也就是说——充血对象里不出现 RPC、不出现 DAO、不出现 Redis。一旦出现,它就不是领域对象,而是"Service 换了个文件名"。

充血的三层延伸(原文提到但容易漏):不止"一个类",还可以是一个包(包里含该领域所需的模型、仓储接口、工厂)、以及同类下的内部子类(用户实名包含通信、实名卡、银行卡、四要素)。

实测(Java 17.0.20,2026-11-06,单文件运行):

[value object] equals by value: true, identity: false
[aggregate] total = Money[cents=3200, currency=CNY]
[aggregate] invariant: empty order
[aggregate] locked: locked in PAID
[value object] guard: currency mismatch
[value object] immutable: 2 final fields, no setters

这六行几乎覆盖了战术建模的全部要点,逐行解释:

  1. record 生成的值对象按值相等(equals 为 true)但不是同一个对象(== 为 false)——这正是"值对象没有身份"的定义;
  2. 聚合自己算总额(3200 = 1200×2 + 800),计算逻辑在对象内,不在 Service;
  3. 空订单调用 pay() 抛 empty order——不变式被守住;
  4. 已支付的订单再 addLine() 抛 locked in PAID——状态迁移约束被守住;
  5. 币种不同的金额相加抛 currency mismatch——值对象自带校验(这条最容易被漏掉,跨币种相加是真金白银的 bug);
  6. 值对象只有 2 个 final 字段、无 setter——不可变。

3. 实体、值对象、聚合:三层判据

概念 定义(A-1533 口径) 判据三问 例子
实体 Entity 唯一标识 + 状态属性 + 行为动作,标识在生命周期内不变 换个属性值,它还是"同一个东西"吗? 用户、订单、购物车
值对象 Value Object 一组属性共同描述一个概念,无标识、不可变、按值相等 两个对象所有属性相同,能互相替换吗? 金额+币种、四级地址、日期区间
聚合 Aggregate 一组内聚对象的集合,一致性边界 + 聚合根 + 事务边界 这些对象必须在一个事务里一起改吗? 订单 + 订单明细(+ 收货地址值对象)

A-1533 用了一个很好的比喻:胳膊、腿、身体是实体(和生命有关);帽子、手套、裤衩是值对象(穿戴,不具有生命属性);把胳膊腿身体放一起,一次写库一个事务,就是聚合。

3.1 聚合设计的四条原则(E-S18-011)

原则 做法 违反的后果
1. 以不变式划边界 只有"必须同时成立"的规则才放进同一聚合 聚合过大 → 锁竞争、加载慢
2. 聚合要小 一个聚合通常只有根 + 少量子对象 大聚合 = 事实上的"全局锁"
3. 跨聚合用 ID 引用 聚合 A 存 customerId,不存 Customer 对象 对象图蔓延 → 一次加载拖出半个库
4. 跨聚合靠最终一致 领域事件 / 消息 / 补偿 想用一个大事务保证 → 分布式事务地狱

原文的一句话总结很到位:"聚合内事务一致性,聚合外最终一致性。" 这句是判断聚合边界是否划对的黄金标准——如果你发现自己在为"两个聚合之间的一致性"写分布式事务,先回头看看它们是不是本来就该是一个聚合。

3.2 值对象的持久化(E-S18-012)

值对象在库里怎么存,决定了它是不是"真值对象":

存法 做法 适用 代价
展平(flatten) Money → amount_cents + currency 两列 最常见,推荐 需要映射代码
JSON 列 整个值对象序列化进一列 结构易变、查询少 无法按字段索引
独立表 值对象单独一张表 需要被多处引用 引入"伪实体"风险
JPA @Embeddable / MyBatis TypeHandler 框架级映射 Spring Data JPA / MyBatis 需要理解框架的脏检查语义

判据:值对象一旦有了自己的 ID 列,它就不是值对象了(会退化成实体,且带来"两处都能改"的一致性问题)。


4. 仓储与端口适配器:依赖倒置的落地

A-1533 的原话:领域模型"自身只关注业务功能实现,不与外部任何接口和服务直连",不直接调 DAO、不调 Redis、不引 RPC,而是定义接口(仓储/适配器),让基础设施层实现。

// domain 层定义(不 import 任何框架)
public interface OrderRepository {
    void save(Order order);
    Order find(String id);
}
// infra 层实现(import 数据库、缓存、MQ 都行)
public class JdbcOrderRepository implements OrderRepository { ... }

这带来三个可验证的收益:

收益 怎么验证
领域层可单测 new Order(...) + 内存仓储,不启 Spring
实现可替换 MySQL → MongoDB,只改 infra
依赖方向单向 ArchUnit 规则:domain 不得依赖 infra(见 A02 §4,实测可跑)

仓储不是 DAO:DAO 面向表(insert/selectByPrimaryKey),仓储面向聚合(save(order) / find(id),粒度是聚合根)。一个仓储方法可能对应多条 SQL,这是正常的——它封装的是"聚合怎么落地",不是"表怎么读写"。

规范模式(Specification):复杂查询可以把业务规则封装成独立对象,再由仓储翻译成 SQL。但要注意边界:规范是查询条件,不是业务规则。把"VIP 用户才能用这张券"写进规范,会让规则分散在两处。


5. 领域服务与领域事件

5.1 领域服务:无处安放的规则

该放对象 该放领域服务
单个聚合内的校验、计算、状态迁移 跨多个聚合的规则(转账要同时改两个账户)
有自然归属的行为(order.pay()) 没有自然归属的行为("风控额度计算")
只依赖自身状态 需要外部只读数据(汇率、费率表)

判据:一个规则如果不能自然地归属到某个聚合根,就别硬塞。A-1536 的原文也提醒了另一半:"不要以为定义了聚合对象,就把超越1个对象以外的逻辑,都封装到聚合中",聚合只做"和本对象相关的单一简单封装",重逻辑放 service。

5.2 领域事件与发件箱(E-S18-013)

领域事件是"领域内已经发生的事实",用过去时命名(OrderPaid、InventoryReserved)。真问题不是"怎么发",而是"什么时候发":

发布时机 问题 适用
事务内同步发布 事务回滚了,事件已发(对进程内监听器) 同进程、可容忍
事务提交后发布 提交成功但发布失败 → 事件丢失 低价值通知
发件箱(outbox) 事件与业务数据同库同事务写入 outbox 表,再由投递器发 MQ 高价值、需可靠

outbox 的最小形态是三列:id / payload / status,一个定时任务扫 status=pending 投递并置 sent。代价是多一张表 + 一个投递器;收益是**"业务成功 ⇔ 事件一定被发出"**(至少一次,配合消费端幂等)。

判据:如果"事件丢了"会导致钱、货、账不一致 → 必须 outbox;如果只是发个站内信 → 事务提交后直接发即可。


6. 战术建模的边界:什么时候不要用

场景 用 DDD 战术建模? 理由
纯 CRUD 后台(录入、查询、导出) ❌ 没有不变式,充血对象只是搬运工
报表 / 数据分析 ❌ 读模型为主,用 CQRS 的读侧即可
一次性脚本 / 数据迁移 ❌ 生命周期短,七段骨架(J01 §5)更合适
复杂业务规则、长周期迭代 ✅ 不变式多,改一处要动多处
多团队协作的领域系统 ✅ 需要统一语言与边界

这也是必须纠正的一条旧说法:DDD 不是"所有项目都该上",也不是"上了 DDD 就要 CQRS + 事件溯源"。CQRS 与事件溯源是可选的战术补充,它们解决的是"读写负载差异大"和"需要完整审计/回放",不是 DDD 的组成部分(参见 Martin Fowler 的 CQRS 与 Event Sourcing 条目,两篇都明确把它当作独立模式讨论)。


7. 常见误区

误区 真相
"充血 = 把 Service 的方法搬进实体" 充血对象里不能有 RPC/DAO/Redis 调用;有就不是领域对象
"DDD 必须 CQRS/事件溯源" 二者是可选补充,与 DDD 无强制关系
"一个聚合越大越完整" 大聚合 = 全局锁 + 加载慢;聚合应尽可能小
"值对象加个 ID 方便查询" 加了 ID 就变成实体,且会出现两处可改的一致性问题
"仓储 = DAO" 仓储粒度是聚合,DAO 粒度是表
"领域事件发了就完事" 关键在时机:事务内发会"回滚后仍发出",需 outbox

8. 自测

  1. 你的 Order 对象上有没有 setter?如果有,谁能改它的状态?
  2. "订单 + 明细 + 收货地址"里,哪些是实体、哪些是值对象、为什么它们在一个聚合里?
  3. 跨聚合一致性你会用分布式事务还是领域事件?判据是什么?
  4. 值对象用 JSON 列存,什么时候会反噬?
  5. OrderRepository.save() 和 OrderDao.insert() 的粒度差在哪?
  6. 什么情况下"事件丢了"可以接受?

下一篇

S18-A01 · 限界上下文与上下文映射(战略 DDD)

战术建模解决"一个领域内部怎么建",战略设计解决"领域边界怎么划、划完怎么协作"。下一篇讲事件风暴的产出物、限界上下文的四个划分信号、上下文映射的九种关系,以及两张未发布的旧卡(账户域、交易域)留下的提纲。


本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-1533、A-1534(建模目的部分);扩展点 E-S18-010/011/012/013。全部输出为 Java 17.0.20 实测(DddLab.java 单文件运行);CQRS/事件溯源口径引自 Martin Fowler bliki。

S18-J06 · DDD 战术建模:实体、值对象、聚合
https://www.clxhxhhr.top/posts/5440/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。