Skip to content

DDD 领域驱动设计 ​

S6-018选修高级约 90 分钟JDK 21Java 高级与架构 · 架构设计

1. 本节定位 ​

微服务拆分回答了「拆的代价」;DDD 回答「按什么拆、拆完每个边界里怎么建模」。

★ 本节把 DDD 战术设计做成 5 组可运行实测 ✓✓:

text
贫血模型:setter 一调 → **「已支付 + 总价 0.01 元 + 3 条明细」的非法状态被构造出来** ✗✗✗
充血聚合:金额不足 / 已支付改明细 / 超 5 条明细 → **全部被聚合根拒绝** ✓✓✓
值对象  :跨币种相加直接抛异常、运算返回新对象 ✓✓
领域事件:先发事件后回滚 = 「幽灵订单」✗✗;提交后发布 = 安全 ✓✓
限界上下文:订单与库存各自建模 + 防腐层翻译 ✓✓

2. 学习目标 ​

  • 说清贫血模型为什么会「破防」,充血模型如何守住不变量 ★★★;
  • 会用值对象表达「钱、地址、区间」这类概念 ★★★;
  • 能设计小聚合,并解释「一次事务只改一个聚合」★★★;
  • 知道领域事件的正确发布时机与幽灵订单的关系 ★★★;
  • 理解限界上下文与防腐层的作用 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

★ 大部分「业务系统」的代码长这样 ✗:

java
order.setStatus("PAID");
order.setTotalCents(1);
order.setItemCount(3);
// 编译通过、保存成功 —— 一个「不可能」的订单进入了数据库 ✗✗✗

★★ 根因 ✓✓:业务规则写在了 Service 里,而模型只是「数据袋子」 —— 只要有一个入口忘了校验,规则就破防 ✗✗;DDD 的答案:把规则放回模型内部 ✓✓✓。

5. 核心概念 ​

5.1 贫血 vs 充血:规则放在哪,决定守不守得住(实测)★★★ ​

★ 贫血模型实测 ✗✗:

text
直接 set:状态=PAID,总价=1 分,明细=3 条
→ **已支付、总价 0.01 元、却有 3 条明细** —— 一个不可能的组合被构造出来了 ✗✗✗

★ 充血模型实测(同样三个非法操作,全部被拒) ✓✓✓:

text
支付 40 元(应付 50 元)→ 被拒:支付金额不足:应付 5000 分,实付 4000 分 ✓✓
已支付后加明细 → 被拒:订单 9527 状态为 PAID,不能改明细 ✓✓
第 6 条明细 → 被拒:单笔订单最多 5 条明细 ✓✓

★★ 一句话 ✓✓✓:贫血模型把「规则」变成「纪律」,充血模型把规则变成「物理约束」 —— 纪律会松,物理约束不会 ✓✓。

5.2 值对象:不可变 + 按值相等(实测)★★ ​

text
a = 10.00 CNY,b = 10.00 CNY → a.equals(b) = true ✓(按值相等,不是按引用)
a.plus(c) = 15.00 CNY,而 a 仍然是 10.00 CNY ✓✓(运算返回新对象,原对象不变)
跨币种相加 → 直接抛异常:币种不同,不能相加:CNY vs USD ✓✓

★ 什么时候用值对象 ✓:「没有身份、只关心值」的概念 —— 钱、日期区间、地址、坐标、颜色 ✓; 「有生命周期、要区分个体」的才用实体(订单、用户、设备)✓。

★ 判断技巧 ✓✓:问「两个对象可不可以互换」 —— 两张 10 元钞票可以互换(值对象); 两个订单号 9527 与 9528 不能互换(实体)✓。

5.3 聚合:一致性边界(实测)★★★ ​

★ 聚合的三条设计准则 ✓✓✓:

准则含义实测对应
规则内聚违反不变量的操作无法通过外部调用完成三个非法操作全被聚合根拒绝 ✓✓
一次事务只改一个聚合跨聚合的一致性靠领域事件 + 最终一致订单聚合 → 事件 → 库存命令 ✓
聚合要小只保护「必须立刻一致」的部分订单项是内部实体,不单独建聚合 ✓

