l

2021年3月18日 星期四

領域驅動設計學習筆記(14):透過合約讓領域模型與通用語言更精確

March 18 21:18~23:13

【圖1】ezKanban的design-level event storming(部分)


工商服務

自學好久的領域驅動設計(Domain-Driven Design;DDD)卻連一個完整的小功能都做不出來嗎?覺得DDD要學得東西太多,總是抓不到重點嗎?想要透過事件風暴(event storming)來塑模業務流程並找出bounded context與aggregate,卻不知如何下手嗎?

你需要真實世界的DDD + Event Storming + Clean Architecture 教學與範例,歡迎參加2021年4月2、3、4日(五、六、日)的【領域驅動設計與簡潔架構入門實作班】課程(已確定開課)。

***

前言

在〈用合約彌補測試案例的不足(上):撰寫合約〉與〈用合約彌補測試案例的不足(下):透過合約發現系統行為缺陷〉這兩篇文章中,Teddy談到依合約設計(Design By Contract)在ezKanban系統中的幾個實際應用案例,這一集Teddy要談如何透過撰寫合約找到領域模型與通用語言的行為漏洞。

***

問題敘述

幾天前Teddy去客戶家上DDD與Event Storming企業內訓課程,沒有和ezKanban團隊mobbing。今天mobbing的時候團隊問Teddy一個關於Aggregate行為的問題,請先參考圖1:

  • WorkflowCard是兩個Aggregate,前者代表看板的工作流程,後者代表一項工作。
  • Workflow包含垂直的工作階段—Stage,與水平的工作階段—SwimLane
  • 使用者新增Card之後,Card被放在Workflow的Stage或SwimLane裡面,使用者可以調整卡片的順序(order),該順序紀錄於Stage和SwimLane身上。
  • 由於Workflow與Card分屬不同的Aggregate,因此當使用者移動卡片(Move Card)之後,Card必須通知Workflow卡片已經移動,以同步Workflow身上關於卡片位置與順序的狀態。


團隊遇到的情況是:他們在寫Card.move() 的前置條件(如圖2),原本希望除了檢查參數不是null,以及order >= 0 以外,還能夠檢查order不會大於該卡片所要加入的那個Stage/SwimLane的卡片總數。例如,使用者在To Do這個Stage加上一張卡片,To Do原本已經有2張卡片,move收到的order參數是8,則前置條件檢查失敗。團隊希望能在Card.move()的前置條件檢查這個限制。


【圖2】Card.move方法的前置條件

但是,因為一個Stage/SwimLane身上有多少張卡片的資料是儲存在Stage/SwimLane身上,Card並不知道,所以也就沒辦法在Card.move()方法加上這一條前置條件。

於是團隊問Teddy:是不是應該把move方法移到Workflow上面,這樣就可以在前置條件中檢查Card的順序?」

***

思考邏輯

這個問題,可以從以下兩點來思考:

  1. Single source of truth:Card與Workflow分屬不同的Aggregate,兩者之間透過領域事件達到狀態最終一致性。因此,第一個思考點就是:「關於一張卡片移動至哪個Stage/SwimLane這件事,擁有第一手資訊的人是誰?Workflow還是Card?」Teddy覺得應該是Card。
  2. 關於檢查Card順序這件事,除了原本的Card.move()方法以外,另外一個地方就是Workflow.commitCardInLane這個方法,請參考圖3第270~271行,檢查了卡片的順序不能大於現有Stage/SwimLane裡面所擁有的卡片數量。

【圖3】Workflow.commitCardInLane()方法的前置條件


現在假設一個情況:

  • 一張卡片要移到To Do上面的第8個位置。
  • To Do身上只有2張卡片。

因此,圖3第270~271行的前置條件會失敗,丟出PreconditionViolationException並且終止程式執行,代表呼叫它的人有bug。呼叫commitCardInLane的人是誰?是CardMoved領域事件發生後的Policy,簡單來說就是Card有bug,這個bug很可能是前端傳過來錯誤的order。

***

如果不希望因為卡片順序有問題就終止整個系統執行,另一種可能性是放寬Workflow.commitCardInLane()的前置條件,把270~271行拿掉,改成在程式裏面(commitCardInLane的method body)判斷,如果條件不成立則丟出一個CardCommittedFailed領域事件,並通知Card執行交易補償(因為Card已經把它自己的狀態改成移動到新的To Do裡面,但Workflow判斷卡片所移動的順序是不合法的,所以請卡片移回它原本的位置)。

如果選擇這種作法,則原本圖1要再加上CardCommittedFailed領域事件,如圖4所示。


【圖4】讓領域事件以及Workflow Aggregate與Card Aggregate的行為更加完整。

***

結論

無論是選擇上述兩種方式的哪一種,系統的行為都因為撰寫合約而變得更精確。最後團隊與Teddy選擇了第二種方式--交易補償,因為這種行為模式容錯能力比較好。

看到這裡如果還沒跑開的鄉民,應該可以發現以下結論:

透過撰寫合約讓領域模型與通用語言更加完整。

***

友藏內心獨白:寫合約可以找到系統行為的漏洞。

2 則留言:

  1. 這個跨Aggregate的業務邏輯,能否使用Domain Service 來處理呢? (當然啦,同一個Bounded Context & AP 下)

    回覆刪除
    回覆
    1. 我也很好奇這個問題,畢竟同屬一個 Bounded Context,兩個 Aggregate Root 應該可以捏在一起變成一個 Domain Service。

      刪除