前幾章討論了 Web、應用、領域與持久化各層對實作使用案例的貢獻,卻幾乎沒碰那個惱人又無所不在的主題:各層模型之間的對映(mapping)。你大概曾為「要不要在兩層共用同一模型以省去寫對映器」而爭論過:

  • 支持對映的開發者:「不在層間對映,就得在兩層共用同一模型,這會讓層緊密耦合!」
  • 反對對映的開發者:「但層間對映會產生大量樣板程式碼,對許多只做 CRUD、各層本來就共用同一模型的使用案例來說是殺雞用牛刀!」

如同這類爭論,雙方各有道理。本章討論幾種對映策略及其優缺點,幫這兩位開發者做決定。

「不對映(No Mapping)」策略#

第一種策略其實就是完全不對映。以 BuckPal 的轉帳使用案例為例:

  • Web 層的 controller 呼叫 SendMoneyUseCase 介面,該介面以 Account 物件為參數,因此 Web 與應用層都需存取 Account——兩者共用同一模型。
  • 應用另一端的持久化層與應用層之間也是同樣關係。
  • 既然所有層共用同一模型,就不需要在它們之間實作對映。

Figure 9.1: 若 port 介面以領域模型作為輸入輸出模型,便可選擇不在層間對映

但這個設計有什麼後果?

Web 與持久化層可能對模型有特殊需求:Web 層若以 REST 暴露模型,類別可能需要定義 JSON 序列化的標註;持久化層若用 ORM,可能需要定義資料庫對映的標註,甚至要求類別遵守某種契約。在此策略下,這些特殊需求全得在 Account 領域模型類別處理——即使領域與應用層根本不在意。這違反單一職責原則Account 會因 Web、應用、持久化層的理由而被迫改變。此外各層還可能在 Account 上要求各自的客製欄位,導致領域模型碎片化。

那麼是否永遠不該用「不對映」?當然不是。即使感覺有點髒,它在某些情況下完全有效:

  • 考慮一個簡單的 CRUD 使用案例,真的需要把同樣的欄位從 Web 模型對映到領域模型、再對映到持久化模型嗎?多半不需要。
  • 領域模型上那一兩個 JSON 或 ORM 標註真的困擾我們嗎?即使持久化層變動時得改個標註,那又如何?

這也給前述兩位開發者一個教訓:即使過去選定了某種策略,日後仍能更換。許多使用案例一開始是簡單 CRUD,後來才長成具豐富行為與驗證、值得更貴對映策略的完整業務使用案例;也可能永遠停留在 CRUD——那我們就慶幸沒在別的策略上多投資。

「雙向(Two-Way)」對映策略#

每一層都有自己模型的策略,作者稱為「雙向」對映:

  • 每層擁有自己的模型,其結構可與領域模型完全不同。
  • Web 層把 Web 模型對映成輸入 port 期望的輸入模型,也把輸入 port 回傳的領域物件對映回 Web 模型。
  • 持久化層在「輸出 port 使用的領域模型」與「持久化模型」之間做類似對映。
  • 兩層都進行雙向對映,故名「雙向」。

Figure 9.2: 各配接器擁有自己的模型,負責在自己的模型與領域模型之間雙向對映

優點:

  • 各層能在不影響其他層的前提下修改自己的模型(只要內容不變)。Web 模型可採最利於呈現資料的結構,領域模型可採最利於實作使用案例的結構,持久化模型可採 ORM 持久化所需的結構。
  • 帶來乾淨的領域模型,不被 Web 或持久化的關切弄髒,不含 JSON 或 ORM 標註,滿足單一職責原則。
  • 繼「不對映」之後,它概念上是最簡單的策略:對映責任明確——外層/adapter 對映進內層模型再對映回來,內層只認識自己的模型,能專注領域邏輯。

缺點:

  • 通常產生大量樣板程式碼。即使用對映框架減少程式碼,實作對映仍占去不少時間,部分原因是除錯對映邏輯很痛苦——尤其當框架把內部運作藏在泛型程式碼與反射之後。
  • 輸入與輸出 port 以領域物件為輸入參數與回傳值,adapter 雖把它們對映成自己的模型,但這仍比下面要談的「完整」策略(引入專屬「傳輸模型」)造成更多層間耦合。

「雙向」對映同樣不是銀彈。但在許多專案中,它被奉為必須在整個程式庫遵守的神聖律法,連最簡單的 CRUD 使用案例也不放過,這不必要地拖慢了開發。沒有任何單一對映策略該被視為鐵律——應為每個使用案例個別決定。

「完整(Full)」對映策略#

