Chapter 09
自测与回顾
前八章建立了从战略到代码的全套判断力——问题空间与解空间、限界上下文、事件风暴、聚合、战术构件、领域事件、架构与陷阱。本章把它收敛成可自测的判断题与一次综合演练,逼出"在两个方案之间选得对"这层能力。
怎么用这一章
- 合上前八章作答。把答案写在纸上或编辑器里,别只在脑子里"想一下"——想得通和写得出差着一整层。
- 四组辨析题(A 概念 / B 聚合与一致性 / C 战术 / D 边界)逐组回扣 2-3 章,每题只问一件事,可独立作答。
- 综合辨析演练是重点:从一段业务描述跑通 事件风暴 → 限界上下文 → 聚合 → 战术构件,并在关键处二选一。
- 所有答案集中在页面最底部一个折叠块里——做完一整组再去对。
这一章不是把术语再背一遍,而是检验你能不能在真实判断里调用它们。先看每组辨析题考的是哪几章、彼此如何咬合:
A概念辨析(回扣 02 战略 + 03 限界上下文)
- 用一句话区分问题空间与解空间:子域属于哪一边,限界上下文属于哪一边?为什么这条分界决定了"先识别什么、后设计什么"的顺序?
- 有人说"一个子域就对应一个限界上下文"。这句话哪里对、哪里靠不住?给出一个子域被拆成多个上下文、以及多个子域共用一个上下文的可能情形。
- 在云市里,限界上下文(如
Sales)和代码里的模块(package)是不是一回事?如果不是,二者各自约束的是什么?
B聚合与一致性(回扣 05 聚合 + 07 领域事件)
- 不变量"订单总额
total== Σ(line.unitPrice × line.qty)":它该被放进Order聚合内用事务一致保证,还是跨聚合最终一致?判定依据是什么? - 不变量"下单后库存可用量不能为负":下单发生在
Sales,扣减发生在Inventory。这条该在同一事务里保证,还是用OrderPlaced→StockReserved的最终一致链?凭什么判? - "一个事务只改一个聚合"这条经验法则,为什么说它源自乐观锁、而不是某种性能玄学?把聚合当成"版本号单位"来想,一个事务跨两个聚合会发生什么?
- 把
Order聚合做得很大(直接内嵌StockItem、把库存也并进来),在并发下单时会付出什么代价?这和"小聚合"原则是什么关系?
C战术辨析(回扣 01 领域与语言 + 06 战术构件)
- 下面这段代码是不是贫血模型?依据哪一条来判?要把它救回来,规则应该搬到哪里去?
// Order 只有 getter/setter,是个数据袋
public class Order {
private OrderStatus status;
private List<OrderLine> lines;
private Money total;
// ... 全是 get/set ...
}
// 规则全堆在 Service 里
@Service
public class OrderService {
public void confirm(Order order) {
if (order.getLines().isEmpty())
throw new IllegalStateException("空订单不能确认");
if (order.getStatus() != OrderStatus.CREATED)
throw new IllegalStateException("状态非法");
order.setStatus(OrderStatus.CONFIRMED); // 状态机散在外部
}
}
- 领域服务(如
PricingService)和应用服务(如PlaceOrderApplicationService)的分界在哪?给定一段"算一笔订单价 = 单价 + 优惠券 + 会员折扣 + 满减"的逻辑,它该进哪个?为什么不能塞进单个实体的方法里? - 应用服务
PlaceOrderApplicationService.placeOrder()里允许出现哪些动作、绝不该出现哪些动作?用云市下单流程举一个"越界"的反例。
D边界辨析(回扣 03 限界上下文 + 08 架构与陷阱)
- 团队决定把云市五个限界上下文一一拆成五个微服务、各自一库一进程。这个决定哪里可能出错?"限界上下文 = 微服务"这条硬规则被推翻后,今天的默认起点是什么?
- 有人在
Sales上下文里要求"实时同步看到Inventory的最新库存数字",于是把两个上下文合进一个事务、一个库。这是把什么样的需求误当成了一致性需求?正确的处理是什么? - 一个"管理后台的运营标签维护"功能:增删改查几张表,没有不变量、没有状态机、没有跨表规则。该不该给它套上聚合、仓储、领域服务这一整套?什么信号告诉你应该退回 CRUD?
★综合辨析演练 · 云市新增"售后退货"(必做)
这是本章的压轴题。它把前八章拧成一条链,要求你在关键处二选一,并讲清"为什么不是另一个"——选对不算赢,说出依据才算。
云市要新增"售后退货":买家对一笔已支付(PAID)甚至已签收(COMPLETED)的订单,针对其中某几个订单项发起退货;运营审核通过后,Payment 退款、Inventory 把退回的货品重新入库、买家收到退款通知。一笔订单可多次部分退货,但退货总额不得超过该订单已支付金额。
按下列步骤跑一遍,每到带 「选」 的地方先停下、二选一并写下依据:
- 事件风暴:在这段描述里圈出至少 4 个领域事件(过去式命名),并标出触发它们的命令。提示:
ReturnRequested(退货已申请)是起点。 - 限界上下文:这条退货流程横跨哪几个上下文?退货审核这件事,更像
Sales的职责还是要新立一个AfterSales(售后)上下文?说出你的取舍。 - 「选」聚合归属:退货单(
ReturnOrder)是Order聚合的一部分(作为Order内部的一个集合成员),还是一个独立聚合(按 ID 引用orderId)?二选一,给依据。 - 「选」退款金额一致性:"退货总额不得超过已支付金额"这条不变量:用事务一致(退货单与该校验在同一聚合、同一事务内裁决)还是最终一致(先收下退货申请、异步再核对金额、超额则补偿驳回)?二选一,给依据。
- 「选」退款与重新入库:审核通过后,"
Payment退款"和"Inventory重新入库"这两个动作,跟退货单的状态变更放同一事务,还是用领域事件ReturnApproved驱动、各上下文异步各做各的?二选一,给依据。 - 战术构件:为退货流程点名出至少四个战术构件——聚合根、值对象、领域服务(若需要)、应用服务、仓储——并说清每个装的是什么。
这六步至少强制你在 05 聚合 与 07 领域事件、以及 03 限界上下文 与 06 战术构件 的方案之间各做一次选择。答案在页尾折叠块。
合上教程,在纸上或 Excalidraw 里画两张图:(1)Order 聚合的边界——把聚合根 Order、内部成员 OrderLine[] / ShippingAddress / Money 圈进一个框,再用虚线箭头 + ID 画出它对 buyerId / skuId 的"按标识引用"(不是对象指针);(2)云市五个上下文的上下文映射——把 Catalog / Sales / Inventory / Shipping / Payment 五个框连起来,在每条连线上标出它们之间的集成关系方向。
画完翻回 05 聚合 对照聚合边界、翻回 03 限界上下文 对照上下文映射。两件事最容易画错:把 OrderLine 画成独立框、用实线对象指针连 buyerId——若你也这么画了,正好回去重读"按 ID 引用"那一节,这是聚合最该被记住的约束。
#何时该退回 CRUD 清单
DDD 的战术构件是有成本的——聚合、仓储、领域服务、防腐层都要写、要维护。均匀地把它铺到每个角落最贵。下表给"该上 DDD"与"该退回 CRUD/事务脚本"各自的信号,对照云市自查每个功能落在哪一栏。
| 该上 DDD 战术构件(核心域 / 复杂域) | 该退回 CRUD / 事务脚本(支撑域 / 通用域) |
|---|---|
| 有真正不变量要时刻守住(如订单总额 == 行小计之和、状态机单向) | 没有不变量,字段之间互不约束,存什么读什么 |
| 有状态机,状态迁移有前置条件、不可跳级回退 | 没有状态机,或只有"草稿/发布"这种一两态开关 |
| 业务规则会随时间长出来、且规则本身是竞争力(核心域) | 规则稳定、是行业通用做法,买现成或抄标准实现即可(通用域) |
| 同一个词在不同上下文语义冲突,需要显式翻译边界 | 就是几张表的增删改查,运营后台、配置维护、字典管理 |
| 读写模型差异大、并发争用明显,值得 CQRS / 聚合拆分 | 低频、单人操作、并发可忽略,一把 service + DAO 足够 |
右栏命中越多,越该退回 CRUD——这不是"偷懒",而是把 DDD 的预算省下来投到真正值得守护不变量的核心域(云市的 Sales)上。给一个增删改查的运营标签功能套上聚合 + 仓储 + 领域服务,是过度设计,徒增维护成本而无收益。
全部答案(四组 + 演练都做完再展开)
A · 概念辨析
- 问题空间是"业务要解决什么",里面住着子域(核心/支撑/通用);解空间是"我们怎么建模来解决它",里面住着限界上下文和模型。子域是被识别出来的(发现),限界上下文是被设计出来的(选择)——所以顺序是先在问题空间里分辨核心域在哪、值不值得投入,再到解空间里划上下文。把这条颠倒,就会一上来就画架构图、还没搞清哪块是核心。
- "一子域一上下文"是理想对齐下的常见情形,不是定律。子域被拆成多上下文:一个庞大的"销售"子域可能因团队/上线节奏被切成"下单"和"促销"两个上下文。多子域共用一个上下文:一个遗留单体里"库存"和"配送"两个子域可能暂时挤在同一个上下文内,靠后续演进再拆。判据是语义边界与团队边界,不是子域数量。
- 不是一回事。限界上下文是语义边界:同一个词(如"商品 Product")在
Sales里是下单快照、在Catalog里是胖展示对象,跨界必须翻译。模块是代码组织单位,约束的是 package/编译/可见性。一个上下文内部可以有多个模块;但模块不能跨越上下文的语义边界去直接引用另一个上下文的领域类。
B · 聚合与一致性
- 放进
Order聚合内、事务一致。判据是 Vernon 的"谁必须立刻看到"规则:买家删一行、总额必须当场对得上,这是下单者自己必须即时看到的不变量,且total与line同属一个聚合的状态。它是Order的真正不变量,由聚合根在每次改行时重算并保证。 - 用最终一致,走
OrderPlaced→ 库存上下文异步预留 →StockReserved的链路。判据同样是"谁必须立刻看到":下单的买家不必在提交订单的同一事务里看到库存扣减,扣减是仓库的职责,且Order与StockItem是两个独立聚合、分属Sales与Inventory两个上下文。强行塞进一个事务=把两个聚合、两个上下文耦死。 - 因为聚合是乐观锁的版本号单位:每个聚合根带一个 version,提交时比对、不一致就重试。一个事务只改一个聚合,意味着一次提交只竞争一个 version、冲突面最小。跨两个聚合时,一个事务要同时锁住两个 version,任一被别人改过就整体回滚——争用面翻倍,且把本可独立演进的两个一致性边界焊死。所以这条不是性能玄学,是并发模型的直接推论。
- 把
StockItem并进Order,意味着每一次下单都要竞争同一批热门 SKU 的库存版本号——高并发下单时,所有人改的是同一个大聚合,version 冲突频发、重试风暴、吞吐崩塌。小聚合原则正是这条的解药:聚合只圈进"必须时刻一致"的那点状态(由真正不变量决定),其余(库存)拆成独立聚合、用最终一致连接,把争用打散。
C · 战术辨析
- 是贫血模型。判据(Fowler 的 AnemicDomainModel):领域对象
Order只有 getter/setter、不含行为,业务规则(非空校验、状态机迁移)全被抽到OrderService里——对象成了数据袋,"面向对象"只剩其表。救法:把confirm()的规则搬回Order自己,成为order.confirm()——由聚合根守护"确认前订单项非空、状态必须是 CREATED"这些不变量,外部只能通过这个方法迁移状态,无法setStatus()绕过。 - 分界:领域服务装真实领域逻辑(一段重要业务规则,但不自然属于任何单个实体或值对象),它的名字进入统一语言;应用服务只做编排(load → 调领域逻辑 → 存 → 发事件 → 管事务和安全),零业务规则。"算订单价 = 单价 + 优惠券 + 会员折扣 + 满减"跨了商品、优惠券、会员多个概念,不属于
Order单个实体,所以是领域服务PricingService。塞进单个实体会让该实体凭空依赖优惠券、会员等它本不该知道的东西,撑破它的职责。 - 应用服务里允许:加载购物车 / 调
PricingService算价 / 用工厂Order.place(...)造订单 /orderRepository.add(order)/ 发布OrderPlaced/ 开启与提交事务 / 鉴权。绝不该出现:金额怎么算、状态能否迁移、订单项非空这类领域规则。越界反例:在placeOrder()里直接写if (status == CREATED) status = CONFIRMED或手算 total——这等于把不变量从聚合里漏到编排层,Order重新沦为贫血数据袋。
D · 边界辨析
- 问题:把语义边界(限界上下文)当成了部署边界(微服务)。"一上下文一服务"是被推翻的硬规则——五个进程意味着五份分布式事务、网络重试、跨服务一致性的全部成本,而很多上下文之间根本不需要独立伸缩或独立部署。今天的默认起点是模块化单体:先用清晰的上下文边界 + 模块隔离,把服务"从边界涌现"出来——确有独立伸缩/团队/发布节奏的诉求时,再把某个上下文拆成服务。
- 把"想看到最新数据"误当成了"必须事务一致"。"看到最新库存"是一个读模型/查询新鲜度问题,不是写一致性问题——下单这个写操作的正确性并不要求库存数字在同一事务里冻结。正确处理:
Sales通过查询接口或Inventory推送的事件维护一份库存读模型(可有秒级延迟),下单时按最终一致预留库存(StockReserved/StockReservationFailed),而不是把两个上下文合库合事务。 - 不该套。运营标签维护是典型的支撑域 CRUD:增删改查几张表、没有真正不变量、没有状态机、没有跨表规则。退回 CRUD 的信号——没有需要时刻守住的不变量、没有状态迁移前置条件、规则稳定且非竞争力、并发可忽略。给它套聚合 + 仓储 + 领域服务是过度设计,把核心域才值得花的预算浪费在不产生价值的地方。一把 Service + DAO 足够。
★ · 综合演练:售后退货
- 事件风暴:
ReturnRequested(退货已申请,命令RequestReturn)→ReturnApproved/ReturnRejected(退货已审核,命令ReviewReturn)→RefundIssued(退款已发起,Payment)→StockRestocked(货品已重新入库,Inventory)→BuyerNotified(买家已通知)。命令在前、事件在后,事件全用过去式。 - 限界上下文:横跨
Sales(订单上下文,提供 orderId 与已支付金额)、Payment(退款)、Inventory(重新入库),以及承载退货流程本身的归属。退货审核有自己的状态机、规则和生命周期,与下单的状态机不同——更宜新立AfterSales(售后)上下文,而非把它的状态硬塞进Sales的Order里撑大下单上下文。代价是多一条上下文边界要翻译;收益是两个状态机互不污染。 - 「选」聚合归属 → 独立聚合。
ReturnOrder应是独立聚合,按 ID 持orderId(不是把它塞进Order内部集合)。依据:一笔订单可多次部分退货,退货有自己的状态机和不变量(退货总额 ≤ 已支付金额),生命周期与Order解耦;若并进Order,下单聚合会越长越大、退货并发改的又是Order的版本号,违反小聚合、制造争用。按标识引用让两者各守各的一致性边界。 - 「选」退款金额一致性 → 事务一致(在
ReturnOrder聚合内裁决)。"退货总额 ≤ 已支付金额"对单笔退货单而言是它自己的真正不变量:审核通过的那一刻必须当场拒绝超额,不能"先收下、过会儿再发现超了"。把"该订单累计已退金额"作为ReturnOrder(或其上下文内的退货账本)的状态,在同一事务内校验通过才允许进入APPROVED。注:这里事务一致的是退货单自己的金额校验,不是要把Payment的实际退款也拉进同一事务(见下条)。 - 「选」退款与重新入库 → 最终一致,由
ReturnApproved事件驱动。审核通过、退货单状态置为APPROVED(含金额校验,事务一致)后,发ReturnApproved事件;Payment订阅它去退款、Inventory订阅它去重新入库,各自异步、各管各的聚合与事务。依据:这三件事分属三个上下文/三个聚合,强行同事务=跨上下文分布式事务、把三者焊死;用事件 + 幂等 + 必要时补偿(退款失败则回退退货单状态),既满足"谁必须立刻看到"(买家不必在审核那一刻就看到钱到账),又保持上下文自治。 - 战术构件:聚合根
ReturnOrder(守退货状态机与金额不变量);值对象Money(退款金额,复用)、ReturnLine(退货项,内部实体/值对象,持 skuId + 数量);领域服务(若退款金额计算涉及优惠分摊、运费扣减等跨概念规则,可设RefundCalculationService);应用服务ReviewReturnApplicationService(编排:load 退货单 → 调审核 → 存 → 发ReturnApproved,零规则);仓储ReturnOrderRepository(一聚合根一仓储,add/returnOrderOfId)。
·四组都讲得出依据?那你已经握住主线了
如果四组辨析与那道退货演练你都能讲出"为什么不是另一个",那么贯穿全书的几条主线——限界上下文是语义边界、聚合是一致性边界、事务一致 vs 最终一致是组织问题、领域服务装逻辑应用服务只编排、均匀地用 DDD 最贵——就不再是口号,而是你能拿去推导任何陌生领域的工具。往下可以走这些方向:
- 函数式 DDD:用类型让非法状态无法表示(Wlaschin《Domain Modeling Made Functional》),思想已从 F# 扩散到 TypeScript / Rust / Kotlin。
- 按上下文裁剪 CQRS / 事件溯源:社区已主动把它们与 DDD 解耦——只在真正值得的核心域投入,别全局铺开。
- 上下文映射的九种关系:把 03 的客户/供应商、防腐层、共享内核等关系,套到你手上真实系统的边界上重画一遍。