《Java 消息中间件·事件驱动架构》2.1 领域事件识别

事件不是表变更的别名。本节用 libraryd 的六个业务动作演示识别过程:先把动作、状态迁移与表写入分开列,再从迁移里提取过去时事实,最后用确定性 lint 挡掉 UpdateBookStatus、BookRowUpdated 这类伪事件,给出六个事件的命名对照与 12 个候选名的实测判定。

事件识别的失败通常不是「漏了」

团队第一次做事件建模,最常见的担心是「是不是漏了事件」。实际做下来,漏掉的情况反而少,多出来、错掉的情况多。三种典型表现:

  1. 每张表一个事件。loan 表变了发 LoanRowUpdated,book_copy 表变了发 BookCopyRowUpdated,一次借阅产出三条「事件」,消费者被迫理解数据库结构。
  2. 用祈使句当事件名。UpdateBookStatus、CreateLoan、SendOverdueEmail——名字是命令,不是事实,消费者无法判断「这件事到底发生没发生」。
  3. 把技术动作当业务事实。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 刷新一下)不发。

第三步:命名规范

命名不是风格问题,是契约问题。消费者靠名字判断语义,平台靠名字做检索,运维靠名字定位问题。四条硬规则:

  1. 过去时或完成态。 Created、Returned、Placed、Cancelled、Fulfilled、Adjusted、Overdue。不用 Create、Update、Process。
  2. 聚合名 + 事实。 LoanCreated 而不是 BookStatusChanged。名字里的主语必须是领域对象,不是表、不是字段。
  3. 不含接收者、不含发送动作。 SendOverdueEmail 描述的是「通知服务要做什么」,不是「业务发生了什么」。同一事实可能有五个消费者,事件名不能偏向其中任何一个。
  4. 不含表/持久化词。 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借阅LoanCreatedlibrary.loan.createdbookIdloanId、memberId、dueAt
2归还LoanReturnedlibrary.loan.returnedbookIdloanId、returnedAt
3预约HoldPlacedlibrary.hold.placedbookIdholdId、memberId
4取消预约HoldCancelledlibrary.hold.cancelledbookIdholdId、reason
5库存调整InventoryAdjustedlibrary.inventory.adjustedbookIddeltaCopies、reason
6逾期LoanOverduelibrary.loan.overdueloanIdmemberId、daysOverdue

两点说明。

第一,key 的选择属于下一节(边界与顺序)的内容,这里只标结果:前五个按 bookId,因为同一本书的库存与预约必须同序;逾期按 loanId,因为它只关心单笔借阅。这个差异来自聚合边界,不是随手定的。

第二,预约聚合其实有两个终态:HoldCancelled 与 HoldFulfilled。本节用「取消预约」演示识别过程,library.hold.fulfilled(预约履约)用完全相同的规则得到,2.3 的目录里会一并登记。把它单列出来,是因为「取消」和「履约」在业务上是两个不同的事实,不能合成一个 HoldClosed。

定时扫描产生的事件算不算领域事件

逾期提醒最容易起争议:它由定时任务触发,不是用户动作,算领域事件吗?

判据不是「谁触发」,而是「事实是否属于领域」。LoanOverdue 描述的是「这笔借阅已经超过应还日期」,这是一个业务事实,无论由用户点击还是定时扫描发现。相反,ScanJobFinished 描述的是调度系统的运行状态,属于技术事实,不该进业务主题。

同一条判据可以推广成一句话:事件的触发方式不影响它的领域属性,事件的语义决定它属于哪一层。 定时任务可以生产领域事件,用户点击也可能生产技术事件(比如 CacheWarmed)。

识别清单

给出一份可以直接拿去开会的清单,八条,按顺序做:

  1. 列出业务动作,用动词短语,不写表名。
  2. 对每个动作,写出它改了哪些表、每张表改了什么。
  3. 写出动作成功后新增的业务事实,一句话,主语是领域对象。
  4. 画该聚合的状态机,标出合法迁移;不合法的动作从事件候选里剔除。
  5. 给事实起过去时的名字,主语 + 完成态后缀。
  6. 检查名字里有没有表名、字段名、接收者、发送动作。
  7. 生成主题名 library.<aggregate>.<verb>,确认能从主题反推聚合。
  8. 跑 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 事件边界与聚合

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收