簡介#

在前一個模組中,我們引入了明確的 commands 和 queries 以及對應的 handlers。本模組將展示如何以簡單但強大的方式來豐富(enrich)這些 handlers,利用先前建立的基礎架構,輕鬆地在應用程式中加入 cross-cutting concerns

新需求:Database Retries#

假設我們收到一個新需求:資料庫連線不穩定,時常斷線,因此需要實作 retry 機制

天真的做法#

最直覺的做法是在 EditPersonalInfoCommandHandlerhandle 方法中,將 unitOfWork.commit() 包在 try-catch 迴圈裡重試三次:

public Result handle(EditPersonalInfoCommand command) {
    Repository studentRepo = new StudentRepository(unitOfWork);
    Student student = studentRepo.getById(command.getId());
    if (student == null)
        return Result.fail("No student found with Id '{id}'");
    student.setName(command.getName());
    student.setEmail(command.getEmail());
    for (int i = 0; i < 3; i++) {
        try { unitOfWork.commit(); }
        catch (Exception e) { continue; }
    }
    return Result.oK();
}

然而這個做法行不通,原因有二:

  • 當資料庫連線中斷時,原本持有的 connection 會變得無法使用,即使資料庫恢復上線,也無法直接重新呼叫 commit(),必須建立新的資料庫連線
  • commit() 不是唯一存取資料庫的地方,getById() 同樣會存取資料庫,如果在兩次嘗試之間資料發生變化,也需要重新讀取

正確的做法:重新執行整個 Handler#

唯一可靠的方式是重新執行整個 command handler

public Result handle(EditPersonalInfoCommand command) {
    for (int i = 0; i < 3; i++) {
        try {
            Repository studentRepo = new StudentRepository(unitOfWork);
            Student student = studentRepo.getById(command.getId());
            if (student == null)
                return Result.fail("No student found with Id '{id}'");
            student.setName(command.getName());
            student.setEmail(command.getEmail());
            unitOfWork.commit();
            return Result.oK();
        } catch (Exception e) {
            continue;
        }
    }
}

但這帶來兩個問題:

  • 程式碼冗長且混亂
  • 如果要在其他 command handler 也實作 retry,只能複製貼上這段 try-catch 迴圈,造成大量重複程式碼

幸運的是,我們可以利用前一模組中所有 command 和 query handlers 共同實作的統一介面,在其上引入 decorators

引入 Database Retry Decorator#

建立一個 DatabaseRetryDecorator 類別,實作 ICommandHandler 介面:

package logic.decorators;

public class DatabaseRetryDecorator implements ICommandHandler {
    private ICommandHandler handler;
    private Config config;

    public DatabaseRetryDecorator(ICommandHandler handler, Config config) {
        this.config = config;
        this.handler = handler;
    }

    public Result handle(ICommand command) {
        for (int i = 0; ; i++) {
            try {
                Result result = handler.handle(command);
                return result;
            } catch (Exception ex) {
                if (i >= config.getNumberOfDatabaseRetries()
                    || !isDatabaseException(ex))
                    throw new Exception();
            }
        }
    }

    private boolean isDatabaseException(Exception exception) {
        String message = exception.getInnerException().getMessage();
        if (message == null)
            return false;
        return message.contains("The connection is broken and recovery is not possible")
            || message.contains("error occurred while establishing a connection");
    }
}

這個 decorator 的運作方式:

  • 實作與所有 command handlers 相同的 ICommandHandler 介面
  • 透過建構子注入一個被包裝的 handler設定檔
  • handle 方法中,用 for 迴圈搭配 try-catch 來執行被包裝的 handler
  • 捕獲到 exception 後,檢查是否為資料庫相關的例外,以及是否超過最大重試次數
  • 若非資料庫例外或超過次數,則重新拋出例外

當資料庫連線中斷時,原有的連線不可重用。因此 command handler 需要注入 SessionFactory(而非 UnitOfWork),在每次重試時建立新的 UnitOfWork 實例。

Decorator Pattern#

Decorator 是一個修改既有 class 或 method 行為的 class 或 method,但不改變其 public interface,因此不影響該 class 的使用者。

