組裝層是應用程式把 port 插上 adapter 的地方——port 是 domain 對外宣告的介面、adapter 是介面的具體實作、依賴注入 是把兩者接起來的機制。組裝發生在三個位置:依賴注入的組裝集中在 composition root(組裝根、依賴注入唯一的組裝起點),路由表以單點註冊靠攏它,UI 事件的接線散佈在各畫面。位置不同、同屬組裝層的責任:把功能單元接到使用者按得到的入口上。使用者入口之外、機器觸發的組裝位置(事件訂閱、排程任務)有同一種證言缺口、判準同樣適用;本章聚焦使用者入口。

本模組(DDD 領域驅動設計指南)的源頭句「讓違反規則的路徑走不通」守的是領域內部的規則;本章守它的對偶面——讓正確的路徑確實走得通。domain 模型全對、系統不可用,是分層架構特有的失效形態:每一層各自正確、層與層之間沒接上。

一個專案的五個問題

這個失效形態先用一個完整案例展開——本章後面的每個判準與工具都從它歸納而來。一個專案的單元測試全數通過、實機測試找出五個問題(完整記錄與 Flutter / Riverpod 的實作細節見 測試全綠、功能失聯):

問題現象斷裂點
注入項佔位注入項停在拋「requires override」的佔位、production 一解析就連鎖報錯DI 組裝
路由佔位入口按鈕彈出「功能開發中」、路由表把入口指向佔位頁——功能本體已全部完成路由表
空 callback按鈕的事件處理是空實作、點擊沒有任何反應UI 事件接線
初始化順序框架初始化與例外攔截散在不同範圍、非同步例外可能逃出攔截平台語意
資料庫開啟失敗一條資料庫設定指令在目標平台的執行語意不同、開啟直接失敗、持久化全面不可用平台語意

前三個是同一種形狀:功能單元全部存在、對應的測試全部通過,斷的是「把功能接到入口」的那一段——依賴注入沒接上真實實作、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀:程式碼在開發機的測試環境(host 環境)行為正確、在目標平台的語意下失效。兩種形狀的對策不同,平台差異的處置收在「邊界」一節;本章的主線是前三個。

分層的承諾與它的影子

分層架構(以及 ports & adapters、即六角形架構)的核心承諾是 domain 不依賴框架、可以脫離 infrastructure 單獨測試。兌現承諾的機制是 測試 seam:seam 是不修改程式本體就能替換其中一段行為的位置——port 加上注入點、測試時把真實依賴換成 mock。seam 工作得越好、domain 的測試越乾淨,這是 DDD 教材反覆強調的部分。

承諾有一道影子、而且跟承諾出自同一個機制:mock 換掉的正是組裝。本章用「證言」指一個測試通過時能證明的事——證言的範圍由測試的執行環境決定、mock 環境的綠燈證明的是 mock 環境下的行為。於是「組裝有沒有完成」這件事、在以 seam 為基礎的測試套件裡無從證明。兩類測試各自的證言範圍:

測試執行環境證明什麼對組裝的證言
行為測試mock/override 注入功能邏輯正確
接線測試組裝路徑零 override、真實路由表與依賴port 插上了 adapter測試層唯一來源

行為測試維持 mock 注入、驗的是功能邏輯。上述案例的測試全落在這一類、全綠是誠實的:功能邏輯確實正確、綠燈在它的證言範圍內沒有失真——失真發生在把這個綠燈讀成「功能可用」的那一步。

接線測試補的正是缺掉的那份證言、下一節展開。

接線測試:組裝證言的來源

接線測試(wiring test)在組裝路徑零 override 的環境驗證組裝完成:逐一解析每個注入項、用真實路由表逐條導航、對每顆按鈕實際觸發並斷言導航發生。它只驗「插上了沒」、功能邏輯留給行為測試——兩類測試各守自己的證言範圍、彼此替代不了。

它在既有測試光譜上的位置,可以從兩個熟悉的參照點定出來。第一個參照點是端對端測試:E2E 在目標平台同時驗行為與接線、代價是裝置需求與執行不穩定;接線測試把驗證範圍收窄到「接上了沒」、換取在 host 環境快速且確定地執行。第二個參照點是 DI 容器的自驗證:部分容器提供「啟動時逐一解析全部註冊項」的機制、專門攔沒被提供實作的註冊項;接線測試是這個機制的推廣——解析之外、把路由與 UI callback 的接線納進同一種證言。

