Chapter 04
事件风暴:从战略边界推到战术聚合的桥
上一章画出了上下文边界,本章用一面事件墙把边界推进到聚合——让聚合从协作中浮现,而非凭空设计。
本章你将建立的 schema
- 为什么建模从"过去式的事件"起步,比从名词或 ER 图起步更快暴露关键节点
- 事件风暴 Event Storming 的三个缩放层级:全局 / 流程级 / 设计级,各自回答什么问题
- 设计级六种贴纸的语义:领域事件 / 命令 / 聚合 / 策略 / 读模型 / 参与者
- 一条推导链如何把事件墙变成上下文、聚合,最终落到代码骨架
- 领域叙事 Domain Storytelling 与实例化映射 Example Mapping 如何与事件风暴互补
前三章给了边界:领域与统一语言(01)、子域划分(02)、限界上下文与一词多义(03)。边界是一组"框",但框里要放什么类、哪些字段必须在同一个事务里改、哪个对象是入口——这些战术问题,光盯着框看不出来。本章给一个把"框"展开成"协作流程"的具体工作法:事件风暴。它的产出不是图好看,而是一串可以直接翻译成命令、聚合、读模型的贴纸,正好接上 05 章的聚合设计。
全章用同一个具体场景走查:「云市」平台上一个买家把购物车结算成订单、订单被支付、库存被预留、运单被创建的全过程。这条主线会在 4.4 节被逐格拆成贴纸,再被翻成代码。
4.1为什么从"事件"起步,而不是名词或 ER 图
事件风暴:让业务专家用过去式的"已发生的事"来叙述业务,把这些事件按时间贴成一面墙,关键节点和知识盲区会自己浮出来。
传统建模从名词起步——先画实体、连关联、补字段,得到一张 ER 图。问题是名词隐藏了时间和因果:一张 订单 表看不出"下单"和"支付"之间还有一次异步的库存预留,也看不出"运单创建失败"该怎么补偿。业务的风险恰恰藏在这些状态转移里,而不是藏在字段里。事件优先,等于先把时间轴摊开,逼出每一次转移。
为什么是过去式:一个领域事件 Domain Event 是"领域里已经发生、业务关心的一件事实",命名用过去式——OrderPlaced(订单已下单)、OrderPaid(订单已支付)、StockReserved(库存已预留)。过去式不是语法洁癖:它强迫叙述者说"发生了什么",而不是"系统应该有什么按钮"。业务专家天然会用过去式讲故事——"客户把购物车结算了,然后我们这边收到款,仓库再去备货"。这种叙述里,每一个分句几乎都是一个领域事件。把它们抄成贴纸贴上墙,建模就开始了。
事件优先比名词优先快在哪:名词优先时,"商品 Product"是一个词,大家以为指同一个东西,争论被推迟到写代码时才爆发(这正是 03 章一词多义 polysemy 的来源——目录里的胖展示对象、销售里的下单快照、库存里的 SKU 是三个类)。事件优先时,分歧立刻可见:当有人贴出"价格已变更"而另一人贴出"下单价格已锁定",房间里马上意识到价格在下单那一刻被冻结,目录改价不影响已下单的订单——这是销售上下文里一条真实业务规则,名词建模要到很晚才会发现它。
像查事故,不是先画一张"涉事车辆零件清单"(名词/ER),而是先按时间排出"几点几分发生了什么"的时间线(事件墙),断点和矛盾的证词会自己暴露。类比失效处:事故时间线只为复盘已发生的事,事件风暴还要据此设计未来系统的命令与聚合——它既向后看也向前推。
暴露知识盲区,是它最被低估的产出。当一组事件之间贴不上"是谁、按什么规则触发的下一步",墙上就留下一个空洞。空洞处常用一张显眼的贴纸标成热点 hotspot(争议/未知/风险)。云市的典型热点:"运单已创建失败时,已经预留的库存怎么释放?"——这不是技术问题,是业务还没想清楚的规则。事件风暴的价值,一半在它贴出来的事件,另一半在它逼出来的这些问号。
4.2三个缩放层级:全局 / 流程级 / 设计级
事件风暴不是一种会,而是同一手法在三个缩放层级上的三场会:全局 Big-Picture 看地形、流程级 Process-Level 看一条流程、设计级 Design-Level 看一个聚合怎么落地。
"先发现业务、再设计模型"是这套教程的两段式主线。一场会同时干"探索整个企业"和"敲定一个聚合的字段"是做不到的——参会人不同、贴纸密度不同、产出物不同。分成三个层级,等于给探索装上变焦镜头:先广角扫地形找边界,再推近到一条核心流程,最后微距对准要落地的那个聚合。
| 层级 | 回答的问题 | 谁在场 | 主要产出 |
|---|---|---|---|
| 全局 Big-Picture | 整个业务由哪些大事件构成?边界、痛点、热点在哪? | 尽量多角色:业务、运营、客服、技术 | 一面长长的事件墙 + 一批热点 → 候选限界上下文 |
| 流程级 Process-Level | 某一条流程里,事件被谁的什么命令触发、依什么策略推进? | 这条流程相关的业务专家 + 开发 | 命令 / 策略 / 参与者 / 读模型补齐的一条流程 |
| 设计级 Design-Level | 这段流程要落成哪些聚合?每个聚合的边界和不变量是什么? | 开发为主 + 关键业务专家 | 聚合 / 命令 / 事件 / 读模型的对应关系 → 代码骨架 |
三层之间是缩放关系,不是审批流水线。全局层的一段事件序列(云市的"下单到发货")被挑出来,单独拉一场流程级;流程级里识别出的某个聚合候选(Order),再拉一场设计级把它的边界和不变量钉死。本书的主战场是设计级——因为只有设计级直接产出能翻成代码的结构,也只有它直接喂给 05 章的聚合设计。下一节先把设计级的贴纸语义对齐,后面才好做推导。
一个高频失败模式:刚把全局事件墙贴了一半,技术同学就跳进去争"这个聚合该不该拆表"。结果是业务专家被技术细节挡在门外,全局地形没扫完。纪律:全局层只贴事件、只标热点,不许出现命令、聚合、字段——把战术冲动留到设计级。
4.3设计级语义:六种贴纸各代表什么
设计级用六种颜色的贴纸表达一套固定语义:橙=领域事件、蓝=命令、黄=聚合、紫=策略、绿=读模型、黄底小人=参与者;颜色背后是确定的角色和连线规则。
事件墙能翻成代码,靠的不是"贴了很多纸",而是这套颜色即类型的约定。每种颜色对应模型里的一类构件,颜色之间的连接顺序对应一条固定的因果链。认清六种语义,才能在 4.4 节把一面墙机械地推导成命令、聚合、读模型。
下面是 Brandolini 约定的设计级语义。本教程是单色品牌(朱红 + 黑白),所以正文与图里用颜色名做文字标注,不真的上色——记住"橙色那张是事件"即可,颜色只是助记。
| 颜色(名) | 构件 | 含义 | 云市实例 |
|---|---|---|---|
| 橙色 | 领域事件 Domain Event | 已发生的、业务关心的事实,过去式命名 | OrderPlaced、StockReserved |
| 蓝色 | 命令 Command | 一个改变状态的意图/请求,祈使式命名 | 下单、预留库存、支付订单 |
| 黄色 | 聚合 Aggregate | 接收命令、守护不变量、发出事件的一致性单元 | Order、StockItem |
| 紫色 | 策略 Policy | "每当 X 事件发生,就触发 Y 命令"的反应规则 | 订单已下单 ⇒ 去预留库存 |
| 绿色 | 读模型 Read Model | 参与者做决定时需要看的信息视图 | 购物车视图、订单详情页 |
| 黄底小人 | 参与者 Actor | 发出命令的人或角色 | 买家 Buyer、仓库作业员 |
这六者不是平列,而是串成一条有方向的因果链,这是整套手法的发动机:
参与者 → 看读模型 → 发命令 → 命令落到聚合上 → 聚合发出事件 → 策略监听事件 → 策略触发下一个命令 →(回到聚合)……
云市里"系统每天 0 点把超过 30 分钟未支付的订单自动取消"。这条规则在设计级里,应该贴成哪种颜色的贴纸?它的上游是事件还是参与者?
停 10 秒,再展开
贴成紫色的策略 Policy。它的触发源不是某个人发命令,而是一个事件——这里是"时间到了"这种时间触发事件(可看作系统这个特殊参与者发出的钟表事件)。策略读到它,就对超时订单发出"取消订单"命令,命令落到 Order 聚合上产生 OrderCancelled。凡是"每当……就……"的自动反应,都是策略,不是参与者直接发的命令。
4.4从事件墙推出边界与聚合:一条推导链
事件 → 命令 → 聚合 → 读模型 → 上下文,再到代码:这条链让聚合从协作里被推导出来,而不是凭一张表关系图设计出来。
这一节是全章的承重墙,也是 02-03 的边界与 05 的聚合之间那座桥。聚合多大、入口是谁、哪些数据必须在同一事务里,传统做法靠"看表关系猜",结果常常是一个巨型聚合(05 章会讲它为什么是争用热点)。事件风暴给出另一条路:聚合从命令和事件的归属里浮现——谁接收这个命令、谁能保证发出那个事件,谁就是聚合。
先把云市下单主线一格一格摆成事件墙(按颜色名标注,不上色):
逐步把贴纸推成模型(这是机械的,不是灵感):
- 事件 → 命令:每个事件问"是哪个意图导致它发生的"。
订单已下单的前因是下单命令;库存已预留的前因是预留库存命令。命令和事件一一配对,命令祈使、事件过去式。 - 命令 → 聚合:每个命令问"谁来接收它、谁负责保证产出的那个事件的不变量"。下单落到
Order——只有Order能保证"总额 = Σ 行金额、状态从 CREATED 起步"这些不变量。预留库存落到StockItem——只有它能保证"预留量不超过可用量"。谁守护事件的不变量,谁就是聚合,这是聚合浮现的判据,比看表关系可靠。 - 事件 → 策略:两个聚合之间的衔接靠策略。"每当
订单已下单,就发预留库存命令"——这是一张紫色策略贴纸。注意它跨在销售和库存两个上下文之间,所以这次衔接不是同一个事务(05、07 章详解)。 - 读模型 → 参与者决策:买家点"结算"前,看的是绿色的
购物车视图读模型。读模型是参与者做决定的依据,不属于任何聚合的内部状态,常单独走查询路径(CQRS-lite,08 章)。 - 聚类成上下文:把"守护相关不变量、用同一套语言"的聚合圈在一起,就回到了 03 章的限界上下文。
Order在销售 Sales,StockItem在库存 Inventory,圈的位置正是图里那道朱红竖线。
最后一步:贴纸落到代码骨架。设计级的产出可以几乎机械地映射成 Java 结构——命令是一个意图对象、聚合是接收命令并发事件的类、策略是监听事件的反应。这里只给骨架说明模型,不要求端到端可运行;不变量与工厂的完整写法留给 05、06 章。
// 蓝色「命令」→ 一个表达意图的不可变对象
public record PlaceOrder(String buyerId, List<OrderLineDraft> lines) {}
// 黄色「聚合」Order:接收命令、守护不变量、发出橙色事件
public class Order {
private OrderStatus status; // CREATED -> CONFIRMED -> PAID -> ...
private final List<OrderLine> lines; // 聚合内部实体,外部不可直接持有
// 工厂:产出的 Order 已满足全部不变量(详见 05/06 章)
public static Order place(PlaceOrder cmd, PricingService pricing) {
// ……校验非空、用 PricingService 算价、置状态为 CREATED……
Order order = new Order(/* ... */);
order.raise(new OrderPlaced(order.id(), cmd.buyerId())); // 橙色事件
return order;
}
}
// 紫色「策略」:每当 OrderPlaced,就向库存上下文发「预留库存」命令
public class ReserveStockOnOrderPlaced {
public void on(OrderPlaced event) {
// 跨上下文、异步:不在下单同一个事务里(07 章最终一致)
commandBus.send(new ReserveStock(event.orderId()));
}
}
聚合不是"把有外键关联的表打个包"。它是"谁来守护一个事件的不变量"的答案——这正是 02 章核心域、03 章上下文之后,把战略边界落成战术结构的关键一跳。05 章会从另一头(一致性边界、乐观锁版本号)证明同一个结论,两条路在 Order 这个聚合上会合。
4.5互补工具:领域叙事与实例化映射
事件风暴不是唯一的协作建模法,而是工具箱里的一件:领域叙事擅长走通一个具体案例,实例化映射擅长把规则落成可验收的例子,三者分工互补,不是竞争。
事件风暴扫得宽、推得动流程,但有两处天然薄弱:一是它给的是"事件类型",不是"一个真实订单具体怎么走的完整故事";二是它贴出热点(争议规则)后,并不负责把规则收敛成可测的验收条件。这两处恰好是领域叙事 Domain Storytelling 与实例化映射 Example Mapping 的强项。把它们接在事件风暴前后,建模链条才完整。
领域叙事 Domain Storytelling:走一个具体案例
领域叙事让业务专家用一句话句式——"谁(参与者)用什么(工作对象)对谁做了什么"——把一个具体案例从头讲到尾,边讲边用图标和编号箭头记录。它的产出是一个带顺序的故事图,专治事件风暴容易停留在类型层面的毛病:与其讨论抽象的"下单流程",不如走一遍"买家张三把购物车里两件商品结算成订单 #1024、付款、仓库备货发运"。具体案例会逼出事件风暴漏掉的分支(缺货怎么办、优惠券失效怎么办)。Hofer 与 Schwentner 在《Domain Storytelling》里把它定位成统一语言(01 章)的采集器——故事里反复出现的名词和动词,就是该进统一语言的词。
实例化映射 Example Mapping:把规则落到验收例子
实例化映射(Matt Wynne 提出)处理事件风暴留下的热点。它用四色卡片围绕一条规则展开:黄卡=要讨论的故事,蓝卡=规则,绿卡=每条规则的具体例子,红卡=当场答不出的问题。云市那个热点"超时未支付如何取消",用实例化映射会被拆成:规则"创建后 30 分钟未支付则取消",例子"29 分钟时支付成功 → 不取消"、"31 分钟仍未支付 → 自动取消并释放预留库存",以及红卡"已部分支付的订单算不算未支付?"。这些绿卡例子可以直接变成聚合状态机的验收测试,把模糊规则钉成可执行的判据。
| 工具 | 最擅长 | 产出 | 在云市的用法 |
|---|---|---|---|
| 事件风暴 Event Storming | 广度探索、找边界与热点、推聚合 | 事件墙 + 命令/聚合/策略 | 从全局扫到设计级,推出 Order / StockItem |
| 领域叙事 Domain Storytelling | 走通一个具体案例、采集统一语言 | 带顺序的故事图 | 走完"张三的订单 #1024",逼出缺货分支 |
| 实例化映射 Example Mapping | 把一条规则收敛成可验收例子 | 规则 + 具体例子 + 待解问题 | 把"超时取消"热点拆成可测的绿卡例子 |
顺序常是:事件风暴找出流程和热点 → 领域叙事走通有歧义的具体案例、定准语言 → 实例化映射钉死每条规则的验收例子。三者都不替代彼此,挑哪件、挑几件,取决于当下卡在"看不清流程"、"语言不统一"还是"规则没收敛"。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 为什么事件风暴从"过去式的领域事件"起步,比从名词/ER 图起步更快暴露关键节点?用云市"价格冻结"举一个具体差别。
- 设计级六种贴纸(橙/蓝/黄/紫/绿/黄底小人)各代表哪种构件?把它们按因果链串成一句话。
- 事件风暴如何从一面墙导出聚合边界?说清"命令→聚合"那一步的判据,并指出它和"看表关系定聚合"的区别。
- 事件风暴、领域叙事、实例化映射三者分工是什么?各举一个在云市里该用哪件的场景。
答案(先做完再展开)
- 过去式事件强迫叙述"已发生的状态转移",把时间和因果摊开,而名词/ER 图把它们藏在字段里。云市里:当有人贴"价格已变更"、有人贴"下单价格已锁定",房间立刻发现价格在下单那刻被冻结、目录改价不影响已下单订单——名词建模要到写代码才会撞上这条规则。
- 橙=领域事件、蓝=命令、黄=聚合、紫=策略、绿=读模型、黄底小人=参与者。一句话:参与者看读模型后发出命令,命令落到聚合上、聚合发出领域事件,策略监听事件再触发下一个命令。
- 对每个命令问"谁接收它、谁负责保证它产出的那个事件的不变量",能守护不变量的那个就是聚合(
下单→Order,预留库存→StockItem)。区别:判据是"不变量归属/协作里的职责",不是"哪些表有外键关联"——后者常推出巨型聚合。 - 事件风暴管广度探索、找边界与热点、推聚合;领域叙事走通一个具体案例并采集统一语言;实例化映射把一条规则收敛成可验收例子。云市:扫整条下单流程用事件风暴;走"张三订单 #1024"逼出缺货分支用领域叙事;把"超时取消"热点拆成可测例子用实例化映射。
给云市"售后退货"开一段事件风暴
买家收货后申请退货。请在纸上为这条流程跑一遍设计级事件风暴:先按过去式排出至少 4 个领域事件(如"退货已申请"),再为每个事件补出前置命令、接收命令的聚合、必要的策略与读模型,最后标出至少一个热点。提示:想清楚"退款"和"库存回补"分别落在哪个聚合、是否同一个事务。
提示(卡住再展开)
一条可能的事件序列:退货已申请 →(策略:审核通过后)退货已批准 → 商品已退回入库 → 退款已发起 → 退款已完成。聚合归属:退货单是新的聚合(或挂在 Order 的退货子流程上,需讨论),库存回补落 StockItem,退款落支付侧聚合。三个事件跨三个上下文(销售/库存/支付),所以多处是策略衔接、最终一致,不是一个大事务。热点示例:"退款先发起还是商品先入库?""部分退货时订单状态怎么算?"——这些正是该转交实例化映射去钉验收例子的地方。