CQRS 與 Event Sourcing#

CQRS 常被描述為通往 Event Sourcing 的踏腳石,彷彿它只是一個過渡階段,最終必然導向 Event Sourced 架構。這是一個誤解。

No segregation -> CQRS -> Event Sourcing

CQRS 可以獨立提供大量價值,即使你從未實作 Event Sourcing。

CQRS 不需要 Event Sourcing#

  • CQRS 不需要 Event Sourcing 即可運作
  • Event Sourcing 通常需要 CQRS 才能有效運作
  • Event Sourcing 沒有搭配 CQRS(不分離讀寫)是較不常見且較不具擴展性的方案

Event Sourcing 帶來大量的複雜度,其門檻遠高於 CQRS,甚至高於資料庫層級的分離。雖然 Event Sourced 系統仍有其價值,但複雜度的代價不像 CQRS 那樣容易被合理化。

適合 Event Sourcing 的場景#

通常受益於 Event Sourcing 的系統是一種特殊類型 – 那些必須追蹤導致特定狀態的所有事件的系統:

  • 金融科技(Finance tech):必須追蹤所有事件,提供所有金融交易的 audit log
  • 在這類系統中,將 domain events 視為一等公民非常合理

CQRS 可以獨立於 Event Sourcing 成功運用。這兩個概念大致上是正交的(orthogonal),但並非完全無關。

Evolutionary Design(演進式設計)#

演進式設計是一個重要概念:不需要一次性導入所有 CQRS 相關技術。

  • 不必實作課程中的所有技術
  • 延遲決策,直到需求被證明確實存在
  • 這個原則適用於任何 design pattern,不僅限於 CQRS

每個 design pattern 都有其成本與效益。在套用模式之前,務必確保效益大於成本。

CQRS 不是全域模式#

CQRS 不是像 Microservices 架構那樣的應用程式層級模式,而是應該綁定到特定的 Bounded Context 或 Microservice。這意味著:

  • 在不同的 Bounded Context 中可以做出不同的 CQRS 決策
  • 對業務至關重要且有高效能需求的 Bounded Context,可以實作到資料層級的完全分離
  • 較不重要的 Bounded Context,可以僅在 API 層級做分離
  • 甚至保留 CRUD 導向的 endpoints 也可以是一種選項

在大多數企業級應用中,甜蜜點(sweet spot)是分離到 Domain Model 層級 – 分離除了資料庫以外的一切。這通常能在成本與效益之間取得最佳平衡。

從 Handlers 使用 Commands 和 Queries#

一個常見的問題是:能否從 Command Handler 或 Query Handler 中使用其他的 Commands 和 Queries? 簡短的回答是「不行」,但詳細的答案取決於具體情境。

Command Handler 中使用另一個 Command:不行#

以下是一個反面範例,在 UnregisterCommandHandler 中 dispatch 了 DisenrollCommand

class UnregisterCommandHandler implements ICommandHandler {
    @Autowired
    private SessionFactory sessionFactory;
    @Autowired
    private Gate gate;

    public Result handle(UnregisterCommand command) {
        UnitOfWork unitOfWork = new UnitOfWork(sessionFactory);
        Repository repository = new StudentRepository(unitOfWork);
        Student student = repository.getById(command.Id);
        if (student == null)
            return Result.fail("No student found for Id " + command.Id);

        gate.dispatch(new DisenrollCommand(student.getId(), 0, "Unregistering"));
        gate.dispatch(new DisenrollCommand(student.getId(), 1, "Unregistering"));

        repository.delete(student);
        unitOfWork.commit();
        return Result.Ok();
    }
}

應用程式不應該自行觸發 Commands。 Command 代表客戶端對應用程式能做的事情,只有客戶端才能發起 Command。系統回應 Command 並產生 Domain Events,而不是產生新的 Command。

如果需要在 Command Handlers 之間重用邏輯,應該將共用程式碼提取到一個獨立的類別中,例如 Domain Service,而不是重用 Command Handler 本身。

訊息循環的概念#

  • Command:客戶端對應用程式能做什麼
  • Event:應用程式發生了什麼事
  • State:所有 Events 的總和

客戶端看到應用程式的當前狀態並產生 Command。Write Model 接收 Command 並產生 Events。Read Model 接收 Events 並建構當前狀態的快照,然後呈現給客戶端。

Command Handler 中使用 Query:視情況而定#

這取決於你是否能在 Command 端可靠地取得當前狀態:

  • 如果可以(例如沒有使用 Event Sourcing):直接查詢 Command 資料庫即可,不需要使用 Query
  • 如果不行(例如使用了 Event Sourcing):由於無法從 Event Log 有效率地重建狀態,你別無選擇,只能使用 Read 資料庫透過 Query 來取得資料