零 override 有明確的邊界:它指的是組裝路徑——domain 到入口之間不替換任何一段。最外圈的 infrastructure(遠端服務這類)可以在邊界替換、不影響組裝的證言。單機的 local-first 應用做得到全量零 override;有遠端依賴的專案、零 override 的範圍就是組裝路徑本身。

該專案的修復批次先補了這批接線測試、對目標行為斷言、修復前確定性紅燈,修復以測試變綠為驗收點。日常的判讀分界在專案層級:override 出現在個別測試裡是正當用法;整個專案找不到任何一個「無 override 環境解析注入項」的測試、組裝層才算沒有證言。

佔位:合法的中間態、最靜默的失效

佔位是「先立介面、實作後補」的合法開發中間態:指向「開發中」頁的路由、空的事件 callback、拋「requires override」的注入項。先把介面立起來、實作後補,這個節奏本身沒有問題;問題在佔位的失效形態。

不變式的強制層次 記過文件層約束的失效是靜默的;佔位的失效比文件層再隱蔽一級——它讓測試綠燈。型別層看佔位是合法構件、行為測試的 override 讓它永遠沒被觸發、只有零 override 的解析會撞上它。文件層約束至少留下「規則寫在那裡、沒人遵守」的對照證據;佔位連對照證據都沒有、驗收讀到的只有通過。

佔位因此需要一個獨立的攔截點。這個案例的處置是把攔截放進發版前置檢查:出現過的三種形態(佔位路由、佔位注入項、空 callback)都是靜態可掃描的——佔位頁的型別名、「requires override」的拋出語句、空函式體都 grep 得到——掃到即警告、由人判斷是刻意中間態還是漏網。

回傳假值的佔位是掃描的死角:hardcoded 假資料、回空集合的 stub,在掃描與接線測試眼中都是正常構件——注入項解析得了、導航也會發生。這一類要靠行為斷言(對回傳內容驗證、假資料過不了斷言)或發版前的實機冒煙走查攔截;走查的三類敏感點在發版層一段收束、完整清單的住址在 work-log 記錄。同族的另一個死角是「插錯」:注入項插上的是真實作、只是不該是它(例如測試用實作被註冊進 production 組態)——解析成功、接線測試綠燈。接線測試的保證天花板在這裡很清楚:它證明「有插、插得起來」;插上的實作對不對、屬行為與整合測試的證言。

可達性作為組裝層的不變式

「use case 描述的每個入口在 production 環境可達」是一條 不變式。不變式的落點判準(完整推導見 不變式的強制層次)把約束分成文件、型別、執行三層:寫在文件裡靠人記得並自律、做進型別讓錯誤的寫法在編譯期被擋下、放在執行層讓違反在 runtime 當場被拒絕。把判準套在可達性上、答案是多層都有位置、單獨任何一層都不夠:

文件層:use case 的成功保證補上可達性條款——路由指向真實頁面、callback 已接線、注入項在無 override 環境可解析。這層的作用是把組裝責任寫進驗收範圍:它定義「什麼叫完成」。文件層失效靜默、單獨存在時只是願望;怎麼把這層寫得可檢核、「use case 的完成定義」一節展開。

型別層:組裝完成性用型別表達的空間有限、而且空間大小由生態決定。在編譯期生成組裝碼的 DI 生態、缺實作是編譯錯誤、型別層接得住這條不變式;runtime 解析的容器把「這個注入項沒人提供實作」推遲到執行期、編譯器看不見。路由表同理:以字串鍵查表的路由生態、斷鏈也是執行期才暴露。用 runtime 容器與字串路由的專案、型別層在這條不變式上是輔助而非主力。

執行層:接線測試——違反當場紅燈、失效點集中。刻意不可達的入口(feature flag 關閉、分階段發布)不算違反:以旗標開啟的組態跑接線測試、或明列豁免清單——豁免要有記錄、讓「刻意」與「遺漏」在清單上分得開。

發版層(對應強制層次一章補充的 CI 檢查層):佔位掃描加實機冒煙走查。前者警示靜態可掃的佔位、由人判讀;後者是發版前在目標平台人工走一輪的清單——啟動健康、use case 主路徑走查、平台敏感點——攔 host 測試構不到的平台語意差異。