★★ 为什么「小聚合」是关键 ✓✓✓:聚合是加锁的粒度 —— 大聚合 = 高冲突(并发都抢一把锁)+ 长事务 ✗✗; 把「可最终一致」的部分移出聚合,系统才能并发起来 ✓✓(呼应 《高并发系统设计》 的并发设计)。

5.4 领域事件:发布时机决定「幽灵订单」(实测)★★★ ​

text
-- 错误做法:先发事件、后提交事务 --
  事件已发出:[OrderPaid(order=9530, amount=1000分)]
  事务回滚(如后续步骤失败)→ 下游已收到「订单已支付」,但订单实际不存在 ✗✗✗

-- 正确做法:事务提交后再发布 --
  事务提交 ✓ → 发布:[OrderPaid(order=9531, amount=1000分)] ✓✓

★★ 正确姿势 ✓✓✓:聚合内先「收集」事件 → 本地事务提交成功 → 再「发布」(进程内发布 + 事务后投递); 下游消费必须幂等 —— 事件可能重复投递(呼应 《分布式事务》 的四件套)✓✓。

5.5 限界上下文与防腐层(实测)★★★ ​

text
订单上下文的「商品」:OrderContextItem[sku=sku-1, priceCents=3000, snapshotName=机械键盘](关心价格快照)
库存上下文的「商品」:InventoryContextItem[sku=sku-1, stock=42, safetyStock=5](关心库存量)
防腐层把「订单已支付」翻译成:InventoryCommand[sku=sku-1, quantity=1, reason=订单支付成功 → 扣减库存]

★★ 核心原则 ✓✓✓:同一个词(商品)在不同上下文里是「不同的模型」 —— 强行共用一个「大而全」模型 = 改一处崩两处 ✗✗; 各自建模 + 防腐层(ACL)转换 = 上下游独立演进 ✓✓。

★ 战略设计的产物 ✓:限界上下文边界 + 上下文映射(谁调谁、怎么翻译)—— 这才是拆微服务的「业务依据」✓✓。

6. 递进示例 ​

6.1 充血聚合的骨架 ​

java
void addItem(String sku, long cents) {
    if (status != CREATED) throw new IllegalStateException("状态为 " + status + ",不能改明细");
    if (items.size() >= MAX_ITEMS) throw new IllegalStateException("单笔订单最多 " + MAX_ITEMS + " 条明细");
    items.add(new OrderItem(sku, cents));
}

★ 注意 ✓✓:聚合的方法「要么成功、要么抛异常」,没有中间态 —— 校验通过才改状态 ✓。

6.2 最小值对象 ​

java
record Money(long cents, String currency) {
    Money plus(Money other) { requireSameCurrency(other); return new Money(cents + other.cents, currency); }
}

6.3 反例:把「库存」也塞进订单聚合 ​

text
Order 聚合里直接改 inventory.stock —— 两个聚合被一个事务锁住 ✗✗
正解:订单提交后发 OrderPaid 事件,库存上下文自己扣(最终一致)✓✓

7. 完整可运行代码与实测 ​

代码:labs/java-deep/src/main/java/cn/kb/learn/deep/architecture/DddTacticsLesson.java

实测输出(节选):

text
===== 1. 贫血模型:规则不在模型里,就守不住 =====
  直接 set:状态=PAID,总价=1 分,明细=3 条
  → **已支付、总价 0.01 元、却有 3 条明细** —— 一个不可能的组合被构造出来了 ✗✗✗

===== 3. 聚合根:不变量在模型内部,不靠调用方自觉 =====
  支付 40 元(应付 50 元)→ 被拒:支付金额不足:应付 5000 分,实付 4000 分 ✓✓
  已支付后加明细 → 被拒:订单 9527 状态为 PAID,不能改明细 ✓✓

