終於要把前面討論的架構落實成程式碼了。由於應用、Web 與持久化層在我們的架構中鬆散耦合,我們完全可以隨意建模領域程式碼——做領域驅動設計(Domain-Driven Design,DDD)、實作充血或貧血領域模型,甚至自創一套做法皆可。本章描述一種在六角架構風格中實作使用案例的「主觀」做法。既然這是以領域為中心的架構,我們就從領域實體開始,再圍繞它打造使用案例。

實作領域模型#

我們要實作「從一個帳戶轉帳到另一個帳戶」的使用案例。一種物件導向的建模方式是建立 Account 實體,支援從來源帳戶提款、存入目標帳戶:

  • Account 實體提供帳戶當下的快照。每一次提款與存款都記錄在 Activity 實體中。
  • 由於把帳戶所有 activity 都載入記憶體並不明智,Account 只保留近幾天或幾週的 activity 窗口,封裝在 ActivityWindow 值物件中。
  • 為了仍能計算當前餘額,Account 另外持有 baselineBalance 屬性,代表 activity 窗口第一筆 activity 之前的餘額。總餘額 = baseline 餘額 + 窗口內所有 activity 的餘額

如此一來,提款與存款就只是往 activity 窗口新增一筆 activity(由 withdraw()deposit() 方法完成)。提款前會先檢查「不可透支帳戶」這條業務規則。有了能提款與存款的 Account,就能往外建立使用案例了。

使用案例的本質#

一個使用案例通常依循以下步驟:

  1. 接收輸入。
  2. 驗證業務規則。
  3. 操作模型狀態。
  4. 回傳輸出。

為什麼第一步不叫「驗證輸入」?因為作者認為使用案例的程式碼應只關注領域邏輯,不該被輸入驗證污染——輸入驗證會放在別處。但使用案例確實負責驗證業務規則,並與領域實體共同承擔此責。

業務規則通過後,使用案例會依輸入操作模型狀態:通常會改變某個領域物件的狀態,再把新狀態交給持久化 adapter 實作的 port 去儲存;若有持久化以外的副作用,則為每個副作用呼叫對應的 adapter。最後一步,把輸出 adapter 的回傳值轉譯成輸出物件,回傳給呼叫端的 adapter。

為避免第 2 章談過的過寬服務問題,我們為每個使用案例建立獨立的服務類別,而非把所有使用案例塞進一個服務。以轉帳為例,SendMoneyService

  • 實作 SendMoneyUseCase 輸入 port 介面。
  • 呼叫 LoadAccountPort 輸出 port 載入帳戶。
  • 呼叫 UpdateAccountStatePort 輸出 port 將更新後的帳戶狀態持久化。
  • 透過 @Transactional 標註設定資料庫交易邊界(詳見第 7 章)。

Figure 5.1: 服務實作使用案例、修改領域模型,並呼叫輸出 port 持久化變更後的狀態

本例中 UpdateAccountStatePortLoadAccountPort 是由持久化 adapter 實作的 port 介面。若兩者常一起使用,也可合併成更寬的介面,甚至依 DDD 語彙命名為 AccountRepository。本書選擇只在持久化 adapter 中使用「Repository」這個名稱,但你可以自行決定命名。

驗證輸入#

雖然剛說輸入驗證不是使用案例類別的職責,但它仍屬於應用層,因此在這裡討論。為何不讓呼叫端 adapter 先驗證再傳入?

  • 我們不該信任呼叫端已完成使用案例所需的全部驗證。
  • 使用案例可能被多個 adapter 呼叫,每個 adapter 都得各自實作驗證,難免有人出錯或漏掉。
  • 應用層必須在意輸入驗證,否則應用核心可能收到無效輸入,破壞模型狀態。

那麼若不放在使用案例類別,輸入驗證該放哪?交給輸入模型自己處理。轉帳的輸入模型是 SendMoneyCommand,更精確地說,驗證在其建構子中進行:

  • 轉帳需要來源與目標帳戶 ID,以及轉帳金額。任一參數不得為 null,且金額必須大於零。
  • 若違反任一條件,就在建構期間拋出例外,直接拒絕物件建立。
  • 用 record 實作 SendMoneyCommand 使其不可變(immutable):一旦成功建構,便能確保狀態有效且無法被改成無效。

我們真的要手刻每條驗證嗎?既然有函式庫能代勞,例如 Java 的 Bean Validation API:

  • 用標註(如 @NotNull)在欄位上表達驗證規則。
  • 在建構子最後呼叫 Validatorvalidate() 方法,評估這些標註,違反時拋出例外。
  • 預設標註不夠用時,可自訂標註與驗證器(例如 @PositiveMoney)。

