本章概覽#

第 5 章從五個品質屬性(可修改性、可演化性、可測試性、可部署性、可觀測性)角度描述了流暢交付的架構需求。本章專注於單一元件架構——巨石架構(monolithic architecture)——說明它如何滿足這些品質屬性,以及如何用設計手法填補巨石未明確規範的部分,以提升團隊自主性與部署管線速度,重點是模組化巨石(modular monolith)模式

巨石並非反模式。對許多應用而言,它能避開分散式架構固有的複雜度。重點是知道什麼時候它適用、什麼時候會成為流暢交付的阻礙,以及如何把它做得更好。

本章將討論的主題#

  • 巨石架構的優點與限制
  • 巨石滿足流暢交付五個品質屬性的能力
  • 結構化巨石以提升團隊自主性的策略
  • 減少建置期耦合、加速部署管線的設計手法

巨石架構是什麼#

巨石架構是把應用結構為單一元件的簡單架構風格:

  • 程式碼放在單一程式碼倉(repository)。
  • 由單一部署管線建置與部署。
  • 通常配一個關聯式資料庫。

Figure 6.1: The monolithic architecture — single component, single repository, single deployment pipeline

巨石的優點#

  • 單一程式碼庫的簡潔:大幅變更容易(可同時改 code 與 schema、build、deploy);IDE 與工具圍繞單一應用;編譯期 API 檢查——在 Java、Rust 這類編譯語言裡,介面誤用會被編譯器抓到,不需 contract testing。
  • 系統操作易實作、易理解、易維護:操作天生在單一元件內,沒有跨網路通訊;可以單一 ACID 交易實作,自動具備 isolation 與 consistency。
  • 部署相對單純:單一技術棧,典型部署是一組負載平衡的元件實例;但仍可做進階變化——例如 FTGO 的 Restaurant Management 子領域有大型記憶體資料庫,可分成兩個 cluster:多數請求跑在通用機器,Restaurant Management 跑在記憶體最佳化機器,共用同一資料庫(Spring Boot 可用 Spring Profiles 啟用對應功能)。

Figure 6.2: A monolithic application deployed as multiple clusters sharing the same database but optimized for different workloads

  • 排錯較簡單:單一元件實例的請求,所有 log 都在一個檔案內;只有當操作橫跨多實例時,才需要 log aggregation 與 distributed tracing 等可觀測性模式。

ACID 例外:批次工作常刻意拆成多個 transaction,以改善錯誤處理、效能、可擴展性;有時也會把操作拆成同步部分(建立 Order + 發 event)與一或多個事件處理器(寄出確認信)兩段。巨石的關鍵優勢是「拆 transaction」是開發者自願的決定,而非被分散式架構強迫

巨石如何滿足流暢交付的五個品質屬性#

小型團隊開發的小型巨石,通常能很好地滿足這五個品質屬性。問題出在大型巨石被大型組織開發時,巨石會逐漸成為流暢交付的阻礙。

可修改性:仰賴自律的設計#

  • 巨石的領域視角完全可以做到鬆設計期耦合——這是設計問題,不是架構限制。元件視角只有一個元件,自然不會有元件層的耦合。
  • 但巨石「太寬容」:可以隨便加類別到任何位置,程式都跑得動。時程壓力下開發者容易抄捷徑,逐漸累積成緊耦合,墮落為大泥球。
  • 傳統用來組織領域視角的模式(分層架構等)在小巨石還行,但隨著規模成長就無法支撐——下面要介紹的「模組化巨石」是更有效的替代方案。

可演化性:隨應用與組織成長而下降#

大型巨石只有單一技術棧,升級需要全公司協調:時程衝突、利益不一致,連修補安全漏洞這類關鍵升級都可能拖延。

巨石架構也讓低成本實驗新技術變得困難——任何新技術都會影響整個應用,不是只影響一小部分。長期下來技術棧過時的風險極高

Figure 6.3: Upgrading the technology stack of a monolith requires coordinating with all teams

可測試性:隨應用與組織成長而下降#

三個原因:

  • 部署管線會變成瓶頸:整體測試套件執行時間隨應用變大而拉長,即使各子專案單獨快,加總起來仍慢;組織成長帶來變更量上升,讓管線塞車。
  • 無法隔離非確定性模組:巨石不能把 flakey 的部分隔到自己的元件;flakey 測試讓開發者花時間判斷「這次失敗是真 bug 還是隨機」,白白燒掉時間。
  • 本地測試變得不切實際:大型巨石需要超出筆電負荷的資源——只能買大筆電或雲端 IDE,但成本高昂,且即使最大型 EC2 也不一定跑得動。