use case 的完成定義

組裝斷裂的上游在設計文件——這一節是文件層那一列的展開:可達性條款要寫得進 use case、use case 本身先要能回答裝配問題。

修復批次拿五個問題反向追溯提案與 use case 文件、確認每個問題在文件裡的對應條目長什麼樣。結果分成兩型:

問題設計文件對應缺口型態
注入項佔位提案定義了完整的功能方法與 UI 形態有能力定義、驗收不含可達性
路由佔位use case 寫了「使用者點擊按鈕」有行為描述、接線無人認領
空 callbackuse case 寫了操作步驟callback 實作不在驗收內
兩個平台問題全文無對應平台層細節、設計文件構不到

追溯出一個一致的形狀:提案思考「能力」(系統要有哪些功能)、use case 思考「行為」(使用者做什麼、系統回應什麼),兩者之間的「裝配」——把能力組起來、給行為一個入口的責任——沒有歸屬。三個組裝斷裂全部落在這條縫裡。

把縫補起來的做法是讓 use case 撰寫承擔對提案的壓力測試:寫 use case 時逐一回答四個檢核問句、填不出來的問句直接指出對應的設計缺口。問句從五個問題歸納而來、是起點集、新的斷裂形態出現時往上加。

名詞可定位——文中每個畫面、按鈕、服務,能填出「位於哪個畫面、經哪條路由、由誰供給」。填不出來代表提案只設計了能力、沒設計裝配:案例裡注入項佔位對應的功能、提案寫滿了功能方法與 UI 形態、按鈕跟頁面由誰放上去沒有一個字。

路徑連通——步驟一的入口能從應用程式啟動畫面一路追到。追不出這條路、入口接線就無人認領:路由佔位問題的 use case 描述了使用者「點擊按鈕」、按鈕到路由到頁面這條鏈的歸屬、文件裡翻不到。直達入口(web 的 URL、深連結、外部喚起)另立一類:它們繞過啟動鏈、每個直達入口各自追一條可達性。

狀態完備——涉及的每個畫面、狀態機的每個狀態(空、正常、錯誤、權限與登入態)下入口可見性都有答案。這一問附帶一個操作型式:前置條件反向展開。use case 的前置條件每一條都隱含一個「不滿足時會怎樣」的狀態分支。案例裡一條前置條件寫「存在至少一筆資料」——實機上使用者的資料是空的、對應按鈕只存在於正常狀態分支、使用者的回報是「功能不見了」。把前置條件當成狀態枚舉的線索用、空狀態的畫面在設計期就被逼著給出答案;它與 狀態轉換與稽核軌跡 談的領域狀態機互為投影——領域的前置條件投影到畫面層、就是「這個狀態下入口在哪」的答案。

環境差異——涉及平台 API 的步驟、「測試環境與目標平台行為一致嗎」有答案。答案為否或不確定的步驟、標記「需實機驗證」、餵給發版前的冒煙走查清單:兩個平台問題在設計文件裡無處可防、能做的是在設計期把「這一步只有實機能驗」標出來、讓漏網的位置從使用者手上移到發版前的清單上。

任何一問填不出來、代表對應的設計缺口還停在提案層、退回補齊再進實作。

邊界:組裝可達之外的失效

缺口分類先於對策。五個問題的兩種形狀對應兩組修法:組裝斷裂修規格與測試(可達性條款、接線測試)、平台差異修驗證流程(設計期標記、冒煙走查)。混用的代價是把「補測試」當成萬靈丹——平台語意差異是 host 測試補不到的那一類、案例裡那條資料庫設定指令的語意差異、開發機的測試環境重現不出來、要目標平台實際執行才暴露。

本章的作用域同樣要誠實標明:可達性守的是「入口接上了」;接上之後的行為正確性屬行為測試、資料在層間流動的正確性屬整合測試的其他主題。組裝層的證言與六角形內部的證言互補、彼此替代不了:層內的設計判準(資料袋與領域模型不變式的強制層次)決定六角形內部的品質、建構路徑設計 收物件怎麼被建出來、本章把同一個問題抬到應用程式層。層內的模型設計得再好、組裝層沒有證言、使用者拿到的仍然可能是一片接不通的綠燈。

下一步