許多應用由多個領域組成,用領域驅動設計(Domain-Driven Design,DDD)的語彙來說,就是多個限界上下文(bounded context)。「限界上下文」一詞告訴我們:不同領域之間應有邊界。
若領域之間沒有邊界,這些領域的類別之間的依賴就毫無限制。最終依賴會在領域之間滋生、把它們耦合在一起——這意味著領域無法再各自獨立演進,只能一起演進。那我們當初又何必把程式碼分成不同領域呢?分離程式碼成不同領域的全部理由,就是讓它們能獨立演進。這是單一職責原則的應用,只是這次談的不是單一類別的職責,而是構成一個限界上下文的一整組類別的職責:當一個限界上下文的職責改變時,我們不希望去改其他限界上下文的程式碼。
管理限界上下文(保持它們之間邊界清晰)是軟體工程的主要挑戰之一。許多開發者對「遺留軟體」的痛苦聯想,都源於邊界不清——而軟體要變成「遺留」並不需要太久。本書第一版的許多讀者問作者:如何用六角架構管理多個限界上下文?答案並不簡單,一如往常有多種做法、沒有絕對的對錯。
每個限界上下文一個六邊形?#
用六角架構面對多個限界上下文時,我們的反射動作是為每個限界上下文建立獨立的「六邊形」:每個限界上下文住在自己的六邊形裡,提供輸入 port 供互動、使用輸出 port 與外界互動。
- 理想上限界上下文之間完全不需對話,兩者之間就沒有依賴。但現實中這很少見。假設左邊的限界上下文需要呼叫右邊限界上下文的某功能:
- 用六角架構提供的元素,就為第一個限界上下文加一個輸出 port、為第二個加一個輸入 port,再建一個 adapter 實作該輸出 port、做必要對映、呼叫第二個的輸入 port。

Figure 13.1: 若每個限界上下文都實作為自己的六邊形,上下文間每條溝通線都需一組輸出 port、配接器與輸入 port
問題解決了,對嗎?紙上談兵看來很乾淨:限界上下文彼此最佳分離,依賴以 port 與 adapter 清楚結構化,新依賴需要明確加進既有 port 或新增 port,因此不太可能「意外」滲入(建立這種依賴有很多儀式要走)。
但只要把視野推到兩個以上限界上下文,就會發現這個架構擴展性很差:
- 兩個限界上下文一個依賴,需實作一個 adapter。排除循環依賴後,三個限界上下文可能要三個 adapter、四個要六個 adapter……(n 個之間的潛在非循環依賴數為
(n-1)+(n-2)+...+1)。- 每個依賴都得實作一個 adapter,附帶至少一組輸入與輸出 port,且每個 adapter 都得在兩個領域模型間對映。這很快變成開發與維護的苦差事——而一旦成了苦差事、投入大於價值,團隊就會抄捷徑避開它,得到一個乍看像六角架構、卻沒有其承諾好處的架構。

Figure 13.2: 限界上下文間的潛在依賴數隨上下文數量不成比例地增長
回到介紹六角架構的原始文章,六角架構的本意從不是用 port 與 adapter 封裝單一限界上下文,而是封裝一個應用——這個應用可包含多個限界上下文,或一個都沒有。只有在準備把各限界上下文抽取成各自的應用(即各自的(微)服務)時,才適合把每個限界上下文包進自己的六邊形——但那時我們得非常確定所放的邊界是正確的邊界、且不預期它們會改變。
結論:六角架構並未為「在同一應用中管理多個限界上下文」提供可擴展的解法,它也不必如此。 我們可改從 DDD 取經來解耦限界上下文,因為在一個六邊形之內,我們愛怎麼做都行。
解耦的限界上下文#
既然 port 與 adapter 應封裝整個應用、而非各別限界上下文,那該如何讓限界上下文彼此分離?
簡單情況下,限界上下文之間互不溝通,提供完全分離的程式路徑。此時可為每個限界上下文建立專屬的輸入與輸出 port。以「含兩個限界上下文的六角架構」為例:Web adapter 驅動應用、資料庫 adapter 被應用驅動(它們代表任何輸入與輸出 adapter——並非每個應用都是「Web + 資料庫」)。
- 輸入側:每個限界上下文透過一或多個專屬輸入 port 暴露自己的使用案例。Web adapter 認識所有輸入 port,因此能呼叫所有限界上下文的功能。也可改用一個「寬」輸入 port,讓 Web adapter 透過它把請求路由到多個限界上下文——此時上下文之間的邊界對六邊形外部是隱藏的,是否理想視情況而定。
- 輸出側:每個限界上下文定義自己對資料庫的輸出 port,獨立於其他上下文地儲存與取回資料。