Figure 6.4: Testability declines as the application and its organization grow — long test times, deployment pipeline becomes a bottleneck, flakey tests

可部署性:隨應用與組織成長而下降#

巨石可滿足以下四項可部署性:自動化部署、可配置不同環境、漸進式部署、可回滾;單一團隊巨石還能做到「一個團隊一個元件」。但其他三項會隨規模惡化:

Figure 6.5: A monolith satisfies four of eight deployability dimensions; ability to satisfy three others declines as it grows

  • 部署速度下降:應用越大啟動越慢,canary release 還要啟動兩次;部署只能序列化,平行化幫不上忙。
  • 可靠性下降:每次部署都更新整個應用,即便變更很小,潛在影響範圍卻很大,隱藏依賴與測試覆蓋空隙會放大事故風險。
  • 團隊自主部署能力下降:所有團隊提交到同一個程式碼庫,任一團隊把 build 弄壞,其他人就無法部署。
  • 驗證部署的時間拉長:canary release 用生產流量驗證新版,但巨石功能太多,信心需要更久才能建立——某些 bug 甚至可能直到幾天後 batch 跑時才被發現。

可觀測性:隨應用成長而下降#

可觀測性模式可實作,但 telemetry 都是「整個元件層級」的合計值;當巨石越大,個別子領域的行為越被遮蔽,排查越困難。

巨石的其他缺點#

  • 無法支援多技術棧:Java 巨石只能用 JVM 兼容語言,即便其他語言或 runtime 在某些子領域更合適,也無法使用。
  • 無法依特性隔離子領域:第 20 章會提到,子領域有不同的關鍵性、安全性等特徵;微服務可把它們部署成獨立服務以隔離,巨石做不到。

巨石的模式語言#

雖然巨石的有些限制(單一技術棧)無法消除,但下列模式可大幅提升「巨石對流暢交付的支援度」:

  • Technology-Oriented Monolithic Architecture(技術導向):常見但有問題的結構,容易造成緊耦合與低模組化。
  • Modular Monolith(模組化巨石):更好的結構,圍繞領域概念組織應用,提升可修改性、可測試性與可部署性。
  • 一系列減少建置期耦合的支援模式。

Figure 6.6: The monolithic architecture pattern language — patterns to improve a monolith's support for fast flow

從技術導向到模組化巨石#

傳統分層巨石的問題#

以 FTGO 為例,各團隊負責的子領域(consumers、orders、notifications),程式碼被打散到 web/domain/persistence 三層,問題:

  • 層的責任不清:無論應用多大,永遠只有三層,業務邏輯層的責任不斷膨脹。
  • 緊設計期耦合:每個子領域跨層分布,加一個 Consumer 屬性就要改三層。
  • 不對應團隊結構:團隊以子領域劃分,卻被迫穿層工作。
  • 過度的建置期耦合:每層一個 Gradle 子專案,改一個類別會迫使其層與所有上層重建——小變更導致大量測試。

Figure 6.7: In a technology-oriented layered architecture, code for each subdomain is scattered across technical layers

模組化巨石#

模組化巨石以子領域為主軸組織應用,而非以技術概念分層。每個子領域對應一個「領域模組(domain module)」,是貫穿整個架構的垂直切片(包含 web、domain、persistence)。

範例 FTGO 結構:consumers/orders/deliveries/main/(應用入口,依賴所有領域模組)。

Figure 6.8: A modular monolith consists of domain modules corresponding to the application's subdomains

「modular」不代表分層巨石「沒結構」——分層巨石也有結構(技術導向)。也許更貼切的名字是「領域導向架構(domain-oriented architecture)」。

即使是六角架構也常被稱為 domain-centric,但它仍以技術概念(adapter、port、domain)組織,並非以領域本身組織。

領域模組設計的四個關鍵決策#

  1. 內部領域架構
  2. 對外暴露的 API
  3. 資料庫 schema 組織方式
  4. 類別與套件如何組織為 build project

內部領域架構:用六角架構#

每個領域模組以六角架構組織:

