如果能光看程式碼就辨認出架構,不是很好嗎?本章將檢視幾種組織程式碼的方式,並介紹一個能直接反映六角架構(Hexagonal Architecture)的、富表達力的套件結構。
全新專案中,我們最先想做對的事往往是套件結構(package structure)。一開始我們設計出漂亮的結構,打算用到底。但專案一忙亂,就會發現許多地方的套件結構只是「亂碼一團程式碼的漂亮門面」——某套件裡的類別匯入了它本不該匯入的其他套件類別。
本章以前言介紹過的範例應用 BuckPal 為例,特別聚焦其中的「轉帳(Send money)」使用案例:讓使用者把錢從自己的帳戶轉到另一個帳戶。
依層組織(Organizing by Layer)#
第一種方式是依層組織程式碼——Web、領域、持久化各有專屬套件。即使我們已在此套用依賴反轉原則(在 domain 套件中引入 AccountRepository 介面,並在 persistence 套件中實作它,讓依賴只指向領域程式碼),這個結構仍至少有三個問題:
- 功能切片之間沒有套件邊界:若新增管理使用者的功能,就得往
web加UserController、往domain加UserService/UserRepository/User、往persistence加UserRepositoryImpl。缺乏進一步結構時,這很快會變成一團類別,讓本應無關的功能之間產生不想要的副作用。 - 看不出應用提供哪些使用案例:你能光看
AccountService或AccountController就說出它們實作了哪些使用案例嗎?想找某功能,得先猜哪個服務實作了它,再到該服務裡翻找對應方法。 - 看不出目標架構:我們只能猜測這遵循六角架構,再翻
web與persistence套件找出 adapter。但無法一眼看出 Web adapter 呼叫了哪些功能、持久化 adapter 對領域層提供了哪些功能——輸入與輸出 port 都藏在程式碼裡。
依功能組織(Organizing by feature)#
下一種方式是依功能組織:把所有與帳戶相關的程式碼放進高層套件 account,並移除分層套件。每組新功能都會在 account 旁獲得自己的高層套件,並可透過 package-private 可見性為不該被外部存取的類別建立套件邊界,避免功能之間不想要的依賴。
我們也把 AccountService 改名為 SendMoneyService 以收窄其職責。現在光看類別名稱就知道它實作了「轉帳」使用案例。讓應用功能在程式碼中清晰可見,正是 Robert Martin 所稱的「尖叫的架構(Screaming Architecture)」——它對著我們吶喊自己的意圖。
然而依功能組織反而讓架構比依層組織更難辨認:沒有套件名稱可標示 adapter,也仍看不到輸入/輸出 port。更糟的是,即使我們已反轉領域與持久化程式碼間的依賴(
SendMoneyService只認識AccountRepository介面而非其實作),卻無法用 package-private 可見性保護領域程式碼,避免它意外依賴持久化程式碼。
富表達力的套件結構#
我們希望能指著架構圖中的某個方框,立刻知道哪段程式碼負責它。六角架構的主要元素有:實體、使用案例、輸入與輸出 port、輸入與輸出(驅動與被驅動)adapter。把它們對映到套件結構:
- 最高層是
adapter與application兩個套件。 adapter套件:容納呼叫應用輸入 port 的輸入 adapter,以及為應用輸出 port 提供實作的輸出 adapter。本例是簡單 Web 應用,web與persistenceadapter 各有子套件。application套件:容納「六邊形」,也就是應用程式碼本身。其下又有:domain套件:領域模型——領域實體與實作輸入 port、協調領域實體的領域服務。port套件:port 介面。
common套件:跨整個程式庫共用的少量程式碼。
把 adapter 程式碼放進各自套件的好處是:需要時能輕鬆抽換 adapter。假設原本針對簡單的鍵值資料庫實作持久化 adapter,後來存取模式改變、改用 SQL 資料庫更合適,只需在新的 adapter 套件中實作所有相關輸出 port,再移除舊套件即可。
為什麼 port 放在 application 套件內而非與其並列?因為 port 是我們套用依賴反轉原則的方式,由應用定義以對外溝通。把 port 套件放進 application 套件,正表達了「應用擁有這些 port」。
這個套件結構是對抗所謂「架構/程式碼落差(architecture/code gap,或 model/code gap)」的有力武器。這個術語指的是:多數專案中,架構只是抽象概念、無法直接對映到程式碼;隨時間推移,若套件結構不反映架構,程式碼就會越來越偏離目標架構。它同時也促進對架構的主動思考——我們必須主動決定每段程式碼該放進哪個套件。
那麼,這麼多套件是否代表「所有東西都得 public 才能跨套件存取」?
- adapter 套件:不必。其中類別都可以是 package-private,因為外界只透過位於
application套件的 port 介面呼叫它們,因此應用層不會意外依賴 adapter 類別。 application套件:部分類別確實需要 public。port 必須 public(設計上要讓 adapter 存取);領域模型必須 public(供服務、乃至 adapter 存取);服務則不需 public,因為它們可藏在輸入 port 介面之後。
像這樣細粒度的套件結構,確實會要求我們把一些在粗粒度結構中本可 package-private 的類別改為 public。第 12 章會探討如何捕捉對這些 public 類別的不當存取。
要注意本例只包含一個領域(處理帳戶交易)。許多應用會橫跨多個領域,而六角架構本身並未告訴我們如何管理多個領域。我們當然可以把每個領域的程式碼放進 domain 下各自的子套件來分隔;但若想連 port 與 adapter 都按領域拆分,要小心——這很快會變成對映的惡夢,詳見第 13 章。
如同任何結構,維持這套套件結構需要紀律。也會有套件結構就是不合用、不得不擴大架構/程式碼落差的時候。世上沒有完美——但有了富表達力的套件結構,至少能縮小程式碼與架構之間的落差。
依賴注入的角色#
上述套件結構已朝整潔架構邁進一大步,但這種架構有個關鍵要求:應用層不得依賴輸入與輸出 adapter。
- 輸入 adapter(如 Web adapter):容易處理。因為控制流方向與依賴方向一致——adapter 直接呼叫應用層的服務。為清楚標示應用的進入點,我們會把實際服務藏在輸入 port 介面之後。
- 輸出 adapter(如持久化 adapter):必須運用依賴反轉原則,讓依賴方向與控制流方向相反。做法如前:在應用層建立一個介面(即 port),由 adapter 中的類別實作;應用層再呼叫此 port 介面來使用 adapter 的功能。

