讀模型的升級判準
讀模型(read model)是為讀需求的形狀而建的查詢側模型:它回答「畫面或報表需要什麼形狀的資料」、而 repository 回答「aggregate 長什麼形狀」。讀側的設計是一道階梯、不是「要不要 CQRS」的開關——多數專案的正確位置在階梯低處,升級由訊號驅動。本章給出階梯的四階、升級的五個訊號、以及一句可機械執行的自檢問句:
這個查詢回傳的是讀的形狀、還是 aggregate 的形狀?
回傳 aggregate 形狀(entity 或 entity 集合)的查詢屬於 repository;回傳讀的形狀(統計值、扁平列表、跨 aggregate 拼裝)的查詢是讀模型的候選。問句每次新增查詢方法時問一次,答案累積起來就是五訊號的量測值。
階梯:四階與每階的代價
| 階 | 形態 | 讀的形狀在哪裡產生 | 新增代價 |
|---|---|---|---|
| 一 | repository 查詢方法(pull 或 push、回 aggregate 形狀) | 消費端(ViewModel/service)自行投影 | 零:用既有介面 |
| 二 | 讀 port 抽離(獨立查詢介面、同一儲存) | port 實作內 | 一個介面 + 注入點 |
| 三 | 專用讀模型(獨立投影、可獨立快取) | 投影建構器內 | 投影邏輯 + 失效策略 |
| 四 | CQRS 全套(讀寫獨立儲存、事件同步) | 事件消費端 | 同步管線 + 最終一致性 |
每一階都比上一階多買一種能力、也多付一種持續成本。第一階的能力是簡單:所有讀需求共用一條資料通路,消費端各自把 aggregate 形狀折成自己要的樣子。第二階買到介面隔離:讀需求多到 repository 介面開始臃腫時,把查詢集合抽成獨立 port,寫側介面回到精簡。第三階買到形狀與效能的自由:讀的形狀在儲存或快取層物化,查詢不再每次從 aggregate 折算。第四階買到讀寫各自極致最佳化,代價是最終一致性進入系統語意——畫面可能短暫顯示舊值、而且這是設計內行為。
階梯的方向性很重要:每一階的介面簽名對消費端穩定。從第一階爬到第二階時,查詢方法從 repository 介面遷到讀 port、簽名不變、呼叫端只改注入來源。這讓「先停在低階」是安全決策而非技術債——升級路徑不會被低階選擇堵死。
五訊號
以下五個訊號涵蓋技術與效能維度、各自獨立量測,每個訊號指向階梯上的一個目標階:訊號一指向第二階(介面隔離)、訊號二指向第三階(形狀物化)、訊號三與訊號四指向第三階(獨立快取與新鮮度分級)、訊號五指向第三到第四階(獨立演進延伸到讀寫分離)。命中越多、且指向的階越高,往上爬的理由越強。但爬幾階沒有可套的公式:本章案例只完整走過「零命中、停第一階」這一個決策,多訊號命中時的爬升幅度要按每個訊號背後的業務代價個案衡量,而不是把命中數當分數累加。技術訊號之外還有一類驅動——組織與團隊擁有權邊界——性質與這五個正交,單獨成段在五訊號之後。
訊號一:讀需求增生
專用查詢方法累積到三條以上、且形狀彼此不同(一條回統計、一條回扁平列表、一條回分頁切片),repository 介面開始為讀需求膨脹。這是「第一階 → 第二階」的典型訊號:查詢集合已經大到值得一個自己的介面。三條是硬性起點:達到三條就把「介面裡讀方法與寫方法的比例」列入下次 review 的觀察項。讀方法數量超過寫方法的兩倍時,介面已經在為讀側服務——這是這個訊號的行動門檻。
訊號二:讀的形狀偏離 aggregate 形狀
自檢問句的直接輸出。統計值(總數、分組計數)、跨 aggregate 的拼裝(書 + 借閱人 + 標籤樹的合成畫面)、為排序或搜尋而反正規化的扁平投影——這些形狀讓消費端的「自行投影」從幾行 map 長成一段業務邏輯。投影邏輯值得有自己的家(第三階),而不是散在每個 ViewModel 裡各寫一份。
訊號三:讀寫負載特性分歧
讀高頻寫低頻(商品目錄)或寫高頻讀低頻(事件記錄)到同一模型無法同時服務兩邊時,讀側需要自己的快取或儲存策略。分歧要用量測支撐——「感覺查詢很多」不是訊號,慢查詢記錄和快取命中率才是。這個訊號沒有通用閾值:「命中率低到多少該行動」由服務的 SLA 決定(電商結帳頁和內部報表的容忍度差一個數量級),量測工具對了就夠用、門檻要帶服務脈絡才有意義。
訊號四:讀側可接受的新鮮度不同
報表可以慢十分鐘、交易畫面必須即時——同一份資料的不同讀者對「多舊算舊」的答案不同時,用一個模型服務所有人會被最嚴格的需求綁架。新鮮度分級是第三、四階才買得到的能力:投影可以有自己的更新節奏。
訊號五:讀模型需要獨立演進
不同消費者要不同版本的視圖(對外 API 的公開形狀 vs 內部畫面的完整形狀)、或讀形狀的變更頻率遠高於 aggregate 本身。讀側從此有自己的版本生命週期,跟 aggregate 綁在一起只會互相拖累。
技術訊號之外:團隊與交付邊界
五個訊號涵蓋技術與效能維度。另一類常見且獨立的升級驅動是組織邊界:讀側與寫側由不同團隊擁有、需要獨立部署節奏與獨立資料契約。即使五個技術訊號全部零命中,團隊自治的壓力仍可能合理地把系統推上第三或第四階——讀側的模型、儲存、部署由另一個團隊全權管理,Conway’s Law 讓組織結構成為架構的驅動力,跟負載分歧或形狀偏離這類技術維度完全正交。本章不展開組織驅動(涉及團隊拓撲與交付流程),只在此標記它的存在:把五訊號當成升級的全部理由,會漏掉組織維度的命中。
階梯的適用範圍
四階假設在單一 bounded context 內操作。跨服務聯合讀模型(報表服務訂閱多個 domain 的事件建置共享視圖)引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」可概括,超出本階梯的覆蓋範圍。
案例:停在第一階的決策
書庫管理 App 為 repository 補了觀測出口 watchBooks()(背景與三層歸屬見 觀測出口的職責三分)。落地時面對的正是本章的二擇:watchBooks() 放既有 repository 介面、還是抽一個獨立的讀 port?
用五訊號量測當時的狀態:
| 訊號 | 量測值 | 命中 |
|---|---|---|
| 讀需求增生 | 推送型讀需求只有「完整書單流」一條 | 否 |
| 形狀偏離 | watchBooks() 回 Stream<List<Book>>——正是 aggregate 的形狀 | 否 |
| 負載分歧 | 單人離線書庫、讀寫皆低頻 | 否 |
| 新鮮度分級 | 所有衍生視圖都要即時 | 否 |
| 獨立演進 | 消費者全是自家畫面、一個形狀通吃 | 否 |
五訊號零命中、自檢問句答「aggregate 的形狀」——結論是停在第一階:watchBooks() 進既有 repository 介面、與 getAllBooks() 形成 pull/push 對稱,各衍生視圖在 ViewModel 層各自投影(統計頁算總數、待補完列表過濾欄位缺漏)。抽讀 port 在此刻是為一個方法建一個介面、買不到任何一階的能力、只付碎片化的成本。
決策同時綁了升級 trigger:推送型讀需求增生(專用統計投影、分頁查詢流之類累積到三條)時再抽讀 port,屆時 watchBooks() 遷入讀 port、簽名不變、呼叫端只改注入來源。「停在低階」與「記錄何時升級」是同一個決策的兩半——少了後半、低階會在訊號早已命中之後仍靠慣性維持。
判準防的兩種錯
五訊號同時防兩個方向的失誤,兩邊在真實專案都常見:
太早抽:還沒有第二條讀需求就先建讀 port、投影層、甚至讀寫分離骨架——每一層都是要餵養的抽象。訊號零命中時的高階結構,日常成本是每個新查詢都要穿過多一層介面、而買到的能力沒有消費者。
太晚抽:repository 介面長滿 getBooksGroupedByTag()、getMonthlyStatistics()、searchWithPagination()——每條都「只是加一個方法」,直到介面的讀方法數量淹過寫方法、每個 mock 都要 stub 幾十個查詢。訊號早已命中、但沒有量測動作讓命中被看見。自檢問句的價值就在這裡:它把「要不要升級」從一次性的架構辯論、變成每次加方法時的例行量測。
下一步
停在第一階之後要落地觀測出口,歸屬判準見 觀測出口的職責三分。第四階需要事件作為同步載體——載體選用判準見 domain event 與狀態流。太晚抽的日常代價(介面臃腫、mock 爆炸)有實證:mock 55 個方法只用 5 個。術語定義見 Read Model。