常有人說「模型類別中不該使用函式庫」。把依賴降到最低固然有智慧,但若一個輕量依賴能替我們省下時間,何樂不為?

另外,SendMoneyCommand 中的「command」一詞不同於命令模式(command pattern)的常見解讀。命令模式的 command 是可執行的(有 execute() 方法);而我們的 command 只是把參數傳給使用案例服務的資料傳輸物件。叫它 SendMoneyDTO 也行,但作者偏好「command」以明確表達「此使用案例會改變模型狀態」。

建構子的威力#

輸入模型 SendMoneyCommand 把大量責任放在建構子上:類別不可變,建構子參數對應每個屬性,且建構子也負責驗證,因此無法建立出狀態無效的物件。

本例只有三個參數。若參數更多,能否用 builder 模式更方便?可以:把長參數列的建構子設為 private,藏在 builder 的 build() 方法裡,仍讓建構子做驗證。聽起來不錯,但考慮以下情境:

  • 軟體生命週期中常要為 SendMoneyCommandBuilder 新增欄位。我們把新欄位加進建構子與 builder。
  • 接著被同事、電話、郵件……打斷思緒。回來後繼續寫程式,卻忘了在呼叫 builder 建立物件的地方補上新欄位。
  • 編譯器不會警告我們正試圖建立一個狀態無效的不可變物件!只能寄望執行期(最好是在單元測試中)驗證邏輯介入並拋錯。

若直接使用建構子、而非藏在 builder 之後,每次新增或移除欄位,就能順著編譯錯誤的軌跡把改動反映到整個程式庫。長參數列也能排版得整齊,好的 IDE 還會顯示參數名稱提示。

Figure 5.2: IDE 在參數列顯示參數名稱提示,幫助我們不迷失

為了讓程式更易讀也更安全,可引入不可變的值物件取代部分原始型別參數。值物件(value object)的「值」就是它的身分:兩個值相同的值物件視為相同。例如把街道、城市、郵遞區號、國家、州別合併成 Address 值物件;甚至更進一步做出 CityZipCode 值物件,這樣若誤把 City 傳進 ZipCode 參數,編譯器會抱怨。

某些情況 builder 仍是較佳解。例如部分參數為選用時,用建構子就得傳入 null(醜陋),而 builder 可只定義必要參數。但若用 builder,務必確保 build() 在遺漏必要參數時大聲失敗,因為編譯器不會替我們檢查。

為不同使用案例使用不同的輸入模型#

我們可能想為不同使用案例共用同一個輸入模型。以「註冊帳戶」與「更新帳戶資料」為例,兩者初始所需輸入幾乎相同(使用者名稱、電子郵件等),但「更新」需要帳戶 ID,「註冊」不需要。若共用同一輸入模型:

  • 註冊時永遠得傳入 null 的帳戶 ID——讓不可變 command 物件的某欄位允許 null,本身就是程式碼壞味道。更糟的是兩個使用案例被耦合,必須一起演進。
  • 輸入驗證該怎麼辦?兩者驗證不同(一個要 ID、一個不要),只能把客製驗證邏輯塞進使用案例本身,污染業務程式碼。
  • 若註冊時帳戶 ID 欄位意外有非 null 值,要拋錯還是忽略?這些都是維護工程師(包括未來的自己)會困惑的問題。

為每個使用案例設置專屬輸入模型,能讓使用案例更清晰、與其他使用案例解耦、避免不想要的副作用。代價是必須把傳入資料對映到不同使用案例的不同輸入模型——這個對映策略與其他策略會在第 9 章討論。

驗證業務規則#

輸入驗證不屬於使用案例邏輯,但驗證業務規則絕對是。如何區分兩者?一個務實的判準:

  • 業務規則驗證需要存取領域模型的當前狀態;輸入驗證不需要。
  • 輸入驗證可宣告式實作(如前述 @NotNull),業務規則則需要更多脈絡。
  • 也可說輸入驗證是「語法驗證」,業務規則是使用案例脈絡下的「語意驗證」。

例如「來源帳戶不可透支」需要存取模型當前狀態以檢查餘額,是業務規則;而「轉帳金額必須大於零」無需存取模型,可作為輸入驗證的一部分。

這個區分或許有爭議——你可能認為轉帳金額太重要,驗證它無論如何都該算業務規則。但這個區分幫助我們把驗證放對位置、日後也容易找回:只需回答「這個驗證需不需要存取模型當前狀態」。這正是第 1 章「可維護性支援決策」的好例子。

如何實作業務規則?

  • 最佳做法是放進領域實體,如同「來源帳戶不可透支」就放在 Account 裡——緊鄰需要遵守此規則的業務邏輯,易於定位與推理。
  • 若無法在領域實體中驗證,可在使用案例開始操作領域實體之前驗證:呼叫一個執行實際驗證的方法,失敗時拋出專屬例外,由對接使用者的 adapter 顯示為錯誤訊息。

