事件风暴:把一次业务讨论变成可执行的规则
便利贴贴满一面墙,不等于团队理解了业务。事件风暴真正的产出是一条大家都认可的行为链、一张规则表和一串还没有答案的问题。其中的规则最终要变成会失败的测试。
一个活动报名平台上线第一周,客服后台看到同一个人在同一个场次里占了两个名额:用户连点了两次「报名」。开发回头看需求讨论的记录,墙上贴着「报名已确认」「候补已登记」「报名已取消」「候补已递补」,唯独没有人贴出「同一个人重复报名会怎样」。代码照着便利贴写,这条规则也就不存在。
本文用这个虚构案例走一遍事件风暴:从已经发生的事开始,反推命令、执行者和策略,把讨论中暴露的问题标成热点,最后把规则写成 Given/When/Then 验收测试。同一组测试先检查照着第一次讨论写出的初稿模型,再检查补上规则后的修订模型。
一、先说结论
- 从已经发生的事开始,而不是从数据表开始:领域事件用过去式命名,描述业务在意的事实。「报名已确认」是事件,「registration 表插入一行」不是。
- 命令、执行者和策略从事件反推:每个事件问三个问题:谁触发的、他做决定时要看到什么、有没有「每当……就……」的自动反应。
- 热点比便利贴更值钱:争论、歧义和「这种情况怎么办」要当场标出来,定成规则,或者记为待验证的假设。
- 讨论的产物要进测试:每条规则至少对应一个验收测试。实测中,同一组 12 个用例检查初稿模型失败 3 个,全部是漏掉的重复报名规则。
- 事件风暴不替代需求优先级、界面设计和详细建模:它擅长让跨角色的人对齐行为,边界和模型还要在后续的建模与代码中反复修正。
二、从已经发生的事开始
事件风暴(EventStorming)的第一步是让所有人往墙上贴已经发生的业务事实,用过去式,先不管顺序,再按时间排开。活动报名平台的一次讨论整理出这样一条时间线:
| 顺序 | 领域事件 | 触发它的命令 | 执行者 | 做决定时需要看到的信息 |
|---|---|---|---|---|
| 1 | 场次已发布 | 发布场次 | 活动运营 | 场地容量、开场时间 |
| 2 | 报名已确认 | 报名 | 参会人 | 已确认人数、自己是否已报过 |
| 3 | 候补已登记 | 报名 | 参会人 | 已确认人数、候补队列 |
| 4 | 报名已取消 | 取消报名 | 参会人 | 离开场还有多久 |
| 5 | 候补已离开 | 取消报名 | 候补者 | 自己是否在队列中 |
| 6 | 候补已递补 | —(策略:有人取消就递补) | 系统 | 候补队列的先后 |
| 7 | 报名已截止 | 截止报名 | 活动运营 | — |
| 8 | 应收已生成 | 生成应收(策略:拿到收费名额就生成) | 系统(计费) | 场次价格 |
| 9 | 通知已发送 | 发送通知(策略:确认、递补后通知) | 系统(通知) | 联系方式、模板 |
从事件开始有两个好处。一是业务人员说得出来:他们未必能描述「报名模块」,但一定知道「报了名之后会发生什么」。二是事件天然带着时间顺序,缺了哪一步、哪两步之间有等待,排开以后一眼可见。
几个容易贴错的地方:
- 数据变化不是事件:「更新报名状态为 2」描述的是存储,不是业务事实。要追问:状态变成 2 意味着业务上发生了什么?
- 查询不是事件:「查看报名列表」没有改变任何事实,它对应的是某个角色做决定时需要的信息,写在命令旁边。
- 领域事件不等于消息:「报名已确认」是一个业务事实,它会不会发到 Kafka、用什么格式,是后面的实现决策。见 上下文集成。
三、从事件反推命令、执行者与策略
事件排好之后,对每一个事件问三个问题。
谁让它发生的? 找到命令和执行者。「报名已确认」和「候补已登记」都由参会人的「报名」命令触发,结果取决于当时的名额。一个命令产生不同的事件,说明这里有一条规则在做选择。
他做决定时要看到什么? 参会人点「报名」之前,系统需要知道名额是否已满、他是否已经报过。这些信息后来会变成聚合要守护的状态,或者读模型要提供的数据。
有没有自动反应? 「候补已递补」没有人去点按钮,它是一条策略:每当有人取消,就让候补第一位递补。策略把一个事件接到下一个命令上,是业务规则最集中的地方,也是最容易在代码里被写成某个定时任务、从此没人记得的地方。
事件 1、7 属于活动管理,8 属于计费,9 属于通知,2—6 由报名负责。这是限界上下文的候选:同一面墙上,不同的人对同一个词的理解开始分叉时,往往就是边界所在。边界怎样确认和实现,见 从统一语言到限界上下文。
四、热点:讨论里最值钱的部分
讨论中出现争论、不确定或「这种情况怎么办」时,贴一张热点(通常用粉色)。这次讨论留下了五个:
| 编号 | 讨论中暴露的问题 | 结论 |
|---|---|---|
| H1 | 用户连点两次「报名」,客服看到同一个人占了两个名额 | 定为规则 R3。第一次讨论没有人贴出它 |
| H2 | 开场前多久还能取消? | 运营确认 24 小时,定为 R4 |
| H3 | 候补递补后要不要等候补者确认? | 未决:运营倾向直接递补,客服担心无人到场。本期按直接递补实现,记为待验证假设 |
| H4 | 营销看板和报名后台的「报名人数」对不上 | 不是报名上下文的问题:营销统计的是线索 |
| H5 | 取消后退款怎么算 | 属于计费上下文,本期不建模 |
热点的处理方式只有三种:当场定成规则、明确划出范围、记为待验证的假设并写清复盘时间。最糟糕的是第四种:讨论时说「这个以后再说」,然后没有任何记录。H1 就是这样漏掉的,它不是没人想到,而是想到的人没在场。
五、从便利贴到验收测试
讨论结束后,墙上的内容整理成两张表。
规则表:每条规则有编号、来源和它产生的事件或拒绝原因。
| 编号 | 规则 | 来源 | 产生的事件或拒绝原因 |
|---|---|---|---|
| R1 | 名额未满时报名直接确认 | 时间线 2 | RegistrationConfirmed |
| R2 | 名额已满时进入候补,按报名先后得到顺位 | 时间线 3 | CandidateWaitlisted |
| R3 | 同一参会人在同一场次只能占一个位置(已确认或候补) | 热点 H1 | 拒绝:DUPLICATE |
| R4 | 开场前 24 小时内不能取消 | 热点 H2 | 拒绝:TOO_LATE_TO_CANCEL |
| R5 | 已确认者取消后,候补第一位直接递补 | 时间线 4、6 | RegistrationCancelled、CandidatePromoted(待验证:H3) |
| R6 | 候补者取消只离开队列,不触发递补 | 时间线 5 | WaitlistLeft |
| R7 | 报名截止后不接受新报名 | 时间线 7 | 拒绝:SESSION_CLOSED |
| R8 | 收费场次有人拿到名额(确认或递补)时生成应收 | 时间线 8 | 策略输出 CreateCharge |
词汇表:每个术语写明含义、它不是什么,以及代码里叫什么。「不是什么」这一列最有用:「参会人不是会员中心的会员」「候补不是报名失败」,这些正是评审里反复争论的地方。
然后每条规则写成 Given/When/Then。事件风暴的产物是事件,测试也用事件来断言,而不是去查数据库里的状态字段:
@Test
@DisplayName("R5 已确认者取消后,候补第一位递补")
void r5() {
// Given:容量 1,Alice 已确认,Bob、Carol 依次候补
RegistrationModel session = given(1, ALICE, BOB, CAROL);
// When:Alice 取消 / Then:产生取消与递补两个事件
assertEquals(List.of(new RegistrationCancelled(S, ALICE), new CandidatePromoted(S, BOB)),
session.cancel(ALICE, A_WEEK_BEFORE));
// Carol 仍排第一,新来的 Dave 排第二
assertEquals(List.of(new CandidateWaitlisted(S, DAVE, 2)), session.register(DAVE, A_WEEK_BEFORE));
}given(...) 用命令把场次推到某个状态,而不是直接给字段赋值,这样测试只依赖行为,模型内部怎么存都可以改。
5.1 同一组测试检查初稿和修订模型
照着第一次讨论的便利贴,可以写出一个初稿模型 DraftSession:有容量、有候补、有取消与递补,但没有 R3。修订后的 Session 补上了它。两者实现同一个接口,12 个验收用例通过系统属性选择检查哪一个:
初稿模型(DraftSession):tests=12 failures=3
FAIL R3 候补中的参会人再次报名被拒绝,不会排两个位置
FAIL R3 重复点报名不会让一个人占两个名额
FAIL R3 已确认的参会人再次报名被拒绝
修订模型(Session):tests=12 failures=0失败的 3 个用例全部属于 R3,其余 9 个通过。第二个失败用例描述的正是开篇的故障:容量为 2 时 Alice 连点两次,初稿接受了两次;她取消一次之后,仍然占着一个名额,下一位报名的人被放进了候补。
5.2 让规则、术语和代码保持对应
表格写完之后最常见的结局是没人再打开。一个简单的约束是把对应关系交给脚本检查:
规则 8 条,用例 12 个,术语 8 个,问题 0 个
R3 同一参会人在同一场次只能占一个位置 3 个用例
R5 已确认者取消后,候补第一位直接递补 2 个用例
术语 递补 CandidatePromoted 找到脚本只检查两件事:规则表里的每个编号至少有一个用例名以它开头,词汇表里的每个代码名都能在源码中找到对应的类型或方法。它不能证明用例写得充分,但能保证新加一条规则时,不写测试就过不了构建。
六、怎样组织一次讨论
- 参与者:必须有做决定的业务方(这里是运营)、处理异常的一线角色(客服)、下游的相关方(财务),以及写代码和写测试的人。只有开发参加的事件风暴,产出的是开发对业务的猜测。
- 范围:一次只讨论一条能在两周左右交付的业务链。报名到递补是一条链,退款规则另开一次。
- 时长:一个半小时左右。先各自贴事件,再一起排时间线,最后留足时间处理热点。
- 会后:当天整理出时间线、规则表、词汇表和热点清单,写明谁负责哪一个未决问题。规则表进入代码仓库,和测试放在一起维护。
七、常见误区
- 「事件就是数据库的增删改」:事件描述业务事实;一个事件可能对应多张表的修改,也可能不改任何表(例如「报名已截止」只是一个时间点)。
- 「贴满墙就说明理解了」:没有热点的讨论通常说明问题没问够。开篇的故障就来自一张没被贴出来的便利贴。
- 「事件风暴的结果就是限界上下文」:它给出的是候选边界,要在建模和代码里验证,见 DDD 的边界不止一个。
- 「领域事件都要发到消息队列」:领域事件可以只在进程内使用,是否跨进程发布、用什么契约,是另一个决定。
- 「讨论完就可以开始写代码了」:还缺一步,把规则写成会失败的测试。测试写不出来的规则,说明它还不够清楚。
小结
事件风暴的价值不在便利贴本身,而在它逼着不同角色把「接下来会发生什么」说清楚。从过去式的事件出发,反推命令、执行者和策略,把争论标成热点,再把定下来的规则写成用事件断言的验收测试。下一步是把这些事件和规则整理成模型,见 领域建模;规则怎样在并发下被守住,见 聚合边界。
配套实验
- codesphere-labs/ddd/registration-acceptance-tests:事件时间线、热点、规则表与词汇表,12 个验收用例分别检查初稿与修订模型,规则—用例—术语的可追踪检查(验证记录)
参考资料
- Alberto Brandolini,《Introducing EventStorming》(Leanpub)
- EventStorming 官方站点
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 2 章(统一语言)
- Dan North,Introducing BDD:Given/When/Then 的来源