在 Event Sourcing 場景下使用 Read 資料庫會帶來一系列一致性問題,因為 Read 和 Write 資料庫之間不是立即一致的。例如,unique constraint 驗證可能因此無法正確執行。

Query Handler 中使用 Command 或 Query#

  • 使用 Command:絕對不行,因為 Query Handler 不能改變狀態
  • 使用另一個 Query:由於 Read Model 是不可變的(immutable),指導原則沒有那麼嚴格。但一般來說,仍然建議不要重用 Query Handlers,而是將共用邏輯提取到獨立的 Service 中
CommandQuery
Command Handler不行僅限 Event Sourcing 場景
Query Handler不行盡量避免

One-way Commands(單向 Commands)#

另一個常見的誤解是 Commands 應該永遠是單向的 – 也就是說 Commands 不應該回傳任何東西,因為這會違反 CQS(Command-Query Separation)原則。這個觀點建議改為輪詢(poll)一個外部來源來取得 Command 執行結果。

這是一個誤解。

真正的 One-way Commands 是不可能的#

Commands 總是需要回傳某種形式的確認:

  • OKError
  • 一個 locator(可用來從另一個 API endpoint 輪詢結果)

既然如此,如果操作本身不是天生非同步的,為什麼要回傳 locator 而不直接回傳結果呢?

Commands 可以回傳的內容#

  • 如果操作不需要太多時間,同步完成並回傳 OK 或 validation error
  • 如果是更新操作,可以回傳 aggregate 的新版本號
  • 如果是建立操作,可以回傳新建實體的 ID

回傳新建資源的 ID 看起來像是違反了 CQS 原則,但其實不然。Commands 回傳它們所建立的資源的 locator 是可以接受的。如果你同步建立資源,那麼該資源的 ID 就是這個 locator。

CQRS vs. Specification Pattern#

這是兩個 Domain-Driven Design 模式之間的一個重要衝突。

Specification Pattern 的用途#

Specification Pattern 將一段領域知識封裝在單一位置,然後在三種場景中重用:

  • Data retrieval(資料檢索)– 屬於 Query 端
  • Input validation(輸入驗證)– 屬於 Command 端
  • Construction-to-order(按需建構)

衝突所在#

  • Specification Pattern 主張使用單一 Domain Model 來處理驗證和資料檢索,遵循 DRY 原則(Don’t Repeat Yourself)
  • CQRS 主張將 Domain Model 拆分為兩個,分別處理讀和寫,追求 Loose Coupling

如何取捨?#

這是 DRY 原則與 Loose Coupling 原則之間的經典衝突。指導原則如下:

  • Loose Coupling 在絕大多數情況下勝出,除了最簡單的情境
  • Domain Knowledge 重複是較小的惡:重複領域知識雖然不太方便,但與高耦合的替代方案相比並不算糟糕
  • 高耦合帶來太多限制:你的雙手會被另一個元件的限制所束縛

在大型系統中,你幾乎總是會傾向選擇 Loose Coupling 而非防止讀寫之間的領域知識重複。Specification Pattern 在較簡單的情境中運作良好,但在大型系統中 CQRS 的分離帶來更大的靈活性。

Book Summary(全書總結)#

整本書涵蓋了以下主題:

  • CQRS 模式:它是什麼,以及如何在實務中應用
  • CQRS 的三大目標:Simplicity(簡單性)、Performance(效能)、Scalability(可擴展性),其中簡單性最為重要
  • 漸進式引入分離
    • 分離 API endpoints,將單一巨大的 update method 拆分為多個 task-based 方法
    • 提取明確的 Commands、Queries 和 Handlers,引入統一介面和 Decorators
    • 從 Read 端移除 Domain Model,直接撰寫高效能 SQL 查詢
    • 引入獨立的 Read 資料庫,schema 完全配合 Read Model 需求
  • 讀寫資料庫之間的同步
  • CQRS 最佳實踐與常見誤解

小結#

本模組總結了 CQRS 的核心最佳實踐與常見誤解:

  • CQRS 與 Event Sourcing 是獨立的概念:CQRS 不需要 Event Sourcing 即可提供大量價值,但 Event Sourcing 通常需要 CQRS
  • 採用演進式設計:不需要一次導入所有技術,延遲決策直到需求被證明,並確保每個模式的效益大於成本
  • 不要從 Handler 中觸發其他 Commands:Command 只能由客戶端發起,需要共用邏輯時應提取到 Domain Service
  • One-way Commands 是個誤解:Commands 可以也應該同步回傳結果(OK、Error、新建資源的 ID),不需要強迫使用非同步輪詢
  • CQRS 優先於 Specification Pattern:在 DRY 原則與 Loose Coupling 之間,大型系統幾乎總是選擇 Loose Coupling,容忍少量的領域知識重複
  • CQRS 是 Bounded Context 層級的決策,不同的 Context 可以有不同的分離程度