Figure 4.1: Web controller 呼叫由服務實作的輸入 port,服務再呼叫由配接器實作的輸出 port
那麼,是誰提供實作 port 介面的實際物件給應用?我們不想在應用層手動實例化 port,以免引入對 adapter 的依賴——這就是**依賴注入(dependency injection)**登場之處。我們引入一個中立元件,它依賴所有層,負責實例化構成架構的多數類別。
以前述例子說明:
- 中立的依賴注入元件會建立
SendMoneyController、SendMoneyService、AccountPersistenceAdapter的實例。 SendMoneyController需要一個SendMoneyUseCase,依賴注入機制便在建構時給它一個SendMoneyService實例——controller 只認識介面,並不知道實際拿到的是SendMoneyService。- 同理,建構
SendMoneyService時,機制會以UpdateAccountStatePort介面的身分注入一個AccountPersistenceAdapter實例——服務從不知道介面背後的實際類別。
第 10 章會以 Spring 框架為例,更深入討論如何初始化應用。
這如何幫助我打造可維護的軟體?#
我們看到了一套讓實際程式碼結構盡可能貼近目標架構的套件結構。如今在程式碼中尋找某個架構元素,只需沿著架構圖中方框的名稱往套件結構下層導航即可,這對溝通、開發與維護都大有幫助。
接下來幾章,我們將透過實作應用層的使用案例、Web adapter 與持久化 adapter,看到這套套件結構與依賴注入的實際運作。