Figure 13.3: 若限界上下文無需互相對話,各自可實作自己的輸入 port、呼叫自己的輸出 port
拆分輸入 port 是選用的,但作者強烈建議讓各限界上下文「儲存與取回領域資料」的輸出 port 彼此分離。若一個上下文管金融交易、另一個管使用者註冊,就該有專屬於交易資料、與專屬於註冊資料的不同輸出 port。
每個限界上下文都該有自己的持久化。若共用輸出 port 來存取資料,它們會因依賴同一資料模型而迅速強耦合。想像日後因某限界上下文的擴展性需求不同,要把它抽成獨立微服務——若它與另一上下文共用資料庫模型,抽取就極為困難(我們總不希望新微服務伸手去搆另一個應用的資料庫吧?)。
只要多個限界上下文在同一執行期執行,它們可以共用實體資料庫、參與同一資料庫交易。但在該資料庫內,不同上下文的資料之間應有清楚邊界,例如分屬不同的資料庫 schema,或至少不同的資料表。
這樣拆分輸入輸出 port 有個好處:限界上下文完全解耦,各自能獨立演進而不影響其他。但它們之所以解耦,正因為彼此不對話。那麼,如果有跨越多個限界上下文的使用案例、或一個上下文需要與另一個溝通呢?
適度耦合的限界上下文#
若所有耦合都能避免,軟體架構會輕鬆得多。但現實應用中,一個限界上下文很可能需要另一個的協助才能完成工作。
以「金錢交易」限界上下文為例:基於安全理由,我們想記錄是哪位使用者發起交易,因此需要使用者的資訊——而使用者資訊住在另一個限界上下文。但我們的上下文不必與使用者管理上下文緊密耦合:
- 只傳必要資料:與其在「交易管理」上下文中認識整個 user 物件,或許只需知道使用者的 ID。註冊上下文中的 user 是擁有眾多屬性的複雜物件,但在交易上下文中,使用者的表示也許只是包住 user ID 的薄包裝。轉帳使用案例只需接受「發起交易者的 ID」作為輸入並記錄它,不必把交易上下文耦合到 user 的所有其他細節。
- 領域事件(domain event):但若要驗證「使用者未被禁止交易」呢?可使用領域事件。每當使用者管理上下文中的使用者狀態改變,就觸發一個可被其他限界上下文接收的領域事件。交易上下文可監聽「使用者新註冊」或「使用者被封鎖」等事件,把該資訊存進自己的資料庫,供日後在轉帳使用案例中驗證使用者狀態。
- 應用服務(application service):另一種解法是引入應用服務,作為使用者管理與交易上下文之間的協調者。它實作轉帳輸入 port;被呼叫時,先向使用者管理上下文詢問使用者狀態,再把狀態傳給交易上下文提供的轉帳使用案例——實作不同,效果與用領域事件相同。
以上只是「適度」耦合限界上下文的兩個例子。若還沒讀過 DDD 的相關文獻,建議讀一讀以獲取靈感。
回到六角架構,適度耦合多個限界上下文可能長這樣:
- 在各限界上下文之上引入一個應用服務作為協調者。輸入 port 現在由這個服務實作,而非由限界上下文自己實作。
- 應用服務可呼叫輸出 port 從其他系統取得所需資訊,再呼叫限界上下文提供的一或多個領域服務。除了協調呼叫,它也充當交易邊界,讓我們能在同一資料庫交易中呼叫多個領域服務。
- 各限界上下文內的領域服務仍各用自己的資料庫輸出 port,以保持上下文間資料模型分離。我們也可決定這種分離並非必要、改用單一資料庫輸出 port(但要知道共用資料模型會導致非常緊的耦合)。
- 限界上下文之間共享一組領域事件,分別發出與監聽,以鬆耦合的方式交換資訊。

Figure 13.4: 若有跨多個限界上下文的使用案例,可引入應用服務協調、並以領域事件分享資訊
這如何幫助我打造可維護的軟體?#
管理領域之間的邊界是軟體開發最困難的部分之一。
在小型程式庫中,邊界或許並非必要,因為整個程式庫的心智模型仍裝得進我們大腦的工作記憶。但一旦程式庫達到一定規模,就該在領域之間引入邊界,好讓我們能孤立地推理每個領域;否則依賴會悄悄滲入,把程式庫變成那種令人聞之色變的「一團爛泥」。
六角架構的核心是管理應用與外界之間的邊界,這道邊界由應用提供的某些輸入 port、以及應用期望的某些輸出 port 構成。但六角架構無法幫我們管理應用內部更細粒度的邊界——在六邊形之內,我們愛怎麼做都行。若程式庫大到超出工作記憶,就該退回 DDD 或其他概念,在程式庫內部建立邊界。
下一章我們將探討一種輕量的建立邊界方法,無論是否搭配六角架構都能使用。