适用读者:服务层(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
这六行几乎覆盖了战术建模的全部要点,逐行解释:
record生成的值对象按值相等(equals为 true)但不是同一个对象(==为 false)——这正是"值对象没有身份"的定义;- 聚合自己算总额(
3200= 1200×2 + 800),计算逻辑在对象内,不在 Service; - 空订单调用
pay()抛empty order——不变式被守住; - 已支付的订单再
addLine()抛locked in PAID——状态迁移约束被守住; - 币种不同的金额相加抛
currency mismatch——值对象自带校验(这条最容易被漏掉,跨币种相加是真金白银的 bug); - 值对象只有 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. 自测
- 你的
Order对象上有没有 setter?如果有,谁能改它的状态? - "订单 + 明细 + 收货地址"里,哪些是实体、哪些是值对象、为什么它们在一个聚合里?
- 跨聚合一致性你会用分布式事务还是领域事件?判据是什么?
- 值对象用 JSON 列存,什么时候会反噬?
OrderRepository.save()和OrderDao.insert()的粒度差在哪?- 什么情况下"事件丢了"可以接受?
下一篇
S18-A01 · 限界上下文与上下文映射(战略 DDD)
战术建模解决"一个领域内部怎么建",战略设计解决"领域边界怎么划、划完怎么协作"。下一篇讲事件风暴的产出物、限界上下文的四个划分信号、上下文映射的九种关系,以及两张未发布的旧卡(账户域、交易域)留下的提纲。
本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-1533、A-1534(建模目的部分);扩展点 E-S18-010/011/012/013。全部输出为 Java 17.0.20 实测(DddLab.java 单文件运行);CQRS/事件溯源口径引自 Martin Fowler bliki。