「完整」對映為每個操作引入獨立的輸入與輸出模型:

  • 不再用領域模型跨越層邊界溝通,而是用每個操作專屬的模型,例如作為 SendMoneyUseCase port 輸入模型的 SendMoneyCommand。這類模型可稱為「command」「request」等。
  • Web 層負責把輸入對映成應用層的 command 物件。這種 command 讓應用層的介面非常明確、少有詮釋空間:每個使用案例有自己的 command、自己的欄位與驗證,不必猜哪些欄位該填、哪些該留空(否則會觸發當前使用案例不想要的驗證)。
  • 應用層接著把 command 物件對映成它修改領域模型所需的東西。

Figure 9.3: 每個操作各需自己的模型,Web 配接器與應用層各自對映成該操作期望的模型

把一層對映成許多不同 command,自然比「單一 Web 模型對領域模型」需要更多對映程式碼。但這種對映只服務一個使用案例,遠比「得處理眾多使用案例需求」的對映更易實作與維護。

作者不主張把它當全域模式。它最能發揮優勢之處是在 Web 層(或其他輸入 adapter)與應用層之間,清楚標示應用中修改狀態的使用案例;而不會用在應用與持久化層之間,因對映開銷太大。通常作者會把它限制在操作的「輸入」模型,輸出則直接用領域物件(例如 SendMoneyUseCase 回傳帶有更新餘額的 Account)。這說明對映策略可以、也應該混用

「單向(One-Way)」對映策略#

「單向」策略有另一組優缺點:

  • 所有層的模型都實作同一個介面,此介面藉由 getter 方法封裝領域模型的狀態。
  • 領域模型本身可實作豐富行為,供應用層的服務存取。若想把領域物件傳給外層,不需對映,因為它已實作輸入與輸出 port 期望的狀態介面。外層再決定是直接使用此介面、還是對映成自己的模型;由於狀態介面不暴露修改行為,外層無法意外修改領域物件的狀態
  • 外層傳進應用層的物件也實作此狀態介面,應用層必須把它對映成真正的領域模型才能取得其行為。

Figure 9.4: 領域模型與配接器模型實作同一「狀態」介面,每層只需單向對映收到的物件

這個對映與 DDD 的「工廠(factory)」概念契合:DDD 的工廠負責從某狀態重建領域物件,正是我們在做的事。對映責任明確:某層收到他層物件時,就對映成自己能用的東西——因此每層只對映一個方向,故名「單向」。但由於對映分散在各層,它概念上比其他策略更難。

此策略在「各層模型相似」時最能發揮威力。例如唯讀操作中,Web 層可能完全不需對映成自己的模型,因為狀態介面已提供它所需的全部資訊。

何時用哪種策略?#

這就是那個價值百萬的問題了,而答案一如往常令人不滿意:看情況。

既然每種策略各有優缺點,就該抗拒「為整個程式庫定一條鐵律」的衝動。這違反直覺——在同一程式庫混用模式感覺不整潔。但若只為滿足整潔感,明知某模式不是這項工作的最佳解卻硬用,那就是不負責任。何況軟體會演進:昨天最適合的策略今天未必最適合。與其一開始就鎖死某策略並死守到底,不如先用簡單策略以快速演進程式碼,日後再轉向更複雜、更能解耦各層的策略。

要決定何時用哪種,團隊需要約定一組準則,回答「什麼情況下哪種策略是首選」,以及「為什麼是首選」(以便日後評估這些理由是否仍成立)。例如可為修改型使用案例與查詢定不同準則,也可在「Web↔ 應用」與「應用 ↔ 持久化」之間用不同策略。準則可能像這樣:

  • 修改型使用案例,Web↔ 應用之間首選「完整」對映,以解耦各使用案例,獲得清晰的逐使用案例驗證規則,且不必處理當前用不到的欄位。
  • 修改型使用案例,應用 ↔ 持久化之間首選「不對映」,以無對映開銷地快速演進;一旦應用層得處理持久化議題,就轉向「雙向」,把持久化議題留在持久化層。
  • 查詢,Web↔ 應用及應用 ↔ 持久化之間皆首選「不對映」以快速演進;一旦應用層得處理 Web 或持久化議題,就在對應的兩層之間轉向「雙向」。

要成功套用這類準則,它們必須存在於開發者腦中。因此準則應作為團隊的共同努力,持續討論與修訂。

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

輸入與輸出 port 是各層之間的守門員:它們定義各層如何溝通、如何跨層對映模型。

有了每個使用案例專屬的狹窄 port,我們就能為不同使用案例選擇不同的對映策略,甚至隨時間演進而不影響其他使用案例,從而在特定時刻為特定情況選出最佳策略。

為每個使用案例選擇不同對映策略,比「全部統一一種」更難、也需要更多溝通;但只要對映準則為大家所知,它就會回報團隊一個「只做它該做的事、且更易維護」的程式庫。

既然已知道應用由哪些元件構成、它們如何溝通,接下來就能探討如何把這些元件組裝成一個可運作的應用。