《整潔架構實戰》Podcast 準備稿:動手實作使用案例
書名: 整潔架構實戰(Get Your Hands Dirty on Clean Architecture) 作者: Tom Hombergs 系列: 設計品味系列 公開標題建議: 設計品味系列|整潔架構實戰.第三集:一個使用案例該長什麼樣? (3/6) 涵蓋範圍: 第 5 章「實作使用案例」
背景速覽
前兩集都在搭骨架——為什麼要管架構、怎麼反轉依賴、套件結構怎麼擺。這一集終於把書名的「動手做(Get Your Hands Dirty)」兌現:作者拿 BuckPal 的「轉帳」使用案例,一步一步示範一個 use case 在六邊形架構裡到底該怎麼寫。
這章單獨成一集,是因為它密度很高,而且藏了好幾個會引起工程師「宗教戰爭」的決定:輸入驗證該放哪?要不要用 builder?充血還是貧血領域模型?唯讀操作算不算 use case?這些都是你寫每一個功能時、天天都要做的微決策,而作者每一個都給了有理由的主張。這集很「實戰」,很值得逐個拆。
一句話重點
一個使用案例就是「接收輸入 → 驗證業務規則 → 操作模型狀態 → 回傳輸出」這四步——而把輸入、輸出、驗證都做成「每個 use case 專屬」,正是讓它們解耦、可維護的關鍵。
值得討論的重點
重點一:使用案例的四個步驟,以及「為什麼不先驗證輸入」
作者把一個 use case 的骨架歸納成四步:
- 接收輸入
- 驗證業務規則
- 操作模型狀態
- 回傳輸出
注意第一步是「接收輸入」而不是「驗證輸入」——這是個刻意的設計決定。作者認為 use case 的程式碼應該只專注領域邏輯,不該被輸入驗證污染。但要分清楚:輸入驗證 不該進 use case,業務規則驗證 卻絕對是 use case 的責任。
落到程式碼:為了避免上集講的「過寬服務」,作者堅持每個 use case 一個獨立的 service。轉帳就是 SendMoneyService——它實作 SendMoneyUseCase 輸入 port,呼叫 LoadAccountPort 載入帳戶、呼叫 UpdateAccountStatePort 存回狀態,並用 @Transactional 設定交易邊界。一個 service 只服務一個 use case,名字一看就知道它幹嘛。
重點二:輸入驗證放哪?讓「輸入模型」自己驗證自己
既然 use case 不該管輸入驗證,那驗證放哪?作者的答案很漂亮:交給輸入模型的建構子。
轉帳的輸入模型是 SendMoneyCommand。作者用 Java record 把它做成不可變(immutable),並在建構子裡做驗證——任一參數不得為 null、金額必須大於零,違反就在建構期間拋例外,直接拒絕物件誕生。這帶來一個很強的保證:只要這個 command 物件成功建出來,它的狀態就一定有效,而且因為不可變,事後也改不壞。
作者給這個設計取了個名字:這在 use case 周圍形成一道「防腐層(anti-corruption layer)」——一道薄薄的屏障,把壞輸入彈回給呼叫端,讓神聖的 use case 程式碼保持乾淨。實作上不用手刻每條規則,可以用 Bean Validation(@NotNull 那一套),在建構子最後呼叫 validate()。作者還順手反駁了「模型類別不該用函式庫」的潔癖:一個輕量依賴能省你時間,何樂不為。
重點三:直接用建構子,別躲在 builder 後面(一個反直覺的主張)
這段我特別喜歡,因為它違反很多人的習慣。SendMoneyCommand 只有三個參數,但如果參數多,能不能用 builder 模式讓建立更方便?作者說:可以,但他不建議,並給了一個很有說服力的理由。
想像你給 SendMoneyCommandBuilder 加了個新欄位,加進了建構子、加進了 builder。然後你被同事、電話、郵件打斷。回來繼續寫,卻忘了在「呼叫 builder 建物件」的地方補上新欄位。問題是——編譯器不會警告你!你建了一個狀態無效的不可變物件,只能祈禱 runtime 的驗證邏輯(最好是在單元測試裡)幫你接住。
但如果你直接用建構子,每次新增或移除欄位,編譯器就會在所有呼叫點報錯,你只要「順著編譯錯誤的軌跡」把改動掃過整個程式庫就好。長參數列也能排版整齊,好的 IDE 還會顯示參數名稱提示。這就是「讓編譯器當你的待辦清單」的思路。當然 builder 在「有選用參數」時仍是較好的解——只是要確保 build() 在缺必要參數時大聲失敗。作者還順帶推薦用「值物件(value object)」取代原始型別,比如把 City、ZipCode 做成型別,誤傳時編譯器就會抱怨。
重點四:那些天天要吵的設計決定——專屬模型、充血vs貧血、唯讀查詢
這章後半像一份「微決策指南」,把工程師日常會猶豫的幾件事一次講清楚:
-
每個 use case 用專屬的輸入/輸出模型:別讓「註冊」和「更新」共用一個輸入模型。共用會逼你讓某些欄位允許 null(壞味道),還讓兩個 use case 被綁在一起、必須一起演進。輸出也一樣——「有疑慮時,回傳越少越好」,轉帳就回傳一個
boolean就夠。抗拒拿領域實體當輸入/輸出模型的誘惑,否則實體會因為非必要的理由被迫改變。 -
充血 vs 貧血領域模型:作者保持中立但講清楚。充血:把領域邏輯盡量放進 entity(
Account自己有withdraw()/deposit()並自我驗證),use case 只當「協調者」。貧血:entity 只有 getter/setter,邏輯全在 use case。本書架構兩種都能做,自己選。 -
業務規則驗證放哪:一個超實用的判準——業務規則驗證需要存取領域模型的當前狀態,輸入驗證不需要。「來源帳戶不可透支」要查餘額(業務規則,放進
Accountentity 最好);「金額必須大於零」不用查狀態(輸入驗證,放進 command)。 -
唯讀操作算 use case 嗎:把「查餘額」叫 use case 有點彆扭。作者的做法是做成「查詢(query)」,用獨立的
GetAccountBalanceQuery輸入模型、GetAccountBalanceUseCase輸入 port,跟修改型的 command 清楚區分——光看輸入型別名字就分得出。這跟 CQS / CQRS 的精神一拍即合。
注意事項
⚠️ 「每個 use case 一個 service、每個 use case 一套專屬輸入輸出模型」在複雜領域是黃金準則,但對純 CRUD 是不折不扣的樣板爆炸。一個只是「把表單存進去、撈出來」的功能,你卻要寫 command、寫 use case、寫 service、寫對映器、再為每個都寫測試——投入遠大於回報。作者自己在第 11 章會專門講「什麼時候該有意識地走捷徑略過這些」。所以這集的所有「應該」,前提都是「這個 use case 真的有領域複雜度」。
⚠️ 「不要用 builder、直接用建構子讓編譯器當待辦清單」這招很聰明,但有個邊界:當參數真的很多(七八個以上)、或有不少選用參數時,硬塞建構子會變成一長串難讀又容易傳錯位置的參數列。作者也承認 builder 在選用參數場景仍較好。真正的精神不是「禁用 builder」,而是「別讓抽象層偷走編譯器幫你抓錯的能力」——值物件、型別化參數其實是比這兩者都更好的防線。
⚠️ 「防腐層」這個詞作者借用得有點寬鬆。DDD 原意的防腐層(Anti-Corruption Layer)是指「兩個限界上下文之間、用來翻譯與隔離對方模型」的那一層,是個比較重的戰略模式。作者這裡指的是「輸入模型建構子裡的驗證屏障」,比較輕。聽眾若讀過 DDD 可能會困惑,值得提一句:同一個詞,這裡是借喻、不是 DDD 原版的那個 ACL。
專家補充
💡 「讓建構子做驗證、物件一旦建成就保證有效」這個思路,有個更廣的名字叫「讓非法狀態無法被表達(make illegal states unrepresentable)」,是函數式社群(尤其 F#、Scala、Rust)的核心信條。把驗證推到「型別/建構」層面,而不是散在程式各處用 if 檢查,是現代設計的大趨勢。作者用 Java record + 建構子驗證 + 值物件,其實就是在用 Java 的工具逼近這個理想。
💡 唯讀做成 query、修改做成 command,這個區分背後是 Bertrand Meyer 的 CQS(命令查詢分離)原則,再往上長就是 Greg Young 推廣的 CQRS。本書的版本是「最輕量的 CQRS」——同一個資料庫、同一份模型,只是輸入型別分開。聽眾如果聽過 CQRS 覺得很重很可怕,可以用這個當入門:你不需要事件溯源、不需要讀寫分庫,光是「command 和 query 用不同的輸入 port」就已經拿到大半好處了。
💡 「有疑慮時回傳越少越好」這條,呼應 John Ousterhout《軟體設計的哲學》裡「深模組(deep module)」的概念——好的介面應該窄、實作可以深。回傳一個 boolean 而不是整個 Account,就是把介面收窄、不洩漏不必要的資訊。介面每多暴露一個欄位,就是多給呼叫端一個依賴你的理由,也多給自己一個改變理由。
💡 充血 vs 貧血這個老戰場,Martin Fowler 有篇著名文章把「貧血領域模型」直接叫做 anti-pattern(反模式),因為它把資料和操作資料的邏輯拆開,違反物件導向的封裝精神。但本書作者刻意不選邊——這其實是務實的:貧血模型在「邏輯主要是流程編排、實體本身行為不多」的系統裡(很多企業應用就是這樣)反而清楚好維護。值得提醒聽眾:別被 Fowler 的「anti-pattern」標籤嚇到就盲目追充血,要看你的領域實體到底有沒有「真正屬於它自己的行為」。
討論問題
🎙️ 你的專案裡,輸入驗證現在散落在哪些地方?是塞在 controller、塞在 service、還是到處都有?如果把它收進「輸入模型的建構子」,會不會清爽很多?
🎙️ 你習慣用 builder 還是建構子?作者「直接用建構子、讓編譯器當待辦清單」的論點,有沒有打中你某次「忘了在某個地方補欄位、結果 runtime 才爆」的慘痛經驗?
🎙️ 你的領域模型偏充血還是偏貧血?這是你「設計」出來的,還是「不知不覺長成」的?如果是後者,它現在到底是哪一種你說得清楚嗎?
🎙️ 用「需不需要存取模型當前狀態」來分輸入驗證和業務規則——拿這把尺去量你最近寫的某個驗證,它放對位置了嗎?
更大範圍關聯
-
接 Uncle Bob《Clean Architecture》:那本把 use case 定義成同心圓的一圈,但講得抽象。這章是 use case 的「實作特寫」——一個 service、一套專屬模型、一道防腐層,把抽象變成可以照抄的程式碼。
-
接 DDD:充血領域模型、值物件、command、防腐層、CQS——這章幾乎是 DDD 戰術模式的小型示範場。作者明說這套架構讓你「純粹地套用 DDD」。想學 DDD 戰術模式的人,這章是很好的最小可行範例。
-
接函數式設計:「不可變 + 建構子驗證 + 讓非法狀態無法表達」是函數式社群的核心信條。這章用 Java 的 record 和值物件去逼近它,可以接到更大的「型別驅動設計」脈絡。
-
接 Greg Young 的 CQRS / Bertrand Meyer 的 CQS:唯讀做 query、修改做 command,是 CQRS 最輕量的入門形態。這章是「不用事件溯源也能享受 CQRS 一半好處」的好例子。
錄製建議
目標約 20 分鐘,單人主講。建議節奏:
- 0–2 分:開場。「前兩集搭骨架,這集真的動手寫第一個功能——轉帳,看一個 use case 到底長什麼樣」。
- 2–6 分:use case 四步驟 + 「每個 use case 一個 service」。用
SendMoneyService當具體例子走一遍,強調「為什麼第一步是接收輸入、不是驗證輸入」。 - 6–11 分:輸入驗證放哪。本集第一個亮點——「讓輸入模型的建構子自己驗證自己」+ 不可變 + 防腐層。講清楚「物件一建成就保證有效」這個保證有多強。
- 11–15 分:建構子 vs builder 那個反直覺主張。用「被打斷、忘記補欄位、編譯器不報錯」的故事講最有畫面,落到「讓編譯器當你的待辦清單」這句金句。
- 15–18 分:快掃後半四個微決策——專屬模型、充血vs貧血、業務規則判準(需不需要存取狀態)、唯讀做 query。每個給一句話 + 一個判準。
- 18–20 分:辯證 + 收尾。誠實講「這套對純 CRUD 是樣板爆炸,第 11 集會講怎麼走捷徑」,預告下一集往外走,做 Web adapter、persistence adapter 和測試策略。
口氣提醒:這集細節最多,切忌變成「念 API 清單」。每個決定都先講「你天天會猶豫的那個場景」,再給作者的答案和理由。建構子那段是全集記憶點,留時間講透。