Skip to content

事件风暴:把一次业务讨论变成可执行的规则 ​

便利贴贴满一面墙,不等于团队理解了业务。事件风暴真正的产出是一条大家都认可的行为链、一张规则表和一串还没有答案的问题。其中的规则最终要变成会失败的测试。

一个活动报名平台上线第一周,客服后台看到同一个人在同一个场次里占了两个名额:用户连点了两次「报名」。开发回头看需求讨论的记录,墙上贴着「报名已确认」「候补已登记」「报名已取消」「候补已递补」,唯独没有人贴出「同一个人重复报名会怎样」。代码照着便利贴写,这条规则也就不存在。

本文用这个虚构案例走一遍事件风暴:从已经发生的事开始,反推命令、执行者和策略,把讨论中暴露的问题标成热点,最后把规则写成 Given/When/Then 验收测试。同一组测试先检查照着第一次讨论写出的初稿模型,再检查补上规则后的修订模型。

一、先说结论 ​

  • 从已经发生的事开始,而不是从数据表开始:领域事件用过去式命名,描述业务在意的事实。「报名已确认」是事件,「registration 表插入一行」不是。
  • 命令、执行者和策略从事件反推:每个事件问三个问题:谁触发的、他做决定时要看到什么、有没有「每当……就……」的自动反应。
  • 热点比便利贴更值钱:争论、歧义和「这种情况怎么办」要当场标出来,定成规则,或者记为待验证的假设。
  • 讨论的产物要进测试:每条规则至少对应一个验收测试。实测中,同一组 12 个用例检查初稿模型失败 3 个,全部是漏掉的重复报名规则。
  • 事件风暴不替代需求优先级、界面设计和详细建模:它擅长让跨角色的人对齐行为,边界和模型还要在后续的建模与代码中反复修正。

二、从已经发生的事开始 ​

事件风暴(EventStorming)的第一步是让所有人往墙上贴已经发生的业务事实,用过去式,先不管顺序,再按时间排开。活动报名平台的一次讨论整理出这样一条时间线:

顺序领域事件触发它的命令执行者做决定时需要看到的信息
1场次已发布发布场次活动运营场地容量、开场时间
2报名已确认报名参会人已确认人数、自己是否已报过
3候补已登记报名参会人已确认人数、候补队列
4报名已取消取消报名参会人离开场还有多久
5候补已离开取消报名候补者自己是否在队列中
6候补已递补—(策略:有人取消就递补)系统候补队列的先后
7报名已截止截止报名活动运营—
8应收已生成生成应收(策略:拿到收费名额就生成)系统(计费)场次价格
9通知已发送发送通知(策略:确认、递补后通知)系统(通知)联系方式、模板

从事件开始有两个好处。一是业务人员说得出来:他们未必能描述「报名模块」,但一定知道「报了名之后会发生什么」。二是事件天然带着时间顺序,缺了哪一步、哪两步之间有等待,排开以后一眼可见。

几个容易贴错的地方:

  • 数据变化不是事件:「更新报名状态为 2」描述的是存储,不是业务事实。要追问:状态变成 2 意味着业务上发生了什么?
  • 查询不是事件:「查看报名列表」没有改变任何事实,它对应的是某个角色做决定时需要的信息,写在命令旁边。
  • 领域事件不等于消息:「报名已确认」是一个业务事实,它会不会发到 Kafka、用什么格式,是后面的实现决策。见 上下文集成。

三、从事件反推命令、执行者与策略 ​

事件排好之后,对每一个事件问三个问题。

谁让它发生的? 找到命令和执行者。「报名已确认」和「候补已登记」都由参会人的「报名」命令触发,结果取决于当时的名额。一个命令产生不同的事件,说明这里有一条规则在做选择。

他做决定时要看到什么? 参会人点「报名」之前,系统需要知道名额是否已满、他是否已经报过。这些信息后来会变成聚合要守护的状态,或者读模型要提供的数据。

有没有自动反应? 「候补已递补」没有人去点按钮,它是一条策略:每当有人取消,就让候补第一位递补。策略把一个事件接到下一个命令上,是业务规则最集中的地方,也是最容易在代码里被写成某个定时任务、从此没人记得的地方。

执行者与读取的信息命令领域事件(过去式)策略参会人看到:离开场还有多久取消报名报名已取消RegistrationCancelled每当有人取消就递补第一位递补候补系统执行候补已递补CandidatePromoted命令被规则拒绝(重复报名、超过取消时限)不是事件:什么事实都没有发生
图 1 · 从右往左读:先有事件「报名已取消」,再问谁触发了它(参会人发出的取消命令)、他看到了什么;策略「每当有人取消就递补」把这个事件接到下一个命令上,产生「候补已递补」

事件 1、7 属于活动管理,8 属于计费,9 属于通知,2—6 由报名负责。这是限界上下文的候选:同一面墙上,不同的人对同一个词的理解开始分叉时,往往就是边界所在。边界怎样确认和实现,见 从统一语言到限界上下文。

四、热点:讨论里最值钱的部分 ​

讨论中出现争论、不确定或「这种情况怎么办」时,贴一张热点(通常用粉色)。这次讨论留下了五个:

编号讨论中暴露的问题结论
H1用户连点两次「报名」,客服看到同一个人占了两个名额定为规则 R3。第一次讨论没有人贴出它
H2开场前多久还能取消?运营确认 24 小时,定为 R4
H3候补递补后要不要等候补者确认?未决:运营倾向直接递补,客服担心无人到场。本期按直接递补实现,记为待验证假设
H4营销看板和报名后台的「报名人数」对不上不是报名上下文的问题:营销统计的是线索
H5取消后退款怎么算属于计费上下文,本期不建模

