在套用 Clean Architecture (後續簡稱 CA)過程,最常討論的問題莫過於「Transaction 如果跨多個 repository,該怎麼處理?如果 Transaction 由 Use Case 控制會不會違反 CA 原則?如果放到 Repository 那有一些判斷的邏輯是不是也混雜進去?」
這個問題確實有點棘手,網路上也常看到各種不同的作法,決定今天重新整理一下,檢視不同的作法與考量,並透過實際的案例去驗證不同作法的優劣,使用動態語言 Ruby / 靜態語言 Golang 確保實作是真實可行
目前想到的驗證場景為
「用戶」帳號有點數,透過點數購買「商品」並成立對應的「訂單」 商品必須有足夠數量、用戶點數必須有足夠的點數才可以成立訂單 以上行為必須包含在一個 transaction 中
外部的幾種做法
1. 由 UseCase 控制,因為 Usecase 才知道所有的 Context
這部分說法是從《Clean Architecture 實作篇》第 84 頁所截取的內容,作者提到只有 Usecase 有足夠的上下文去判斷這幾個 repository 操作是否該放到同一個 transaction,範例 code 如下
|
|
作者直接使用 framework 提供的 annotation @Transaction 把所有的操作都放到同一個 Transaction 中
2. 應該放到 repository 中,有其他的需求用 callback function 補充
這是我在 Coscup 聽到的 golang 版本 CA 實作,作者一開始想要的解法是usecase 控制 lock,repository 控制 transaction
|
|
但在這個 issue 中有人補充不同的作法 Is there a race condition bug?,另一個人提到 lock 比較不像商務邏輯,比較像實作的細節,所以他會想要放到 repository 中
,而商務邏輯就用 callback function 方式傳入
|
|
重新思考
重新抽象化一下遇到的困境
|
|
重新審思 CA 的條件,有幾點規則我們應該要遵守
- repository 應該只包含儲存層的操作,不應該有商務邏輯
- usecase 不應該知道太多 repository 細節,或換個角度,當抽換 repository 時應該要很容易,不改變 usecase 的實作
根據以上的條件,來測試兩種方式
- usecase 控制 lock 與 transaction
- repository 用 callback function 注入商務邏輯 兩者實作起來的感覺
實作比較
參考程式碼 clean-architecture-transaction-issue
方法一:use case 控制 transaction 與 lock
首先在 usecase 中控制
|
|
這邊暴露了蠻多關於 DB 的細節,包含 transaction / lock / commit,但所有的商業邏輯也都在 usecase 中;
但整體傷害應該不算到太嚴重,主要是也沒有直接跟 DB 耦合,如果抽換成其他 NoSQL 頂多 transaction / commit method 留空,不會直接違反 CA 依賴方向的原則
方法二:由 repository 控制 transaction
由 repository 控制 transaction,商務邏輯用 block 方式傳入,其餘的邏輯都在 repository 中
|
|
這邊的商務邏輯只有判斷是否可以購買,其餘的 DB 操作封裝在 repository 中,這邊取名叫做 Aggregate Root 是想呼應 DDD 裡面的想法「由 Aggregate Root 操作保證底下多個 Aggregate 的一致性」,靈感是來自之前上 Teddy 的課程與 FB 討論
比較兩者差異
- usecase 乾淨程度(方法二勝):
蠻明顯作法很乾淨,把所有的 DB lock / transaction 都封裝得一乾二凈,而商務邏輯還是保留在 usecase 中呼叫 - 擴充性(方法一勝):
如果未來商務邏輯變得更複雜,有可能 DB 查完資料要增加新的比對,例如「高級會員有更多的折價」、「特殊商品買 10 送 1」等等,方法二需要不斷的增加 function 傳入,而方法一因為都是在 usecase 操作,所以直接增加就好,相對好擴充 - 可測試性(方法一勝):
我覺得兩個作法最大的差異是
可測試性
,測試可以用 integration test 連 DB 一起測 / 或是 unit test 把其他相依的物件 mock 掉; integration 測試部分兩者沒太多差異 (參考 main_spec.rb);
但是 unit test 就有很大的差異 (參考 usecase.rb),因為方法一的 repository 方法都是 public 讓 usecase 呼叫,所以很好 mock,但方法二目前我想不到比較好的方法測試,因為 callback function (block) 是在 repo 中呼叫,那我直接 mock 掉不就什麼都測不了了 ?!!
結論
即使 usecase 會比較混亂一些,我還是選擇方法一
(golang 版本待補)