跨模組的訊息有三種責任形狀。命令(command)表達意圖:「去做這件事」,有唯一的處理者、可以拒絕、可以失敗。domain event 記錄事實:「這件事已經發生」,有任意多的訂閱者、沒有拒絕的位置。查詢(query)是對話:「告訴我這個」,一問一答、呼叫端要等到答案。三者的分界是責任結構、不是命名風格。本章的判準一句話:看訊息對消費者的要求——要求執行、給命令;只供反應、給事件;要等答案、給查詢通道。

三種訊息的責任結構

維度命令domain event查詢
語意去做這件事這件事已發生告訴我這個
命名形狀祈使(ImportBook過去式(BookImported疑問(getBookInfo()
消費者唯一處理者任意多訂閱者唯一回應者
失敗語意可拒絕、可失敗、可重試無——事實不可否認有超時、有錯誤回傳
發送端等不等視流程——常等處理結果不等、fire and forget必等——答案是流程的前提

「失敗語意」那一列是三者最深的差異。命令的處理者可以說不:庫存不足、狀態不允許、驗證失敗——失敗是命令語意的一部分、發送端要準備收到拒絕。事件沒有這個位置:BookImported 發出時匯入已經完成,訂閱者遲到、出錯、根本不存在,都改變不了這個事實——訂閱者的失敗是訂閱者自己的問題、不回流到發布端。查詢的失敗又是另一種:查不到、等太久、通道斷了,每一種都要有回應路徑、因為呼叫端停在那裡等。

責任結構決定演進自由度:事件的發布端不知道誰在聽、加訂閱者不改發布端;命令的發送端跟處理者是一對一契約、換處理者要重新對齊語意;查詢的兩端綁最緊、簽名一動兩邊都動。

命名是責任結構的第一道宣告

事件用過去式命名、是把責任結構烙在讀者第一眼接觸的地方:

1class BookImported extends DomainEvent { }   // 事實:已發生、不可否認
2class ImportBook extends DomainEvent { }     // 命令的形狀——「去匯入這本書」

ImportBook 這個名字的問題超出風格:動詞開頭是命令的形狀,訂閱者會用命令的心智模型寫處理邏輯——以為自己能影響流程、以為失敗會被重試。過去式的 Imported 宣告木已成舟,訂閱者知道自己只能對事實做反應。一個書籍管理 App 的命名規範修正記錄了這條分界的完整推導、以及過去式字尾自動檢查的脆弱性(不規則動詞全數漏接——偵測可以機械化、判定要看語意):事件命名的過去式是語意類別、不是風格

查詢裝進事件通道:手工重建 RPC

第二種錯位的方向相反:把對話裝進通知通道。同一個 App 的借閱功能要在建立借閱記錄前查書籍資訊,直接呼叫另一個 domain 的 repository 違反依賴方向;修正方案選了事件驅動的 request/response——BookInfoRequested 發出、BookInfoProvided 回應。耦合確實解掉了,但設計規格裡跟著出現的每一項機制、都是同步呼叫免費附贈的東西:

事件版要自己蓋的同步呼叫的對應物
correlation id 配對請求回應呼叫堆疊天然對應
waitFor + timeout函式 return
「查無資料」的錯誤事件回傳 null/拋例外
超時與通道故障路徑不存在——同步呼叫沒有「沒人接」

這張表的形狀有個名字:用事件通道手工重建 RPC。事件的天然語意是通知——fire and forget、任意多訂閱者、無所謂回應;一問一答的對話硬放上通知通道,RPC 的基礎設施就得自己蓋一遍、而且每個跨 domain 查詢點都要再蓋一遍。名字先洩漏了錯位:BookInfoRequested 的「Requested」記錄的是「有人想要」這個請求、不是業務世界發生的事實——事件命名的過去式測試在這裡當了偵測器,兩條判準在名字上交會。

事件真正買到、而更便宜的工具給不了的能力有兩樣:時間解耦(發布者不等回應、雙方可以不同時在線)與基數解耦(一個事件任意多訂閱者)。這個案例的查詢是同進程、一對一、呼叫端必須等到答案——三個特徵全部用不到事件的能力。解依賴方向有便宜一個量級的工具:消費端宣告自己需要的 port、對方提供實作、組裝層接線,依賴倒置同樣成立、呼叫還是一次普通的 await。完整比價(含 Port 版的介面設計)見 用事件做同步查詢等於手工重建 RPC

判準收斂成兩行:

  • 要解的是依賴方向(同進程、一對一、要等答案)→ 消費端 port、付一張介面的價
  • 要解的是時間、部署或基數(跨進程、離線佇列、多方反應)→ 事件、correlation 與 timeout 是這個量級的合理成本

判讀訊號

訊號判讀
事件類別以動詞開頭(ImportBook命令穿著事件的衣服——訂閱者會以為自己能影響流程
事件成對出現 XxxRequestedXxxProvided + correlation id對話穿著事件的衣服——在通知通道上手工重建 RPC
發布事件後 waitFor/await 回應才能往下走呼叫端語意是同步查詢、事件只是繞路
訂閱者不只一個、或處理可以離線延後反向確認:這時事件才是本命、別退回 port

前三個訊號的修法方向一致:回到責任結構重新分類——是意圖就改命令通道(或直接呼叫 use case)、是對話就改查詢(消費端 port)、確認是事實才留在事件。第四個訊號防反向矯枉:把所有跨 domain 通訊都收回同步呼叫、會把真正需要時間與基數解耦的場景也一起收掉。

邊界

本章處理事件與命令、查詢的訊息類型分界——三者都是離散訊息、差在責任結構。事件與 狀態流(連續觀測)的分界是另一個維度:那裡兩邊都不要求執行、差在「發生了什麼」與「現在是什麼」的時間語意,見 domain event 與狀態流。事件跨服務攜帶全量狀態的 event-carried state transfer 是分散式情境的正當設計、其適用邊界也在該章。

下一步

事件的另一條分界(對狀態流)在 domain event 與狀態流——兩章合起來把 domain event 對三個鄰居(命令、查詢、狀態流)的邊界畫完。兩個 case 的完整記錄:事件命名的過去式用事件做同步查詢等於手工重建 RPC;port 解依賴的實戰在 mock 55 個方法只用 5 個