第 2 章批評傳統分層架構助長「以資料庫為中心」的設計,因為一切最終都依賴持久化層。本章要看的是:如何把持久化層做成應用層的「插件(plugin)」,以反轉這個依賴。
依賴反轉#
我們不再談「持久化層」,而是談一個為領域服務提供持久化功能的持久化 adapter。領域服務呼叫 port 介面來存取持久化功能,這些 port 由持久化 adapter 類別實作,由它執行實際的持久化工作、負責與資料庫溝通。

Figure 7.1: 核心的服務透過 port 存取持久化配接器
在六角架構的語彙中,持久化 adapter 是被驅動(driven)/輸出(outgoing) adapter,因為它被應用呼叫,而非反之。
port 實際上是領域服務與持久化程式碼之間的一層間接。加入這層間接,是為了讓領域程式碼能在不考慮持久化問題(亦即不存在對持久化層的程式碼依賴)的情況下演進——持久化程式碼的重構不會導致核心程式碼變動。
當然,執行期應用核心仍依賴持久化 adapter:若我們改動持久化層並引入 bug,仍可能弄壞核心功能。但只要 port 的契約被滿足,我們就能在持久化 adapter 中為所欲為而不影響核心。
持久化配接器的職責#
持久化 adapter 通常做這些事:
- 接收輸入。
- 把輸入對映成資料庫格式。
- 把輸入送進資料庫。
- 把資料庫輸出對映成應用格式。
- 回傳輸出。
具體而言:
- 持久化 adapter 透過 port 介面接收輸入。輸入模型可以是領域實體,也可以是介面指定的、專用於某個資料庫操作的物件。
- 接著把輸入模型對映成它能用來修改或查詢資料庫的格式。Java 專案常用 Java Persistence API(JPA),因此可能對映成反映資料表結構的 JPA entity 物件。視情境而定,這種對映有時費工而收益甚微,第 9 章會談「不對映」的策略。
- 也可以不用 JPA 或其他 ORM,改用其他技術:把輸入模型對映成純 SQL 敘述送進資料庫,或把資料序列化成檔案再讀回。
- 之後查詢資料庫並接收結果,最後把資料庫回應對映成 port 期望的輸出模型回傳。
關鍵在於:輸入與輸出模型位於應用核心,而非持久化 adapter 內部,如此持久化 adapter 的變動才不會影響核心,依賴方向也才正確。除此之外,這些職責與傳統持久化層其實大同小異——但用本章方式實作,會引出一些我們因太習慣傳統做法而從未思考過的問題。
切分 Port 介面#
實作服務時會冒出一個問題:如何切分定義「應用核心可用的資料庫操作」的 port 介面?
常見做法是為某個實體建立單一 repository 介面,提供其所有資料庫操作。但這樣一來,每個依賴資料庫操作的服務都得依賴這個「寬」port 介面,即使它只用到其中一個方法——程式庫中於是充斥不必要的依賴。
依賴你並不需要的方法,會讓程式更難理解與測試。想像為
RegisterAccountService寫單元測試:該為AccountRepository介面的哪些方法建立 mock?得先查出服務實際呼叫了哪些方法。而若只 mock 了介面的一部分,下一個動這個測試的人可能預期整個介面都被 mock,於是踩坑、又得重新研究。如 Robert C. Martin 所言:「依賴某個帶著你不需要的包袱的東西,會帶給你意料之外的麻煩。」

Figure 7.2: 把所有資料庫操作集中於單一輸出 port 介面,使所有服務依賴用不到的方法
**介面隔離原則(Interface Segregation Principle)**正是答案:寬介面應拆成具體的小介面,讓客戶端只認識它需要的方法。套用到輸出 port 後:
- 每個服務只依賴它真正需要的方法。
- port 的名稱清楚表達其用途。
- 測試時不必再煩惱該 mock 哪些方法,因為多數時候每個 port 只有一個方法。

Figure 7.3: 套用介面隔離原則移除不必要的依賴,並讓既有依賴更清晰可見
如此狹窄的 port 讓寫程式變成「隨插即用」的體驗:處理某個服務時,只「插入」需要的 port,沒有包袱要扛。
「一個 port 一個方法」並非處處適用。某些資料庫操作群組高度內聚、經常一起使用,這時把它們綁進單一介面反而合理。
切分持久化配接器#
前面看到的是「單一持久化 adapter 類別實作所有持久化 port」,但並沒有規則禁止我們建立多個——只要所有持久化 port 都被實作即可。
- 每個聚合(aggregate)一個 adapter:可為「一組需要持久化操作的領域實體」(DDD 語彙的 aggregate)各建一個持久化 adapter,讓 adapter 自動沿著領域的接縫切分。
- 依技術切分:若想用 JPA(或其他 ORM)實作部分 port、用純 SQL 實作另一些以求效能,可建一個 JPA adapter 與一個純 SQL adapter,各實作 port 的子集。