例如檢查「來源與目標帳戶是否確實存在於資料庫」。更複雜的業務規則可能需要先從資料庫載入領域模型再檢查其狀態——既然都要載入,就該把規則實作在領域實體本身。

充血 vs. 貧血領域模型#

我們的架構風格未規定如何實作領域模型,這既是祝福(可因地制宜)也是詛咒(缺乏指引)。常見的爭論是:要遵循 DDD 哲學的充血領域模型(rich domain model),還是貧血領域模型(anemic domain model)

  • 充血領域模型:盡可能把領域邏輯實作在核心的實體中。實體提供改變狀態的方法,且只允許符合業務規則的合法改變(如前述 Account)。此時使用案例僅作為領域模型的進入點,代表使用者的意圖,並把它轉譯成對領域實體的協調式方法呼叫。轉帳服務會載入來源與目標 Account,呼叫其 withdraw()deposit(),再存回資料庫。
  • 貧血領域模型:實體很薄,通常只有持有狀態的欄位與 getter/setter,不含領域邏輯。領域邏輯改放在使用案例類別中,由它們驗證業務規則、改變實體狀態、再交給輸出 port 儲存。「充血」之處在使用案例而非實體。

本書的架構能實作以上任一風格(乃至其他風格),可自由選擇適合需求的那一種。

為不同使用案例使用不同的輸出模型#

使用案例完工後該回傳什麼給呼叫端?與輸入類似,輸出越貼近特定使用案例越好,只該包含呼叫端真正需要的資料。

  • 轉帳範例回傳 Boolean——在此脈絡下最精簡、最具體的值。
  • 我們可能想回傳完整的、更新後的 Account(也許呼叫端想知道新餘額)。但呼叫端真的需要嗎?若需要,是否該為「存取那筆資料」建立專屬使用案例,供不同呼叫端使用?

這些問題沒有單一正解,但提問本身能讓使用案例盡量保持具體。有疑慮時,回傳越少越好。

在使用案例間共用輸出模型,往往會緊密耦合它們:若其中一個需要新欄位,其他即使用不到也得處理它。共用模型長期下來會因種種理由「腫瘤式」增長。套用單一職責原則、保持模型分離,有助於使用案例解耦。基於同理,也應抗拒「拿領域實體當輸出模型」的誘惑——我們不希望領域實體因非必要的理由而改變。(以實體當輸入/輸出模型的議題,詳見第 11 章。)

唯讀使用案例怎麼辦?#

至今討論的都是修改模型狀態的使用案例。唯讀操作呢?假設 UI 需要顯示帳戶餘額,要為它建立專屬使用案例嗎?

  • 把這種唯讀操作稱為「使用案例」有點彆扭。UI 確實需要某個「查看帳戶餘額」的資料,但在某些情況下稱之為「使用案例」顯得人為。若專案脈絡中視其為使用案例,當然就比照其他案例實作。
  • 從應用核心的觀點看,這只是一次單純的資料查詢。若專案脈絡中不視其為使用案例,就可實作成查詢(query),與真正的使用案例區隔。

在本書架構中的一種做法是:為查詢建立專屬輸入 port,並在「查詢服務」中實作:

  • 查詢服務的運作方式與「command」使用案例服務相同:實作名為 GetAccountBalanceUseCase 的輸入 port,呼叫 LoadAccountPort 輸出 port 從資料庫載入資料,並以 GetAccountBalanceQuery 作為輸入模型。
  • 如此一來,唯讀查詢就能與修改型使用案例(command)清楚區分——只看輸入型別的名稱即可分辨。這與**命令查詢分離(Command-Query Separation,CQS)命令查詢職責分離(Command-Query Responsibility Segregation,CQRS)**等概念相得益彰。

上例的服務除了把查詢轉交給輸出 port 外幾乎不做事。若跨層共用同一模型,可以抄個捷徑:讓客戶端直接呼叫輸出 port。這個捷徑會在第 11 章討論。

這如何幫助我打造可維護的軟體?#

我們的架構讓我們隨意實作領域邏輯,但若為使用案例的輸入與輸出獨立建模,就能避免不想要的副作用

這確實比共用模型更費工——得為每個使用案例引入獨立模型,並在模型與實體間對映。但使用案例專屬的模型能讓人對使用案例有清晰的理解,長期更易維護,也讓多名開發者能平行處理不同使用案例而互不干擾。配合嚴謹的輸入驗證,使用案例專屬的輸入與輸出模型能大幅提升程式庫的可維護性。

下一章,我們將從應用中心往「外」走一步,探討如何打造一個 Web adapter,為使用者提供與使用案例對話的管道。