Figure 6.9: A domain module has a hexagonal architecture

  • consumers.restapi:REST 入站介接卡(ConsumerController 等)。
  • consumers.domain:業務邏輯與 entity、repository 介面(ConsumerConsumerRepository)。
  • consumers.infrastructure:出站介接卡(例如 ConsumerDao 實作 ConsumerRepository)。

Figure 6.10: A domain module consists of restapi, domain, and infrastructure sub-packages

API 設計:facade + DTO#

不要直接暴露 entity 與 repository 給其他模組——Order 模組能呼叫任何 public 方法,連「Consumer 是 JPA entity」這個假設都會洩漏出去,且 entity/repository 難以 mock。

Figure 6.11: Order Domain Module directly invoking Consumer entity and ConsumerRepository — tight design-time coupling

更好的做法:facade 風格 API——例如 ConsumerService 介面定義 reserveCredit()releaseCredit(),以 DTO 傳遞資料。Order 模組只依賴 facade,測試時可輕鬆 mock。

Figure 6.12: A ConsumerService facade encapsulates implementation details and reduces design-time coupling

資料庫設計:schema 視為私有實作#

  • 領域模組的 schema 是私有實作細節;模組間協作必須透過 API

Figure 6.13: Domain modules should collaborate through APIs rather than direct database access

  • 例外:讓 SELECT 跨界有時是合理權衡——例如「查詢消費者訂單歷史」用 SQL JOIN 比兩次 API 呼叫高效得多。

Figure 6.14: Sometimes a query that spans multiple domain modules is implemented with a single SQL JOIN for efficiency

  • 絕對避免跨界的 INSERT/UPDATE/DELETE(批次更新例外):會繞過或重複另一個模組的業務邏輯,引發資料一致性 bug。

跨模組系統操作的設計#

跨多個領域模組的操作(例如 createOrder() 跨 Order、Consumer、Notification)需要兩個關鍵決策:模組間如何協作交易如何管理

三種協作模式#

Figure 6.15: Three domain module collaboration patterns — Invoke, Observer, Asynchronous messaging

  • Invoke 模式:直接方法呼叫——最簡單、最常用,搭配 transaction-per-operation 很合;缺點是設計期耦合與執行期耦合(呼叫方必須等被呼叫方回傳)。

Figure 6.16: The Invoke pattern — Order Module invokes reserveCredit() on Consumer Module

  • Observer 模式:Order 在 Order 建立時通知,Notification 訂閱並反應。反向耦合:Notification 依賴 Order;讓 Order 容易遵循 Open-Closed 原則。但 observer 仍是同步呼叫,會被慢的 observer 拖累。

Figure 6.17: The Observer pattern — Order Management notifies Notification Management when an Order is created

  • 非同步訊息(Asynchronous Messaging):透過 message channel(常用 message broker)交換訊息(多為 event)。能消除執行期耦合,但實作複雜——需要 atomic event publication、deduplication 等模式,且操作會被拆成多個 transaction。

Figure 6.18: createOrder() decoupled from notification delivery via an OrderCreated event on a message channel

createOrder() 的合理設計:同步部分建立 Order 並發布 OrderCreated event;非同步事件處理器負責呼叫外部 email 服務。這樣 email 服務 down 不會阻塞訂單建立,reliability、scalability 都更好。

三種交易管理策略#

  • Transaction-per-operation:整個操作一個 ACID transaction(Spring 的 @Transactional 即可宣告式設定)。這是巨石最大的優勢之一——除非有特殊需求,這是 Chris Richardson 推薦的預設做法。

Figure 6.19: The transaction-per-operation strategy — implement a system operation as a single ACID transaction

  • Transaction-per-domain module:每個領域模組一個 transaction,需要 Saga(第 10 章);complicated saga 需要 compensating transaction,難實作、難理解,幾乎沒有任何巨石優勢卻得到微服務的麻煩

Figure 6.20: createOrder() implemented as a Saga with three local transactions and a compensating transaction

  • 例外:不需要 compensating 的簡單 saga 仍然 OK。例如 createOrder() 第一個 transaction 跨 Order 與 Consumer(任何業務規則違反都在此發生),第二個跨 Notification。

Figure 6.21: A simpler saga for createOrder() that avoids compensating transactions

  • 需求驅動的交易邊界(Requirements-driven):依操作需求而非架構元素設定邊界。
    • 範例 1:批次工作刻意拆成多個 transaction 以改善錯誤處理、可擴展性、效能。
    • 範例 2:呼叫外部系統前要先 commit 本地 transaction(因為外部系統通常無法加入交易),收到回應後可能再開新 transaction。