在範例中,decorator 實作了與所有 command handlers 相同的 ICommandHandler 介面,但它不是真正的 command handler,而是以額外邏輯增強 handler 的行為,且不需要修改任何原有的 command handler 程式碼。

Decorator 的優點#

  • 引入 cross-cutting concerns,不產生重複程式碼
  • 遵守 Single Responsibility Principle
  • 可以串聯多個 decorators,組合出複雜功能

Decorator 與 Handler 的職責分離#

  • Decorators 負責處理技術性問題(如 retry、logging)
  • Handlers 負責處理業務邏輯(business use cases)

可以使用 Spring AOP 以更宣告式的方式來套用 decorators。

引入另一個 Decorator#

假設又收到新需求:為 EditPersonalInfo 實作 audit logging。利用已建立的 decorator 機制,只需新增一個 AuditLoggingDecorator

package logic.decorators;

public class AuditLoggingDecorator implements ICommandHandler {
    private ICommandHandler handler;

    public AuditLoggingDecorator(ICommandHandler handler) {
        this.handler = handler;
    }

    public Result handle(ICommand command) {
        String commandJson = JsonConvert.serializeObject(command);
        Console.writeLine("Command of type " + command.getType().getName() + ": " + commandJson);
        return handler.handle(command);
    }
}

這個 decorator 的運作方式:

  • 同樣實作 ICommandHandler 介面
  • 在呼叫被包裝的 handler 之前,先將 command 序列化為 JSON 並記錄下來
  • 然後委派給實際的 handler 執行

在實際應用中,應該注入一個 logger 取代 Console.writeLine,就像在 DatabaseRetryDecorator 中注入 config 一樣。

多個 decorators 可以串聯起來,例如:先經過 AuditLoggingDecorator 記錄日誌,再經過 DatabaseRetryDecorator 處理重試,最後才到真正的 handler。

Command 和 Query Handlers 最佳實踐#

將 Handler 嵌套在 Command/Query 類別中#

隨著應用程式的發展和重構,可能會刪除某些 handlers,卻忘記刪除對應的 command 或 query,留下孤立的(orphaned)類別

解決方法是將 handler 類別嵌套在對應的 command 或 query 類別內部

  • 刪除 command 時,handler 也會一起被刪除
  • 提升可發現性(discoverability)內聚性(cohesion)
  • 可以將 handler 設為 non-public,它們不需要成為組件的公開 API

不要重用 Command Handlers#

當兩個類似的 use case 出現時,可能會想從一個 command handler 呼叫另一個。例如在 UnregisterCommandHandler 中 dispatch DisenrollCommand

class UnregisterCommandHandler implements ICommandHandler {
    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.getId());

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

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

這是不正確的做法,因為:

  • Commands 不應該產生其他 commands – command 代表的是 client 可以對應用程式做的操作,只有 client 才能發起 commands
  • 系統本身應該回應 commands 並產生 domain events,而非自行建立後續的 commands

從一個 command handler 中 dispatch 另一個 command 是對 command 概念的誤用(misuse)。Commands 應由外部 client 觸發,系統內部的反應應透過 domain model 來處理。

正確的重用方式#

如果需要在多個 handlers 之間共用邏輯,應該將共用程式碼抽取到 domain model 中(例如 domain service),然後在各個 handler 中使用。這樣既達到程式碼重用,又不會產生 command handler 互相呼叫的問題。

小結#

  • 使用 Decorator Pattern 來擴展 command 和 query handlers 的功能,無需修改 handler 本身
  • Decorator 是一個修改另一個 class 行為但不改變其 public interface 的 class,本質上是保持介面不變的 wrapper
  • Decorator 可以引入 cross-cutting concerns(如 database retry、audit logging),避免程式碼重複,並遵守 Single Responsibility Principle
  • 多個 decorators 可以串聯,組合出複雜功能,同時保持每個 decorator 小巧且專注
  • 最佳實踐:將 handler 嵌套在對應的 command/query 類別內部,避免孤立類別
  • 最佳實踐:不要重用 command handlers,共用邏輯應抽取到 domain model(如 domain service)中