热点的处理方式只有三种:当场定成规则、明确划出范围、记为待验证的假设并写清复盘时间。最糟糕的是第四种:讨论时说「这个以后再说」,然后没有任何记录。H1 就是这样漏掉的,它不是没人想到,而是想到的人没在场。

五、从便利贴到验收测试 ​

讨论结束后,墙上的内容整理成两张表。

规则表:每条规则有编号、来源和它产生的事件或拒绝原因。

编号规则来源产生的事件或拒绝原因
R1名额未满时报名直接确认时间线 2RegistrationConfirmed
R2名额已满时进入候补,按报名先后得到顺位时间线 3CandidateWaitlisted
R3同一参会人在同一场次只能占一个位置(已确认或候补)热点 H1拒绝:DUPLICATE
R4开场前 24 小时内不能取消热点 H2拒绝:TOO_LATE_TO_CANCEL
R5已确认者取消后,候补第一位直接递补时间线 4、6RegistrationCancelled、CandidatePromoted(待验证:H3)
R6候补者取消只离开队列,不触发递补时间线 5WaitlistLeft
R7报名截止后不接受新报名时间线 7拒绝:SESSION_CLOSED
R8收费场次有人拿到名额(确认或递补)时生成应收时间线 8策略输出 CreateCharge

词汇表:每个术语写明含义、它不是什么,以及代码里叫什么。「不是什么」这一列最有用:「参会人不是会员中心的会员」「候补不是报名失败」,这些正是评审里反复争论的地方。

然后每条规则写成 Given/When/Then。事件风暴的产物是事件,测试也用事件来断言,而不是去查数据库里的状态字段:

java
@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 个验收用例通过系统属性选择检查哪一个:

text
初稿模型(DraftSession):tests=12 failures=3
  FAIL  R3 候补中的参会人再次报名被拒绝,不会排两个位置
  FAIL  R3 重复点报名不会让一个人占两个名额
  FAIL  R3 已确认的参会人再次报名被拒绝
修订模型(Session):tests=12 failures=0

失败的 3 个用例全部属于 R3,其余 9 个通过。第二个失败用例描述的正是开篇的故障:容量为 2 时 Alice 连点两次,初稿接受了两次;她取消一次之后,仍然占着一个名额,下一位报名的人被放进了候补。

墙上的产物规则表验收测试模型时间线 2—6热点 H1、H2H3 未决记为待验证假设R1 R2 R5 R6 R7R3 R4R5 标记待验证9 个用例R3 的 3 个用例初稿 DraftSession12 个失败 3 个修订 Session12 个全部通过脚本检查:规则 8 条都有用例、术语 8 个都能在代码中找到;新增规则不写测试就过不了构建
图 2 · 热点 H1 在第一次讨论中没有被贴出,初稿模型也就没有这条规则;补进规则表后,R3 的 3 个验收用例在初稿上失败、在修订模型上通过

5.2 让规则、术语和代码保持对应 ​

表格写完之后最常见的结局是没人再打开。一个简单的约束是把对应关系交给脚本检查:

text
规则 8 条,用例 12 个,术语 8 个,问题 0 个
R3  同一参会人在同一场次只能占一个位置  3 个用例
R5  已确认者取消后,候补第一位直接递补  2 个用例
术语  递补  CandidatePromoted  找到

脚本只检查两件事:规则表里的每个编号至少有一个用例名以它开头,词汇表里的每个代码名都能在源码中找到对应的类型或方法。它不能证明用例写得充分,但能保证新加一条规则时,不写测试就过不了构建。

六、怎样组织一次讨论 ​

  • 参与者:必须有做决定的业务方(这里是运营)、处理异常的一线角色(客服)、下游的相关方(财务),以及写代码和写测试的人。只有开发参加的事件风暴,产出的是开发对业务的猜测。
  • 范围:一次只讨论一条能在两周左右交付的业务链。报名到递补是一条链,退款规则另开一次。
  • 时长:一个半小时左右。先各自贴事件,再一起排时间线,最后留足时间处理热点。
  • 会后:当天整理出时间线、规则表、词汇表和热点清单,写明谁负责哪一个未决问题。规则表进入代码仓库,和测试放在一起维护。

七、常见误区 ​

  • 「事件就是数据库的增删改」:事件描述业务事实;一个事件可能对应多张表的修改,也可能不改任何表(例如「报名已截止」只是一个时间点)。
  • 「贴满墙就说明理解了」:没有热点的讨论通常说明问题没问够。开篇的故障就来自一张没被贴出来的便利贴。
  • 「事件风暴的结果就是限界上下文」:它给出的是候选边界,要在建模和代码里验证,见 DDD 的边界不止一个。
  • 「领域事件都要发到消息队列」:领域事件可以只在进程内使用,是否跨进程发布、用什么契约,是另一个决定。
  • 「讨论完就可以开始写代码了」:还缺一步,把规则写成会失败的测试。测试写不出来的规则,说明它还不够清楚。

小结 ​

事件风暴的价值不在便利贴本身,而在它逼着不同角色把「接下来会发生什么」说清楚。从过去式的事件出发,反推命令、执行者和策略,把争论标成热点,再把定下来的规则写成用事件断言的验收测试。下一步是把这些事件和规则整理成模型,见 领域建模;规则怎样在并发下被守住,见 聚合边界。


配套实验

参考资料

  • 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 的来源

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权