Figure 7.4: 我們可建立多個持久化配接器,每個聚合一個
領域程式碼並不在意最終由哪個類別履行 port 定義的契約。只要所有 port 都被實作,我們在持久化層就能隨意發揮。
「每個聚合一個 adapter」也是日後分離多個有界脈絡(bounded context)持久化需求的良好基礎。例如一段時間後辨識出負責計費(billing)使用案例的有界脈絡,就讓每個有界脈絡擁有自己的持久化 adapter。「有界脈絡」一詞意味著邊界:帳戶脈絡的服務不可存取計費脈絡的持久化 adapter,反之亦然。脈絡間若需互通,可呼叫彼此的領域服務,或引入應用服務作為協調者(詳見第 13 章)。

Figure 7.5: 若要在限界上下文間建立硬邊界,每個上下文應有自己的持久化配接器
Spring Data JPA 範例#
來看實作 AccountPersistenceAdapter 的程式碼,它要負責把帳戶存入/載入資料庫。
我們用 Spring Data JPA 與資料庫溝通,因此需要 @Entity 標註的類別來表示帳戶的資料庫狀態:
- 現階段帳戶狀態僅含一個 ID,日後可再加欄位(如 user ID)。
- 更值得注意的是
ActivityJpaEntity,它包含某帳戶的所有 activity。我們本可用 JPA 的@ManyToOne或@OneToMany標註兩者的關聯,但暫時略過,因為它會為資料庫查詢引入副作用。 - 接著用 Spring Data 建立 repository 介面,開箱即得基本的 CRUD 功能,以及載入特定 activity 的自訂查詢。Spring Boot 會自動找到這些 repository,Spring Data 則施展魔法、在介面背後提供真正與資料庫溝通的實作。
老實說,現階段用比 JPA 更簡單的 ORM 來實作持久化 adapter 可能更省事,但我們仍選用 JPA,因為預期未來會需要它。選 JPA 是因為大家都用它;但開發幾個月後,你可能會詛咒 eager/lazy loading 與快取功能,渴望更簡單的東西。JPA 是好工具,但許多問題其實有更簡單的解(可考慮 Spring Data JDBC 或 jOOQ)。
有了 JPA entity 與 repository,就能實作持久化 adapter。它實作應用所需的兩個 port——LoadAccountPort 與 UpdateAccountStatePort:
- 載入帳戶:先從
AccountRepository載入帳戶,再透過ActivityRepository載入該帳戶某時間窗口的 activity。為建出有效的Account領域實體,還需要 activity 窗口開始前的餘額,因此從資料庫取得該帳戶所有提款與存款的總和。最後把這些資料對映成Account領域實體回傳。 - 更新帳戶狀態:遍歷
Account實體的所有 activity,檢查它們是否有 ID;沒有 ID 的是新 activity,透過ActivityRepository持久化。
此情境中,Account/Activity 領域模型與 AccountJpaEntity/ActivityJpaEntity 資料庫模型之間存在雙向對映。為什麼要費這個工,不直接把 JPA 標註搬到 Account 與 Activity 上、當作 entity 存進資料庫?
這種「不對映」策略可能是有效選擇(第 9 章詳述),但 JPA 會迫使我們在領域模型上妥協。例如 JPA 要求 entity 有無參數建構子;又或在持久化層,「多對一」關係從效能角度合理,但在領域模型我們卻想要反向的關係。因此,若想打造不向持久化層妥協的充血領域模型,就必須在領域模型與持久化模型之間對映。
資料庫交易怎麼辦?#
我們還沒談交易。交易邊界該放哪?
- 一個交易應涵蓋某使用案例內對資料庫的所有寫入操作,確保其中之一失敗時所有操作能一起回滾。
- 持久化 adapter 並不知道同一使用案例還包含哪些其他資料庫操作,因此無法決定何時開啟與關閉交易。這個責任必須委派給協調持久化 adapter 呼叫的服務。
在 Java 與 Spring 中,最簡單的做法是在領域服務類別上加 @Transactional 標註,讓 Spring 用交易包裹所有 public 方法。
這如何幫助我打造可維護的軟體?#
把持久化 adapter 做成領域程式碼的插件,使領域程式碼擺脫持久化細節,讓我們能打造充血的領域模型。
- 使用狹窄的 port 介面,我們能彈性地以不同方式實作不同 port,甚至採用不同的持久化技術,而應用渾然不覺。
- 只要遵守 port 契約,我們甚至能整個抽換持久化層。
雖然整個抽換持久化層的機率通常很低,但擁有專屬持久化 port 仍然值得,因為它提升了可測試性——例如可輕鬆實作一個記憶體內持久化 adapter 供測試使用。
我們已建好領域模型與一些 adapter,下一章來看如何測試它們是否真如預期運作。