模組化巨石的測試策略#

  • 測試金字塔:盡量把測試往下推。
  • 領域模組獨立測試:減少變更牽動範圍。
  • 應用級測試最少化:速度慢,只用來驗整體 wiring。

每個領域模組的測試套件:

  • 單元測試:in-memory,測非平凡邏輯(例如演算法)。
  • 整合測試:測介接卡(HTTP controller、DAO 等)。
  • 模組測試:整個模組,搭配對被依賴模組的 test double;以 Spring Boot test 工具實作。

應用級測試:用 Test Containers 等框架建構並啟動應用容器映像,只驗證 @SpringBean 的 wiring,不重複模組內已測過的內容。

減少建置期耦合的物理設計#

John Lakos《Large Scale C++ Design》(1996)區分了邏輯設計(logical design)(命名、責任分配、繼承、封裝、鬆耦合、內聚)與物理設計(physical design)(原始檔、Gradle 專案/Maven 模組等)。物理設計直接影響建置期耦合,大型應用必須兩者並重。

三項物理設計技巧#

1. 為領域模組 API 拆出獨立 build project#

  • 把領域模組拆成 consumer-api + consumer-impl
  • Client 只依賴穩定的 consumer-api,測試用介面 mock 即可。
  • 改 implementation 不會觸發 client 的測試。

Figure 6.22: Reduce build-time coupling by splitting a domain module into an API and an implementation build project

2. 套用介面隔離原則(Interface Segregation Principle)#

  • 即便有獨立 API project,未被使用的方法仍會造成耦合
  • 例:ConsumerService.createConsumer() 沒被 Order 使用,但若加新參數,Order 還是會被重測。
  • 解法:把 ConsumerService 拆為 ConsumerService + CreditManagement 兩個小介面,各自獨立 API project,共同依賴一個 common types project。
  • Client 只與真正用到的 API project 建置耦合。

Figure 6.23: Apply the Interface Segregation Principle by splitting an API into smaller per-use-case API build projects

3. 切分 implementation build project#

  • 同一 implementation project 內部也有建置期耦合:webpersistencedomain 都在同一個 project,改 web 也會重跑 persistence 的測試。
  • 解法:webpersistence 各自獨立為 build project,都依賴 domain 但彼此不依賴
  • 慢測試(persistence 用實體資料庫)只在真正改動時才執行。

Figure 6.24: Split a domain module's implementation across multiple build projects (web/persistence/domain) to reduce build-time coupling

模組化巨石的限制#

仍然是巨石#

  • 單一程式碼庫、單一技術棧、單一部署管線。
  • 模組化能降低平均 build 時間,但無法降低最壞情況——廣泛使用的 API 或依賴一改,還是會觸發大量重建。
  • 應用與組織持續成長後,可測試性、可部署性、可演化性、可觀測性的限制終究會浮現。

「模組化巨石可以平滑轉成微服務」是個迷思#

模組化巨石不一定是微服務的踏腳石,有兩個現實障礙:

  • 領域模組之間的互動不適合網路通訊:模組內方法呼叫極快,但服務間網路通訊有 latency、執行期耦合與複雜度。天真地把方法呼叫換成服務呼叫,效能與可靠性會雙雙下降。
  • 把單一交易拆成多個交易並不容易:巨石中常見的「跨模組單一 ACID 交易」在微服務中必須拆為多個 transaction,需要重新設計操作、引入 Saga(第 10 章),並處理一致性、併發、錯誤處理、回滾的全新複雜度。

章節重點摘要#

  • 巨石架構是把應用結構為單一元件、單一程式碼庫、單一部署管線的簡單風格。
  • 由於單一程式碼庫,開發較單純;大幅變更可一次提交完成;多數操作可以 ACID 交易實作。
  • 小型應用、少數團隊是巨石的甜蜜點。
  • 大型應用 + 多團隊會超出巨石承載力:單一管線變瓶頸,技術棧升級需大規模協調。
  • 模組化巨石圍繞子領域組織程式碼,提升團隊自主性、降低建置期耦合。
  • 仔細的 build project 設計能進一步加速部署管線——但無法消除最壞情況的 build 時間。
  • 模組化巨石理論上可被設計為「微服務 ready」,但實務上模組互動方式與交易模型常讓抽取服務變得困難。
  • 模組化巨石仍是巨石,某些應用與組織終究需要轉到微服務架構。