===== 4. 领域事件:发布时机决定「幽灵订单」会不会发生 =====
  -- 错误做法:先发事件、后提交事务 --
    事务回滚(如后续步骤失败)→ 下游已收到「订单已支付」,但订单实际不存在 ✗✗✗
  -- 正确做法:事务提交后再发布 --
    事务提交 ✓ → 发布:[OrderPaid(order=9531, amount=1000分)] ✓✓

8. Debug 观察点 ​

  1. 给贫血版加一行校验,再绕开它(新入口)→ 体会「纪律式防护」的脆弱 ✗✗✓;
  2. 把 MAX_ITEMS 从 5 改成 10:观察聚合是唯一需要改的地方 ✓✓;
  3. 把事件发布挪到「事务提交前」再跑:复现幽灵订单 ✗✗✓;
  4. 对照项目 A 的 BorrowRecord:借书动作是否由聚合根守护?(逾期、续借次数、状态流转)✓✓✓。

9. 常见错误 ​

#错误说明
1模型只有 getter/setter ✗✗✗规则散落,迟早破防 ✓✓
2用一个事务改多个聚合 ✗✗锁冲突 + 长事务;跨聚合用事件 ✓✓
3聚合做大(恨不得装下整个领域) ✗✗并发全被一把锁串起来 ✓✓
4先发事件后提交事务 ✗✗✗幽灵订单 / 幽灵支付 ✓✓
5两个上下文共用一张表/一个模型 ✗✗改一处崩两处 ✓✓
6把 DDD 当技术(只搬代码结构) ✗DDD 的第一产物是边界与语言,不是目录 ✓✓

10. 动手实验 ​

  1. 跑 DddTacticsLesson 五段实验 ✓✓✓;
  2. 给聚合加一条新不变量(如「单笔明细金额 ≤ 2 万元」),只在聚合内实现 ✓✓;
  3. 给「订单已支付」事件补一个幂等消费的库存扣减(呼应 《分布式事务》)✓✓✓;
  4. 把项目 A 的「借书」重构成充血聚合:BorrowRecord.borrow()/renew()/returnBook() 守护规则 ✓✓✓。

验收方式:给一个业务概念,能判断它是实体还是值对象、聚合边界画在哪、哪些规则要内聚、跨聚合怎么同步。

扩展任务:为项目 A 写一页《领域模型说明》:上下文边界、聚合清单(含不变量)、领域事件清单与消费方 ✓✓

11. 练习题 ​

  1. 贫血模型为什么「迟早破防」?充血模型靠什么守住规则?
  2. 判断值对象的两条标准是什么?「钱」为什么必须是值对象?
  3. 聚合的三条设计准则?「聚合要小」的工程原因是什么?
  4. 领域事件应该在什么时机发布?发早了会怎样?
  5. 限界上下文中「同一个词不同模型」是什么意思?防腐层解决什么?
参考答案(要点)
  1. 规则散落、依赖调用方自觉;聚合根内部校验 + 只暴露安全方法 ✓✓★
  2. 无身份 + 可互换;钱可变会导致共享污染与对不上账 ✓✓★
  3. 规则内聚 / 一次事务一个聚合 / 聚合要小;小 = 锁粒度小、并发高 ✓✓★
  4. 事务提交后发布;发早了回滚时下游已消费 → 幽灵数据 ✗✗★
  5. 同一概念在不同上下文各有模型;ACL 负责翻译,隔离上游变化 ✓✓★

12. 掌握标准 ​

  • [ ] 能对比贫血与充血并说出代价 ★★★;
  • [ ] 会用值对象建模 ★★★;
  • [ ] 能画聚合边界并写出不变量 ★★★;
  • [ ] 知道领域事件的正确发布时机 ★★★;
  • [ ] 理解限界上下文与防腐层 ★★★。

13. 扩展资源 ​

  • 《领域驱动设计》(Eric Evans,战略与战术设计的原点)
  • 《实现领域驱动设计》(Vaughn Vernon,聚合与事件实战)
  • DDD Reference(免费速查)

14. 下一步学习 ​

《服务网格》 服务网格 —— 从「代码内的模式」转向「基础设施里的能力」。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引