事件识别的失败通常不是「漏了」
团队第一次做事件建模,最常见的担心是「是不是漏了事件」。实际做下来,漏掉的情况反而少,多出来、错掉的情况多。三种典型表现:
- 每张表一个事件。
loan表变了发LoanRowUpdated,book_copy表变了发BookCopyRowUpdated,一次借阅产出三条「事件」,消费者被迫理解数据库结构。 - 用祈使句当事件名。
UpdateBookStatus、CreateLoan、SendOverdueEmail——名字是命令,不是事实,消费者无法判断「这件事到底发生没发生」。 - 把技术动作当业务事实。
CacheInvalidated、ScanJobFinished、MessageConsumed混进领域事件流,业务方读不懂,平台方也不敢删。
这三种错误有一个共同的根:把「系统做了什么」当成了「业务发生了什么」。领域事件回答的是后一个问题。
三个源头:动作、状态变化、表写入
识别事件时,先把三样东西分开列,不要一上来就写事件名:
| 概念 | 例子 | 时态 | 特点 |
|---|---|---|---|
| 业务动作(命令) | 借书、还书、预约、取消预约 | 祈使 | 可能失败,失败就没有事实 |
| 状态变化 | Loan 从不存在变为 BORROWED | 过程 | 是事件的来源,但不是事件本身 |
| 表写入 | INSERT loan、UPDATE book_copy、UPDATE member | 实现 | 一次动作可能写 0 到 N 张表 |
| 领域事件 | LoanCreated | 过去时 | 已发生、不可撤销、可多播 |
关键不等式:表写入次数 ≠ 事件个数。一次借阅写三张表,只产生一个业务事实「这本书被借走了」;一次定时任务扫描一万条逾期记录,可能只写一张表,却产生一万个事实。下一节的实验把这条不等式量出来。
第一步:把动作和表写入摊开
libraryd 的六个核心动作,先按「动作 → 写了哪些表 → 业务上到底发生了什么」列成一张表,暂时不管事件名:
| 动作 | 表写入 | 业务事实 |
|---|---|---|
| 借阅 | loan.insert、book_copy.update、member.update | 某会员借走了某本书 |
| 归还 | loan.update、book_copy.update | 某笔借阅结束了 |
| 预约 | hold.insert、book.update | 某会员对某本书登记了预约 |
| 取消预约 | hold.update、book.update | 某条预约被撤销了 |
| 库存调整 | book.update | 某本书的可借数量被改了 |
| 逾期扫描 | loan.update | 某笔借阅超过应还日期 |
这张表只写事实,不写事件名,是刻意的。事件名一旦提前出现,讨论就会滑向「叫 LoanCreated 还是 LoanBorrowed」,而不是「这笔业务到底发生了什么」。命名是最后一步,不是第一步。
把动作建模成 record,字段只留业务参数,不带表名:
record Borrow(String bookId, String memberId, Instant at) {}
record Return(String loanId, Instant at) {}
record Reserve(String bookId, String memberId, Instant at) {}
record CancelHold(String holdId, String reason, Instant at) {}
record AdjustInventory(String bookId, int delta, String reason, Instant at) {}
命令可以失败,事件不能。这个差别体现在返回类型上:命令返回 Result 或抛异常,事件只在成功之后才被构造。
第二步:用状态机筛掉不成立的迁移
不是每个动作都值得产出事件。业务上不允许的迁移,即便代码能写表,也不该发事件——否则消费者会收到「一笔已归还的借阅又被归还了一次」这种事实。
把每个聚合允许的迁移显式写出来,和表结构无关:
static final Map<String, Set<String>> ALLOWED = Map.of(
"Loan", Set.of("NULL>BORROWED", "BORROWED>RETURNED", "BORROWED>OVERDUE"),
"Hold", Set.of("NULL>PLACED", "PLACED>CANCELLED", "PLACED>FULFILLED"),
"Inventory", Set.of("ANY>ADJUSTED"));
这段代码的价值不在实现,而在它把「什么算合法事实」变成了可评审的清单。RETURNED>RETURNED 不在集合里,重复归还就会被拒绝;PLACED>CANCELLED 在集合里,取消预约才是合法事实。
有了状态机,事件的产生条件就明确了:命令成功 + 迁移合法 + 产生了新的业务事实,三者同时成立才发事件。只满足前两条、没有新事实的操作(比如把 updated_at 刷新一下)不发。
第三步:命名规范
命名不是风格问题,是契约问题。消费者靠名字判断语义,平台靠名字做检索,运维靠名字定位问题。四条硬规则:
- 过去时或完成态。
Created、Returned、Placed、Cancelled、Fulfilled、Adjusted、Overdue。不用Create、Update、Process。 - 聚合名 + 事实。
LoanCreated而不是BookStatusChanged。名字里的主语必须是领域对象,不是表、不是字段。 - 不含接收者、不含发送动作。
SendOverdueEmail描述的是「通知服务要做什么」,不是「业务发生了什么」。同一事实可能有五个消费者,事件名不能偏向其中任何一个。 - 不含表/持久化词。
Row、Table、Entity、DTO、DAO一律不出现。事件是领域语言,不是存储语言。
主题名与事件名一一对应,全小写、三段:
library.<aggregate>.<past-tense-verb>
LoanCreated 对应 library.loan.created,InventoryAdjusted 对应 library.inventory.adjusted。这样从主题能反推聚合,从聚合能查到全部事件。
命名对照:六个反例错在哪
| 反例 | 违反的规则 | 应该写成 |
|---|---|---|
UpdateBookStatus | 祈使句 + 泛化动词,没有业务事实 | LoanReturned / LoanCreated |
BookRowUpdated | 含表行概念,暴露存储结构 | LoanCreated |
SendOverdueEmail | 含接收者与发送动作 | LoanOverdue |
LoanCreate | 祈使式,不是完成态 | LoanCreated |
InventoryAdjustment | 名词化,读者不知道调了没有 | InventoryAdjusted |
MemberNotified | 描述通知动作,不是领域事实 | LoanOverdue(通知是它的消费者行为) |
最后一行值得展开。MemberNotified 看起来像事件,但它是「通知系统完成了一次发送」,属于技术事实。领域事实是「这笔借阅逾期了」,通知只是它的一个消费者。把技术事实混进领域流,会导致两个后果:通知服务换实现时事件语义变了;业务方想回放「哪些书逾期过」时,得先理解通知系统的内部状态。
一个用户动作可能对应多个事实
「借阅」看起来是一个动作,但如果这本书正好有人预约,业务上同时发生了两件事:预约被履约、借阅被创建。它们属于两个聚合(Hold、Loan),是两个事件。
| 用户动作 | 事实一 | 事实二 | 归属 |
|---|---|---|---|
| 借走一本被预约的书 | 借阅创建 | 预约履约 | Loan / Hold |
| 取消一条已到馆的预约 | 预约取消 | 可借数量恢复 | Hold / Book |
| 归还原书并自动转借给下一位 | 借阅结束 | 新借阅创建 | Loan / Loan |
这张表的作用是纠正一个直觉:事件数量由事实数量决定,不由用户点击次数决定。 但也不能反过来推成「一个动作要在一个事务里发多条事件」——那会诱导实现去同时改多个聚合的表,正是 2.2 要拦的边界违规。
正确的分工是:每个聚合在自己的事务里记录自己的事实。借阅聚合发 LoanCreated;预约聚合作为消费者收到后,在自己的事务里执行履约并发出 HoldFulfilled。两个事件之间是因果,不是同一个事务的原子写。代价是中间存在一个「书已借出、预约还没履约」的短暂窗口,这个窗口怎么收敛,属于第 4 章的一致性话题。
识别阶段只需要产出一张「动作 → 事实」的多对多表,不需要决定实现顺序。把这一步和事务边界分开,后面的建模才不会互相打架。
第四步:跑一遍确定性 lint
规则写成文字会被违反,写成代码才能拦住。lint 需要两个不起眼但关键的辅助函数:一个把驼峰名拆成词,用来找表名与接收者;一个从尾部匹配完成态后缀,用来分离聚合名。
static List<String> splitCamel(String name) {
List<String> out = new ArrayList<>();
for (String w : name.split("(?=[A-Z])")) if (!w.isEmpty()) out.add(w);
return out;
}
static String suffixOf(String name) {
for (String p : ALL_SUFFIX) if (name.endsWith(p)) return p;
return "";
}
splitCamel 不能用 toLowerCase().contains("row") 代替:Borrowed 的小写里也含 row,会把正确名误杀。按大写字母边界切词,再逐词比对,才有稳定的判定。
下面这段 lint 只做静态判断,不连 Kafka,输入是候选名与主题,输出是逐条判定:
static List<String> lint(String name, String topic) {
List<String> r = new ArrayList<>();
if (!name.matches("[A-Z][A-Za-z0-9]*")) r.add("不是 PascalCase");
String suffix = suffixOf(name);
if (suffix.isEmpty()) {
r.add("没有完成态后缀");
} else {
if (GENERIC.contains(suffix)) r.add("后缀 " + suffix + " 是泛化 CRUD 动词");
String agg = name.substring(0, name.length() - suffix.length());
String want = "library." + agg.toLowerCase() + "." + suffix.toLowerCase();
if (!topic.equals(want)) r.add("主题应为 " + want + ",实际 " + topic);
}
if (name.matches(".*" + CRUD + ".*")) r.add("含祈使/CRUD 动词");
for (String w : splitCamel(name)) {
String lw = w.toLowerCase();
if (TABLE_TOKENS.contains(lw)) r.add("含表/持久化词 " + w);
if (RECEIVER_TOKENS.contains(lw)) r.add("含接收者或发送动作 " + w);
}
return r;
}
GENERIC 里放的是 Updated、Changed、Modified、Saved、Deleted 这类语法上是过去式、语义上没信息的后缀。它们是最难拦的一类:看起来合规,实际只是把表变更换了个说法。
用 12 个候选名跑一遍(6 个正确名 + 6 个反例):
本机实测(Temurin 25.0.5+8 / 纯 JDK,无 Kafka / 2026-10-29):
javac -d out EventDiscovery.java && java -cp out EventDiscovery,输出如下。这是一次运行的结果,不涉及性能,可重复。
LoanCreated library.loan.created PASS
LoanReturned library.loan.returned PASS
HoldPlaced library.hold.placed PASS
HoldCancelled library.hold.cancelled PASS
InventoryAdjusted library.inventory.adjusted PASS
LoanOverdue library.loan.overdue PASS
UpdateBookStatus library.book.updated FAIL 没有完成态后缀; 含祈使/CRUD 动词
BookRowUpdated library.book.updated FAIL 后缀 Updated 是泛化 CRUD 动词; 主题应为 library.bookrow.updated,实际 library.book.updated; 含表/持久化词 Row
SendOverdueEmail library.loan.overdue FAIL 没有完成态后缀; 含接收者或发送动作 Send; 含接收者或发送动作 Email
LoanCreate library.loan.create FAIL 没有完成态后缀; 含祈使/CRUD 动词
InventoryAdjustment library.inventory.adjustment FAIL 没有完成态后缀
MemberNotified library.member.notified FAIL 没有完成态后缀
lint 合计: PASS 6, FAIL 6
BookRowUpdated 那条特别典型:它同时踩了泛化动词、表行概念、主题聚合名错位三个坑,lint 一次报全。人工评审时这类名字往往因为「看起来挺清楚」而被放过。
状态机那一半的实测输出
同一个程序还跑了动作 → 迁移的判定。七个候选动作里有一个是反例「重复归还」:
动作 写表 事件 状态迁移 判定
borrow 3 LoanCreated NULL>BORROWED ACCEPT
return 2 LoanReturned BORROWED>RETURNED ACCEPT
reserve 2 HoldPlaced NULL>PLACED ACCEPT
cancel hold 2 HoldCancelled PLACED>CANCELLED ACCEPT
adjust inventory 1 InventoryAdjusted ANY>ADJUSTED ACCEPT
overdue scan 1 LoanOverdue BORROWED>OVERDUE ACCEPT
return again 1 LoanReturned RETURNED>RETURNED REJECT
合计: 候选动作 7, 通过 6, 拒绝 1, 通过动作的表写入 11 次
这张表把开头的不等式量出来了:六个合法业务动作一共写了 11 次表,只产出 6 个事件。 如果按表发事件,11 次写入至少是 11 条,实际因为同表多次更新还会更多;按聚合发事件,是 6 条。差的不是数量,是语义。
六个事件的最终清单
把三步的结果合起来,得到 libraryd 的事件清单:
| # | 业务动作 | 事件 | 主题 | key | 关键载荷 |
|---|---|---|---|---|---|
| 1 | 借阅 | LoanCreated | library.loan.created | bookId | loanId、memberId、dueAt |
| 2 | 归还 | LoanReturned | library.loan.returned | bookId | loanId、returnedAt |
| 3 | 预约 | HoldPlaced | library.hold.placed | bookId | holdId、memberId |
| 4 | 取消预约 | HoldCancelled | library.hold.cancelled | bookId | holdId、reason |
| 5 | 库存调整 | InventoryAdjusted | library.inventory.adjusted | bookId | deltaCopies、reason |
| 6 | 逾期 | LoanOverdue | library.loan.overdue | loanId | memberId、daysOverdue |
两点说明。
第一,key 的选择属于下一节(边界与顺序)的内容,这里只标结果:前五个按 bookId,因为同一本书的库存与预约必须同序;逾期按 loanId,因为它只关心单笔借阅。这个差异来自聚合边界,不是随手定的。
第二,预约聚合其实有两个终态:HoldCancelled 与 HoldFulfilled。本节用「取消预约」演示识别过程,library.hold.fulfilled(预约履约)用完全相同的规则得到,2.3 的目录里会一并登记。把它单列出来,是因为「取消」和「履约」在业务上是两个不同的事实,不能合成一个 HoldClosed。
定时扫描产生的事件算不算领域事件
逾期提醒最容易起争议:它由定时任务触发,不是用户动作,算领域事件吗?
判据不是「谁触发」,而是「事实是否属于领域」。LoanOverdue 描述的是「这笔借阅已经超过应还日期」,这是一个业务事实,无论由用户点击还是定时扫描发现。相反,ScanJobFinished 描述的是调度系统的运行状态,属于技术事实,不该进业务主题。
同一条判据可以推广成一句话:事件的触发方式不影响它的领域属性,事件的语义决定它属于哪一层。 定时任务可以生产领域事件,用户点击也可能生产技术事件(比如 CacheWarmed)。
识别清单
给出一份可以直接拿去开会的清单,八条,按顺序做:
- 列出业务动作,用动词短语,不写表名。
- 对每个动作,写出它改了哪些表、每张表改了什么。
- 写出动作成功后新增的业务事实,一句话,主语是领域对象。
- 画该聚合的状态机,标出合法迁移;不合法的动作从事件候选里剔除。
- 给事实起过去时的名字,主语 + 完成态后缀。
- 检查名字里有没有表名、字段名、接收者、发送动作。
- 生成主题名
library.<aggregate>.<verb>,确认能从主题反推聚合。 - 跑 lint,把 6 个正确名和 6 个反例一起跑——只跑正确名验证不了规则。
第 8 条常被省略。lint 的价值不在于让正确的名字通过,而在于让错误的名字以可复现的方式失败;只测正例的规则集,和没有规则差不多。
这个方法的边界
需要说清楚它不解决什么:
- 它不判断事件该不该跨服务发布。 识别出
LoanCreated不等于要把它发到公共主题,发布范围是 2.2 与第 11 章的问题。 - 它不处理事件之间的因果。
LoanCreated与随后的InventoryAdjusted谁先谁后,属于第 5 章。 - 它不保证命名一次到位。 业务语言会变,
HoldCancelled可能在某天需要拆成「用户取消」和「超时取消」两个事实,那时是一次契约变更,不是改名。
小结
- 领域事件是「已经发生的业务事实」,不是表变更、不是命令、不是技术动作。
- 识别顺序是:动作 → 表写入 → 业务事实 → 状态机校验 → 命名 → lint,命名放最后。
- 六个合法动作写了 11 次表,只产出 6 个事件;按表发事件会把存储结构泄漏给所有消费者。
- 命名四条硬规则:过去时、聚合名 + 事实、不含接收者、不含表/持久化词;主题为
library.<aggregate>.<verb>。 - 12 个候选名的 lint 实测:6 个正确名全部通过,6 个反例全部被拦,其中
BookRowUpdated一次触发三条规则。 - 定时扫描可以生产领域事件,判据是语义而不是触发方式;
ScanJobFinished这类技术事实不进业务主题。
阅读导航:上一节 1.3 事件驱动成熟度模型 | 下一节 2.2 事件边界与聚合
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。