<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Read-Model on Tarragon</title><link>https://tarrragon.github.io/blog/tags/read-model/</link><description>Recent content in Read-Model on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 20 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/read-model/index.xml" rel="self" type="application/rss+xml"/><item><title>讀模型的升級判準</title><link>https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">讀模型&lt;/a>（read model）是為讀需求的形狀而建的查詢側模型：它回答「畫面或報表需要什麼形狀的資料」、而 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository&lt;/a> 回答「&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate&lt;/a> 長什麼形狀」。讀側的設計是一道階梯、不是「要不要 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS&lt;/a>」的開關——多數專案的正確位置在階梯低處，升級由訊號驅動。本章給出階梯的四階、升級的五個訊號、以及一句可機械執行的自檢問句：&lt;/p>
&lt;blockquote>
&lt;p>這個查詢回傳的是&lt;strong>讀的形狀&lt;/strong>、還是 &lt;strong>aggregate 的形狀&lt;/strong>？&lt;/p>&lt;/blockquote>
&lt;p>回傳 aggregate 形狀（entity 或 entity 集合）的查詢屬於 repository；回傳讀的形狀（統計值、扁平列表、跨 aggregate 拼裝）的查詢是讀模型的候選。問句每次新增查詢方法時問一次，答案累積起來就是五訊號的量測值。&lt;/p>
&lt;h2 id="階梯四階與每階的代價">階梯：四階與每階的代價&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階&lt;/th>
 &lt;th>形態&lt;/th>
 &lt;th>讀的形狀在哪裡產生&lt;/th>
 &lt;th>新增代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一&lt;/td>
 &lt;td>repository 查詢方法（pull 或 push、回 aggregate 形狀）&lt;/td>
 &lt;td>消費端（ViewModel／service）自行投影&lt;/td>
 &lt;td>零：用既有介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>二&lt;/td>
 &lt;td>讀 port 抽離（獨立查詢介面、同一儲存）&lt;/td>
 &lt;td>port 實作內&lt;/td>
 &lt;td>一個介面 + 注入點&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>三&lt;/td>
 &lt;td>專用讀模型（獨立投影、可獨立快取）&lt;/td>
 &lt;td>投影建構器內&lt;/td>
 &lt;td>投影邏輯 + 失效策略&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>四&lt;/td>
 &lt;td>CQRS 全套（讀寫獨立儲存、事件同步）&lt;/td>
 &lt;td>事件消費端&lt;/td>
 &lt;td>同步管線 + 最終一致性&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每一階都比上一階多買一種能力、也多付一種持續成本。第一階的能力是簡單：所有讀需求共用一條資料通路，消費端各自把 aggregate 形狀折成自己要的樣子。第二階買到介面隔離：讀需求多到 repository 介面開始臃腫時，把查詢集合抽成獨立 port，寫側介面回到精簡。第三階買到形狀與效能的自由：讀的形狀在儲存或快取層物化，查詢不再每次從 aggregate 折算。第四階買到讀寫各自極致最佳化，代價是最終一致性進入系統語意——畫面可能短暫顯示舊值、而且這是設計內行為。&lt;/p>
&lt;p>階梯的方向性很重要：&lt;strong>每一階的介面簽名對消費端穩定&lt;/strong>。從第一階爬到第二階時，查詢方法從 repository 介面遷到讀 port、簽名不變、呼叫端只改注入來源。這讓「先停在低階」是安全決策而非技術債——升級路徑不會被低階選擇堵死。&lt;/p>
&lt;h2 id="五訊號">五訊號&lt;/h2>
&lt;p>以下五個訊號涵蓋技術與效能維度、各自獨立量測，每個訊號指向階梯上的一個目標階：訊號一指向第二階（介面隔離）、訊號二指向第三階（形狀物化）、訊號三與訊號四指向第三階（獨立快取與新鮮度分級）、訊號五指向第三到第四階（獨立演進延伸到讀寫分離）。命中越多、且指向的階越高，往上爬的理由越強。但爬幾階沒有可套的公式：本章案例只完整走過「零命中、停第一階」這一個決策，多訊號命中時的爬升幅度要按每個訊號背後的業務代價個案衡量，而不是把命中數當分數累加。技術訊號之外還有一類驅動——組織與團隊擁有權邊界——性質與這五個正交，單獨成段在五訊號之後。&lt;/p>
&lt;h3 id="訊號一讀需求增生">訊號一：讀需求增生&lt;/h3>
&lt;p>專用查詢方法累積到三條以上、且形狀彼此不同（一條回統計、一條回扁平列表、一條回分頁切片），repository 介面開始為讀需求膨脹。這是「第一階 → 第二階」的典型訊號：查詢集合已經大到值得一個自己的介面。三條是硬性起點：達到三條就把「介面裡讀方法與寫方法的比例」列入下次 review 的觀察項。讀方法數量超過寫方法的兩倍時，介面已經在為讀側服務——這是這個訊號的行動門檻。&lt;/p>
&lt;h3 id="訊號二讀的形狀偏離-aggregate-形狀">訊號二：讀的形狀偏離 aggregate 形狀&lt;/h3>
&lt;p>自檢問句的直接輸出。統計值（總數、分組計數）、跨 aggregate 的拼裝（書 + 借閱人 + 標籤樹的合成畫面）、為排序或搜尋而反正規化的扁平投影——這些形狀讓消費端的「自行投影」從幾行 &lt;code>map&lt;/code> 長成一段業務邏輯。投影邏輯值得有自己的家（第三階），而不是散在每個 ViewModel 裡各寫一份。&lt;/p>
&lt;h3 id="訊號三讀寫負載特性分歧">訊號三：讀寫負載特性分歧&lt;/h3>
&lt;p>讀高頻寫低頻（商品目錄）或寫高頻讀低頻（事件記錄）到同一模型無法同時服務兩邊時，讀側需要自己的快取或儲存策略。分歧要用量測支撐——「感覺查詢很多」不是訊號，慢查詢記錄和快取命中率才是。這個訊號沒有通用閾值：「命中率低到多少該行動」由服務的 SLA 決定（電商結帳頁和內部報表的容忍度差一個數量級），量測工具對了就夠用、門檻要帶服務脈絡才有意義。&lt;/p>
&lt;h3 id="訊號四讀側可接受的新鮮度不同">訊號四：讀側可接受的新鮮度不同&lt;/h3>
&lt;p>報表可以慢十分鐘、交易畫面必須即時——同一份資料的不同讀者對「多舊算舊」的答案不同時，用一個模型服務所有人會被最嚴格的需求綁架。新鮮度分級是第三、四階才買得到的能力：投影可以有自己的更新節奏。&lt;/p>
&lt;h3 id="訊號五讀模型需要獨立演進">訊號五：讀模型需要獨立演進&lt;/h3>
&lt;p>不同消費者要不同版本的視圖（對外 API 的公開形狀 vs 內部畫面的完整形狀）、或讀形狀的變更頻率遠高於 aggregate 本身。讀側從此有自己的版本生命週期，跟 aggregate 綁在一起只會互相拖累。&lt;/p>
&lt;h2 id="技術訊號之外團隊與交付邊界">技術訊號之外：團隊與交付邊界&lt;/h2>
&lt;p>五個訊號涵蓋技術與效能維度。另一類常見且獨立的升級驅動是組織邊界：讀側與寫側由不同團隊擁有、需要獨立部署節奏與獨立資料契約。即使五個技術訊號全部零命中，團隊自治的壓力仍可能合理地把系統推上第三或第四階——讀側的模型、儲存、部署由另一個團隊全權管理，Conway&amp;rsquo;s Law 讓組織結構成為架構的驅動力，跟負載分歧或形狀偏離這類技術維度完全正交。本章不展開組織驅動（涉及團隊拓撲與交付流程），只在此標記它的存在：把五訊號當成升級的全部理由，會漏掉組織維度的命中。&lt;/p>
&lt;h2 id="階梯的適用範圍">階梯的適用範圍&lt;/h2>
&lt;p>四階假設在單一 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/bounded-context/" data-link-title="Bounded Context" data-link-desc="同一套架構判準跨到另一個服務還站不站得住？bounded context 是模型與詞彙保持一致的邊界——邊界內的推導在邊界外不必然成立。">bounded context&lt;/a> 內操作。跨服務聯合讀模型（報表服務訂閱多個 domain 的事件建置共享視圖）引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」可概括，超出本階梯的覆蓋範圍。&lt;/p>
&lt;h2 id="案例停在第一階的決策">案例：停在第一階的決策&lt;/h2>
&lt;p>書庫管理 App 為 repository 補了觀測出口 &lt;code>watchBooks()&lt;/code>（背景與三層歸屬見 &lt;a href="https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分&lt;/a>）。落地時面對的正是本章的二擇：&lt;code>watchBooks()&lt;/code> 放既有 repository 介面、還是抽一個獨立的讀 port？&lt;/p>
&lt;p>用五訊號量測當時的狀態：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>量測值&lt;/th>
 &lt;th>命中&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>讀需求增生&lt;/td>
 &lt;td>推送型讀需求只有「完整書單流」一條&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>形狀偏離&lt;/td>
 &lt;td>&lt;code>watchBooks()&lt;/code> 回 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code>——正是 aggregate 的形狀&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>負載分歧&lt;/td>
 &lt;td>單人離線書庫、讀寫皆低頻&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新鮮度分級&lt;/td>
 &lt;td>所有衍生視圖都要即時&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>獨立演進&lt;/td>
 &lt;td>消費者全是自家畫面、一個形狀通吃&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>五訊號零命中、自檢問句答「aggregate 的形狀」——結論是停在第一階：&lt;code>watchBooks()&lt;/code> 進既有 repository 介面、與 &lt;code>getAllBooks()&lt;/code> 形成 pull／push 對稱，各衍生視圖在 ViewModel 層各自投影（統計頁算總數、待補完列表過濾欄位缺漏）。抽讀 port 在此刻是為一個方法建一個介面、買不到任何一階的能力、只付碎片化的成本。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">讀模型</a>（read model）是為讀需求的形狀而建的查詢側模型：它回答「畫面或報表需要什麼形狀的資料」、而 <a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 回答「<a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate</a> 長什麼形狀」。讀側的設計是一道階梯、不是「要不要 <a href="/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS</a>」的開關——多數專案的正確位置在階梯低處，升級由訊號驅動。本章給出階梯的四階、升級的五個訊號、以及一句可機械執行的自檢問句：</p>
<blockquote>
<p>這個查詢回傳的是<strong>讀的形狀</strong>、還是 <strong>aggregate 的形狀</strong>？</p></blockquote>
<p>回傳 aggregate 形狀（entity 或 entity 集合）的查詢屬於 repository；回傳讀的形狀（統計值、扁平列表、跨 aggregate 拼裝）的查詢是讀模型的候選。問句每次新增查詢方法時問一次，答案累積起來就是五訊號的量測值。</p>
<h2 id="階梯四階與每階的代價">階梯：四階與每階的代價</h2>
<table>
  <thead>
      <tr>
          <th>階</th>
          <th>形態</th>
          <th>讀的形狀在哪裡產生</th>
          <th>新增代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一</td>
          <td>repository 查詢方法（pull 或 push、回 aggregate 形狀）</td>
          <td>消費端（ViewModel／service）自行投影</td>
          <td>零：用既有介面</td>
      </tr>
      <tr>
          <td>二</td>
          <td>讀 port 抽離（獨立查詢介面、同一儲存）</td>
          <td>port 實作內</td>
          <td>一個介面 + 注入點</td>
      </tr>
      <tr>
          <td>三</td>
          <td>專用讀模型（獨立投影、可獨立快取）</td>
          <td>投影建構器內</td>
          <td>投影邏輯 + 失效策略</td>
      </tr>
      <tr>
          <td>四</td>
          <td>CQRS 全套（讀寫獨立儲存、事件同步）</td>
          <td>事件消費端</td>
          <td>同步管線 + 最終一致性</td>
      </tr>
  </tbody>
</table>
<p>每一階都比上一階多買一種能力、也多付一種持續成本。第一階的能力是簡單：所有讀需求共用一條資料通路，消費端各自把 aggregate 形狀折成自己要的樣子。第二階買到介面隔離：讀需求多到 repository 介面開始臃腫時，把查詢集合抽成獨立 port，寫側介面回到精簡。第三階買到形狀與效能的自由：讀的形狀在儲存或快取層物化，查詢不再每次從 aggregate 折算。第四階買到讀寫各自極致最佳化，代價是最終一致性進入系統語意——畫面可能短暫顯示舊值、而且這是設計內行為。</p>
<p>階梯的方向性很重要：<strong>每一階的介面簽名對消費端穩定</strong>。從第一階爬到第二階時，查詢方法從 repository 介面遷到讀 port、簽名不變、呼叫端只改注入來源。這讓「先停在低階」是安全決策而非技術債——升級路徑不會被低階選擇堵死。</p>
<h2 id="五訊號">五訊號</h2>
<p>以下五個訊號涵蓋技術與效能維度、各自獨立量測，每個訊號指向階梯上的一個目標階：訊號一指向第二階（介面隔離）、訊號二指向第三階（形狀物化）、訊號三與訊號四指向第三階（獨立快取與新鮮度分級）、訊號五指向第三到第四階（獨立演進延伸到讀寫分離）。命中越多、且指向的階越高，往上爬的理由越強。但爬幾階沒有可套的公式：本章案例只完整走過「零命中、停第一階」這一個決策，多訊號命中時的爬升幅度要按每個訊號背後的業務代價個案衡量，而不是把命中數當分數累加。技術訊號之外還有一類驅動——組織與團隊擁有權邊界——性質與這五個正交，單獨成段在五訊號之後。</p>
<h3 id="訊號一讀需求增生">訊號一：讀需求增生</h3>
<p>專用查詢方法累積到三條以上、且形狀彼此不同（一條回統計、一條回扁平列表、一條回分頁切片），repository 介面開始為讀需求膨脹。這是「第一階 → 第二階」的典型訊號：查詢集合已經大到值得一個自己的介面。三條是硬性起點：達到三條就把「介面裡讀方法與寫方法的比例」列入下次 review 的觀察項。讀方法數量超過寫方法的兩倍時，介面已經在為讀側服務——這是這個訊號的行動門檻。</p>
<h3 id="訊號二讀的形狀偏離-aggregate-形狀">訊號二：讀的形狀偏離 aggregate 形狀</h3>
<p>自檢問句的直接輸出。統計值（總數、分組計數）、跨 aggregate 的拼裝（書 + 借閱人 + 標籤樹的合成畫面）、為排序或搜尋而反正規化的扁平投影——這些形狀讓消費端的「自行投影」從幾行 <code>map</code> 長成一段業務邏輯。投影邏輯值得有自己的家（第三階），而不是散在每個 ViewModel 裡各寫一份。</p>
<h3 id="訊號三讀寫負載特性分歧">訊號三：讀寫負載特性分歧</h3>
<p>讀高頻寫低頻（商品目錄）或寫高頻讀低頻（事件記錄）到同一模型無法同時服務兩邊時，讀側需要自己的快取或儲存策略。分歧要用量測支撐——「感覺查詢很多」不是訊號，慢查詢記錄和快取命中率才是。這個訊號沒有通用閾值：「命中率低到多少該行動」由服務的 SLA 決定（電商結帳頁和內部報表的容忍度差一個數量級），量測工具對了就夠用、門檻要帶服務脈絡才有意義。</p>
<h3 id="訊號四讀側可接受的新鮮度不同">訊號四：讀側可接受的新鮮度不同</h3>
<p>報表可以慢十分鐘、交易畫面必須即時——同一份資料的不同讀者對「多舊算舊」的答案不同時，用一個模型服務所有人會被最嚴格的需求綁架。新鮮度分級是第三、四階才買得到的能力：投影可以有自己的更新節奏。</p>
<h3 id="訊號五讀模型需要獨立演進">訊號五：讀模型需要獨立演進</h3>
<p>不同消費者要不同版本的視圖（對外 API 的公開形狀 vs 內部畫面的完整形狀）、或讀形狀的變更頻率遠高於 aggregate 本身。讀側從此有自己的版本生命週期，跟 aggregate 綁在一起只會互相拖累。</p>
<h2 id="技術訊號之外團隊與交付邊界">技術訊號之外：團隊與交付邊界</h2>
<p>五個訊號涵蓋技術與效能維度。另一類常見且獨立的升級驅動是組織邊界：讀側與寫側由不同團隊擁有、需要獨立部署節奏與獨立資料契約。即使五個技術訊號全部零命中，團隊自治的壓力仍可能合理地把系統推上第三或第四階——讀側的模型、儲存、部署由另一個團隊全權管理，Conway&rsquo;s Law 讓組織結構成為架構的驅動力，跟負載分歧或形狀偏離這類技術維度完全正交。本章不展開組織驅動（涉及團隊拓撲與交付流程），只在此標記它的存在：把五訊號當成升級的全部理由，會漏掉組織維度的命中。</p>
<h2 id="階梯的適用範圍">階梯的適用範圍</h2>
<p>四階假設在單一 <a href="/blog/ddd/knowledge-cards/bounded-context/" data-link-title="Bounded Context" data-link-desc="同一套架構判準跨到另一個服務還站不站得住？bounded context 是模型與詞彙保持一致的邊界——邊界內的推導在邊界外不必然成立。">bounded context</a> 內操作。跨服務聯合讀模型（報表服務訂閱多個 domain 的事件建置共享視圖）引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」可概括，超出本階梯的覆蓋範圍。</p>
<h2 id="案例停在第一階的決策">案例：停在第一階的決策</h2>
<p>書庫管理 App 為 repository 補了觀測出口 <code>watchBooks()</code>（背景與三層歸屬見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>）。落地時面對的正是本章的二擇：<code>watchBooks()</code> 放既有 repository 介面、還是抽一個獨立的讀 port？</p>
<p>用五訊號量測當時的狀態：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>量測值</th>
          <th>命中</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>讀需求增生</td>
          <td>推送型讀需求只有「完整書單流」一條</td>
          <td>否</td>
      </tr>
      <tr>
          <td>形狀偏離</td>
          <td><code>watchBooks()</code> 回 <code>Stream&lt;List&lt;Book&gt;&gt;</code>——正是 aggregate 的形狀</td>
          <td>否</td>
      </tr>
      <tr>
          <td>負載分歧</td>
          <td>單人離線書庫、讀寫皆低頻</td>
          <td>否</td>
      </tr>
      <tr>
          <td>新鮮度分級</td>
          <td>所有衍生視圖都要即時</td>
          <td>否</td>
      </tr>
      <tr>
          <td>獨立演進</td>
          <td>消費者全是自家畫面、一個形狀通吃</td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p>五訊號零命中、自檢問句答「aggregate 的形狀」——結論是停在第一階：<code>watchBooks()</code> 進既有 repository 介面、與 <code>getAllBooks()</code> 形成 pull／push 對稱，各衍生視圖在 ViewModel 層各自投影（統計頁算總數、待補完列表過濾欄位缺漏）。抽讀 port 在此刻是為一個方法建一個介面、買不到任何一階的能力、只付碎片化的成本。</p>
<p>決策同時綁了升級 trigger：推送型讀需求增生（專用統計投影、分頁查詢流之類累積到三條）時再抽讀 port，屆時 <code>watchBooks()</code> 遷入讀 port、簽名不變、呼叫端只改注入來源。「停在低階」與「記錄何時升級」是同一個決策的兩半——少了後半、低階會在訊號早已命中之後仍靠慣性維持。</p>
<h2 id="判準防的兩種錯">判準防的兩種錯</h2>
<p>五訊號同時防兩個方向的失誤，兩邊在真實專案都常見：</p>
<p><strong>太早抽</strong>：還沒有第二條讀需求就先建讀 port、投影層、甚至讀寫分離骨架——每一層都是要餵養的抽象。訊號零命中時的高階結構，日常成本是每個新查詢都要穿過多一層介面、而買到的能力沒有消費者。</p>
<p><strong>太晚抽</strong>：repository 介面長滿 <code>getBooksGroupedByTag()</code>、<code>getMonthlyStatistics()</code>、<code>searchWithPagination()</code>——每條都「只是加一個方法」，直到介面的讀方法數量淹過寫方法、每個 mock 都要 stub 幾十個查詢。訊號早已命中、但沒有量測動作讓命中被看見。自檢問句的價值就在這裡：它把「要不要升級」從一次性的架構辯論、變成每次加方法時的例行量測。</p>
<h2 id="下一步">下一步</h2>
<p>停在第一階之後要落地觀測出口，歸屬判準見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。第四階需要事件作為同步載體——載體選用判準見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。太晚抽的日常代價（介面臃腫、mock 爆炸）有實證：<a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個</a>。術語定義見 <a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">Read Model</a>。</p>
]]></content:encoded></item><item><title>Read Model</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/</guid><description>&lt;p>read model 是為讀需求的形狀而建的查詢側模型：畫面或報表要什麼形狀（統計值、扁平列表、跨 aggregate 拼裝）、它就長什麼形狀。repository 回傳 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate&lt;/a> 的形狀、守一致性邊界；read model 回傳讀的形狀、不承擔寫入責任。兩者的分工判準是一句自檢問句：「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。讀側的介面宣告是一種 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>——同樣以領域語言表達、由查詢方的需要定義形狀。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>read model 是 CQRS 讀寫分離的讀側，與 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root&lt;/a> 的寫側形成互補：aggregate 守一致性邊界、read model 服務讀的形狀。它的存在不以 CQRS 全套為前提——讀側是一道階梯：消費端自行投影、讀 port 抽離（獨立的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>）、專用投影、事件同步的獨立儲存，每一階都是 read model 概念的某種深度。低階的 read model 可以只是 ViewModel 裡幾行 &lt;code>map&lt;/code>；高階的 read model 有自己的儲存與更新節奏，接受與寫側的最終一致性。階梯橫跨 ephemeral（ViewModel 內聯投影，沒有獨立生命週期）到 durable（獨立儲存、事件同步），但各階共享同一個設計責任——「讀的形狀由讀需求定義、不由 aggregate 形狀決定」——因此視為同一概念的深度變體。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>repository 介面長出回傳統計值、分頁切片、反正規化投影的方法，是讀的形狀開始混進 aggregate 介面的訊號。反向的訊號同樣可觀察：還沒有第二條讀需求就先建投影層，抽象沒有消費者、每個新查詢多穿一層介面。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>read model 定義「讀側要什麼形狀」，升級到哪一階由訊號決定、不由架構偏好決定——五個升級訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）與階梯各階的代價，教學層展開見 &lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>read model 是為讀需求的形狀而建的查詢側模型：畫面或報表要什麼形狀（統計值、扁平列表、跨 aggregate 拼裝）、它就長什麼形狀。repository 回傳 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate</a> 的形狀、守一致性邊界；read model 回傳讀的形狀、不承擔寫入責任。兩者的分工判準是一句自檢問句：「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。讀側的介面宣告是一種 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>——同樣以領域語言表達、由查詢方的需要定義形狀。</p>
<h2 id="概念位置">概念位置</h2>
<p>read model 是 CQRS 讀寫分離的讀側，與 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root</a> 的寫側形成互補：aggregate 守一致性邊界、read model 服務讀的形狀。它的存在不以 CQRS 全套為前提——讀側是一道階梯：消費端自行投影、讀 port 抽離（獨立的 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>）、專用投影、事件同步的獨立儲存，每一階都是 read model 概念的某種深度。低階的 read model 可以只是 ViewModel 裡幾行 <code>map</code>；高階的 read model 有自己的儲存與更新節奏，接受與寫側的最終一致性。階梯橫跨 ephemeral（ViewModel 內聯投影，沒有獨立生命週期）到 durable（獨立儲存、事件同步），但各階共享同一個設計責任——「讀的形狀由讀需求定義、不由 aggregate 形狀決定」——因此視為同一概念的深度變體。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>repository 介面長出回傳統計值、分頁切片、反正規化投影的方法，是讀的形狀開始混進 aggregate 介面的訊號。反向的訊號同樣可觀察：還沒有第二條讀需求就先建投影層，抽象沒有消費者、每個新查詢多穿一層介面。</p>
<h2 id="設計責任">設計責任</h2>
<p>read model 定義「讀側要什麼形狀」，升級到哪一階由訊號決定、不由架構偏好決定——五個升級訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）與階梯各階的代價，教學層展開見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。</p>
]]></content:encoded></item><item><title>CQRS</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/cqrs/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/cqrs/</guid><description>&lt;p>多數系統預設讀寫共用同一個模型：同一組 entity 既承接寫入的一致性檢查、也承接查詢的形狀需求。CQRS（Command Query Responsibility Segregation）是把這兩個責任拆開的架構決定——寫側走 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root&lt;/a>、守住不變式與一致性邊界；讀側走 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">read model&lt;/a>、只服務查詢要的形狀，不承擔寫入責任。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>CQRS 是一道讀側設計階梯的頂端、而非二選一的開關。讀側的設計從「消費端自行投影 aggregate 形狀」開始，中間經過「抽獨立讀 port」「查詢形狀專用投影」，到頂端才是 CQRS 全套——讀寫獨立儲存、由 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a> 同步讀模型。多數專案的正確位置在階梯低處、不是頂端，升級由訊號驅動而非架構偏好。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>讀寫分離的討論從「查詢方法該不該搬出 repository」，逐漸變成「讀模型需不需要自己的儲存、能不能接受跟寫側短暫不一致」，是逼近階梯頂端的訊號——第四階的代價是最終一致性進入系統語意，畫面可能短暫顯示舊值，而這是設計內行為，不是 bug。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>要不要上 CQRS 全套，不看「這個模式聽起來很適合」，看五個具體訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）是否命中——本卡只回答「CQRS 是什麼、階梯怎麼分階」，訊號的完整推導與升級路徑是 &lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a> 這篇的內容，要判斷自己的專案該停在哪一階、讀那篇。&lt;/p></description><content:encoded><![CDATA[<p>多數系統預設讀寫共用同一個模型：同一組 entity 既承接寫入的一致性檢查、也承接查詢的形狀需求。CQRS（Command Query Responsibility Segregation）是把這兩個責任拆開的架構決定——寫側走 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root</a>、守住不變式與一致性邊界；讀側走 <a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">read model</a>、只服務查詢要的形狀，不承擔寫入責任。</p>
<h2 id="概念位置">概念位置</h2>
<p>CQRS 是一道讀側設計階梯的頂端、而非二選一的開關。讀側的設計從「消費端自行投影 aggregate 形狀」開始，中間經過「抽獨立讀 port」「查詢形狀專用投影」，到頂端才是 CQRS 全套——讀寫獨立儲存、由 <a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a> 同步讀模型。多數專案的正確位置在階梯低處、不是頂端，升級由訊號驅動而非架構偏好。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>讀寫分離的討論從「查詢方法該不該搬出 repository」，逐漸變成「讀模型需不需要自己的儲存、能不能接受跟寫側短暫不一致」，是逼近階梯頂端的訊號——第四階的代價是最終一致性進入系統語意，畫面可能短暫顯示舊值，而這是設計內行為，不是 bug。</p>
<h2 id="設計責任">設計責任</h2>
<p>要不要上 CQRS 全套，不看「這個模式聽起來很適合」，看五個具體訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）是否命中——本卡只回答「CQRS 是什麼、階梯怎麼分階」，訊號的完整推導與升級路徑是 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a> 這篇的內容，要判斷自己的專案該停在哪一階、讀那篇。</p>
]]></content:encoded></item><item><title>自持狀態與可導出狀態：上游身份轉移時，誰要搬家、誰自動對齊</title><link>https://tarrragon.github.io/blog/work-log/pos_held_vs_derived_state_migration/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_held_vs_derived_state_migration/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS App 前端有多個服務各自持有「以單據 id 為 key」的狀態——輪詢的差異比對基準、事件追蹤記錄、當前品項快照。後端合併兩張單據後，舊 id 全部作廢，這些狀態何去何從？
&lt;strong>疑問來源&lt;/strong>：最初的直覺是「全部搬家」——把每一份狀態的 key 都改成新 id。實作後發現有些搬了是白搬、有些不搬會出大事，差異在哪？
&lt;strong>整理目的&lt;/strong>：把「上游身份轉移時的前端狀態遷移」整理成一個二分判準：自持 vs 可導出。
&lt;strong>本文邊界&lt;/strong>：讀側設計的完整階梯見 &lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-三份狀態兩種命運">1. 三份狀態、兩種命運&lt;/h2>
&lt;p>合併發生後，前端各服務持有的狀態逐一檢視：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>性質&lt;/th>
 &lt;th>遷移策略&lt;/th>
 &lt;th>不遷移的後果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>輪詢差異比對基準&lt;/td>
 &lt;td>&lt;strong>自持&lt;/strong>——「上一輪看到什麼」只存在於前端記憶，上游沒有這份資料&lt;/td>
 &lt;td>收到合併通知時把基準搬到新 id 下&lt;/td>
 &lt;td>新 id 查無基準 → 整張單據被誤判為新增 → 重複列印&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件追蹤記錄&lt;/td>
 &lt;td>&lt;strong>自持&lt;/strong>——事件當下的內容與處理進度，上游不保存這個視角&lt;/td>
 &lt;td>通知搬家（改掛新 id）&lt;/td>
 &lt;td>以 id 查詢的畫面從此找不到記錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>當前品項快照&lt;/td>
 &lt;td>&lt;strong>可導出&lt;/strong>——每一輪輪詢整批重建自上游&lt;/td>
 &lt;td>&lt;strong>不搬&lt;/strong>，下一輪同步自動對齊&lt;/td>
 &lt;td>至多一個輪詢週期的短暫過時&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>判準一句話：&lt;strong>這份狀態消失後，能不能單靠向上游重新查詢完整重建？&lt;/strong> 能 → 可導出，讓同步機制自然收斂；不能 → 自持，身份轉移時必須主動遷移。&lt;/p>
&lt;h2 id="2-兩個方向的分類錯誤">2. 兩個方向的分類錯誤&lt;/h2>
&lt;p>&lt;strong>把自持誤當可導出&lt;/strong>（沒搬家）：差異比對基準等不到任何同步來拯救——上游根本沒有「你上次看到什麼」的資料。結果是功能級事故（重複列印、記錄失聯）。&lt;/p>
&lt;p>&lt;strong>把可導出誤當自持&lt;/strong>（多搬家）：對快照做手工搬移，短期看似加速對齊，實際引入了第二個資料來源——搬移邏輯與同步邏輯對同一份狀態各寫一次，兩者的分岔遲早出現。本案初版就搬了快照，後來發現搬進去的是舊內容（明細 id 還是死的），真正的新內容要等下一輪同步——手工搬移只製造了「看起來已對齊」的假象。&lt;/p>
&lt;p>正確形態裡兩類狀態的程式碼形狀差異很清楚：自持狀態有一個「身份轉移通知」的入口方法；可導出狀態沒有任何遷移程式碼，只有既有的同步迴圈。&lt;/p>
&lt;h2 id="3-加速對齊的正確位置催一次同步而不是代替同步">3. 加速對齊的正確位置：催一次同步，而不是代替同步&lt;/h2>
&lt;p>可導出狀態「等下一輪」的延遲若不可接受（本案：合併後使用者可能立刻取消品項，需要新明細 id），正確做法不是手工搬——是&lt;strong>立即觸發一次同步&lt;/strong>。同一條同步路徑、只是提早跑。&lt;/p>
&lt;p>這裡還埋著一個順序陷阱：同步路徑內部有依賴（輪詢用本地單據列表過濾資料），催同步之前要先讓依賴就緒（先同步列表、再刷新快照），否則催出來的是空結果、反而污染比對基準。「催同步」不是一個呼叫，是一小段有順序約束的編排——這個順序錯誤實際發生過，由流程測試首跑抓到（&lt;a href="https://tarrragon.github.io/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6&lt;/a>）。&lt;/p>
&lt;h2 id="4-與讀模型階梯的關係">4. 與讀模型階梯的關係&lt;/h2>
&lt;p>「可導出狀態」就是讀模型精神在前端的形態：它是上游真相的投影，正確性靠「可隨時重建」保證，而不是靠維護它的每一次增量修改都正確。判準也同構——&lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>問「讀的形狀還是 aggregate（聚合根）的形狀」，這裡問「這份狀態的真相在誰手上」。真相在上游 → 投影化、別自持；真相只在本端 → 承認自持、為它設計完整的生命週期（包含身份轉移）。&lt;/p>
&lt;p>長期方向：自持狀態越少越好。本案的追蹤記錄之所以必須自持，是因為上游不保存事件的處理進度——若上游未來補上這份資料，追蹤記錄就能降級為投影，「合併搬家」的程式碼隨之刪除。&lt;strong>每一份自持狀態都是一筆負債，債主是所有會改變上游身份的操作。&lt;/strong>&lt;/p>
&lt;h2 id="5-可複用的判準">5. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>上游身份轉移時，對每份以上游 id 為 key 的狀態問：「消失後能否單靠重新查詢完整重建？」——能，不搬；不能，通知搬家。&lt;/li>
&lt;li>可導出狀態的加速對齊 = 催一次既有同步（注意內部順序依賴），不是另寫搬移邏輯。&lt;/li>
&lt;li>自持狀態要有明確的「身份轉移」入口，且它是所有身份轉移操作（合併、拆分）的必經站。&lt;/li>
&lt;li>盤點自持狀態清單、逐項問「上游為什麼不保存這個」——每消除一項，就少一類遷移 bug。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>自持 vs 可導出的遷移判準（理論層）→ &lt;a href="https://tarrragon.github.io/blog/ddd/cross-boundary-reference-ownership/" data-link-title="跨邊界參照與狀態所有權" data-link-desc="下游持有上游資料的 id、操作靠這個 id 回寫時：上游的哪些操作會讓 id 死亡、有沒有跨操作不變的穩定身份、身份轉移後本端的狀態搬不搬家——參照設計的判準與遷移分工">跨邊界參照與狀態所有權&lt;/a>&lt;/li>
&lt;li>讀側階梯的理論層 → &lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>&lt;/li>
&lt;li>身份轉移的參照層問題 → &lt;a href="https://tarrragon.github.io/blog/work-log/pos_cross_boundary_reference_lifecycle/" data-link-title="跨邊界參照的生命週期：前端凍結的 id，死活由後端決定" data-link-desc="POS App 的前端把後端資料的 id 凍結在本地追蹤記錄裡，後端的「合併」操作會重建資料——舊 id 全部失效，取消與追加功能無聲死亡。判準：跨邊界持有的每一個參照，都要回答「對方的哪些操作會讓它死」；穩定身份是實測出來的事實，不是推理出來的假設。">跨邊界參照的生命週期&lt;/a>&lt;/li>
&lt;li>順序陷阱被抓到的過程 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6 流程測試首跑抓到順序 bug&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS App 前端有多個服務各自持有「以單據 id 為 key」的狀態——輪詢的差異比對基準、事件追蹤記錄、當前品項快照。後端合併兩張單據後，舊 id 全部作廢，這些狀態何去何從？
<strong>疑問來源</strong>：最初的直覺是「全部搬家」——把每一份狀態的 key 都改成新 id。實作後發現有些搬了是白搬、有些不搬會出大事，差異在哪？
<strong>整理目的</strong>：把「上游身份轉移時的前端狀態遷移」整理成一個二分判準：自持 vs 可導出。
<strong>本文邊界</strong>：讀側設計的完整階梯見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。</p></blockquote>
<hr>
<h2 id="1-三份狀態兩種命運">1. 三份狀態、兩種命運</h2>
<p>合併發生後，前端各服務持有的狀態逐一檢視：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>性質</th>
          <th>遷移策略</th>
          <th>不遷移的後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>輪詢差異比對基準</td>
          <td><strong>自持</strong>——「上一輪看到什麼」只存在於前端記憶，上游沒有這份資料</td>
          <td>收到合併通知時把基準搬到新 id 下</td>
          <td>新 id 查無基準 → 整張單據被誤判為新增 → 重複列印</td>
      </tr>
      <tr>
          <td>事件追蹤記錄</td>
          <td><strong>自持</strong>——事件當下的內容與處理進度，上游不保存這個視角</td>
          <td>通知搬家（改掛新 id）</td>
          <td>以 id 查詢的畫面從此找不到記錄</td>
      </tr>
      <tr>
          <td>當前品項快照</td>
          <td><strong>可導出</strong>——每一輪輪詢整批重建自上游</td>
          <td><strong>不搬</strong>，下一輪同步自動對齊</td>
          <td>至多一個輪詢週期的短暫過時</td>
      </tr>
  </tbody>
</table>
<p>判準一句話：<strong>這份狀態消失後，能不能單靠向上游重新查詢完整重建？</strong> 能 → 可導出，讓同步機制自然收斂；不能 → 自持，身份轉移時必須主動遷移。</p>
<h2 id="2-兩個方向的分類錯誤">2. 兩個方向的分類錯誤</h2>
<p><strong>把自持誤當可導出</strong>（沒搬家）：差異比對基準等不到任何同步來拯救——上游根本沒有「你上次看到什麼」的資料。結果是功能級事故（重複列印、記錄失聯）。</p>
<p><strong>把可導出誤當自持</strong>（多搬家）：對快照做手工搬移，短期看似加速對齊，實際引入了第二個資料來源——搬移邏輯與同步邏輯對同一份狀態各寫一次，兩者的分岔遲早出現。本案初版就搬了快照，後來發現搬進去的是舊內容（明細 id 還是死的），真正的新內容要等下一輪同步——手工搬移只製造了「看起來已對齊」的假象。</p>
<p>正確形態裡兩類狀態的程式碼形狀差異很清楚：自持狀態有一個「身份轉移通知」的入口方法；可導出狀態沒有任何遷移程式碼，只有既有的同步迴圈。</p>
<h2 id="3-加速對齊的正確位置催一次同步而不是代替同步">3. 加速對齊的正確位置：催一次同步，而不是代替同步</h2>
<p>可導出狀態「等下一輪」的延遲若不可接受（本案：合併後使用者可能立刻取消品項，需要新明細 id），正確做法不是手工搬——是<strong>立即觸發一次同步</strong>。同一條同步路徑、只是提早跑。</p>
<p>這裡還埋著一個順序陷阱：同步路徑內部有依賴（輪詢用本地單據列表過濾資料），催同步之前要先讓依賴就緒（先同步列表、再刷新快照），否則催出來的是空結果、反而污染比對基準。「催同步」不是一個呼叫，是一小段有順序約束的編排——這個順序錯誤實際發生過，由流程測試首跑抓到（<a href="/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6</a>）。</p>
<h2 id="4-與讀模型階梯的關係">4. 與讀模型階梯的關係</h2>
<p>「可導出狀態」就是讀模型精神在前端的形態：它是上游真相的投影，正確性靠「可隨時重建」保證，而不是靠維護它的每一次增量修改都正確。判準也同構——<a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>問「讀的形狀還是 aggregate（聚合根）的形狀」，這裡問「這份狀態的真相在誰手上」。真相在上游 → 投影化、別自持；真相只在本端 → 承認自持、為它設計完整的生命週期（包含身份轉移）。</p>
<p>長期方向：自持狀態越少越好。本案的追蹤記錄之所以必須自持，是因為上游不保存事件的處理進度——若上游未來補上這份資料，追蹤記錄就能降級為投影，「合併搬家」的程式碼隨之刪除。<strong>每一份自持狀態都是一筆負債，債主是所有會改變上游身份的操作。</strong></p>
<h2 id="5-可複用的判準">5. 可複用的判準</h2>
<ol>
<li>上游身份轉移時，對每份以上游 id 為 key 的狀態問：「消失後能否單靠重新查詢完整重建？」——能，不搬；不能，通知搬家。</li>
<li>可導出狀態的加速對齊 = 催一次既有同步（注意內部順序依賴），不是另寫搬移邏輯。</li>
<li>自持狀態要有明確的「身份轉移」入口，且它是所有身份轉移操作（合併、拆分）的必經站。</li>
<li>盤點自持狀態清單、逐項問「上游為什麼不保存這個」——每消除一項，就少一類遷移 bug。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li>自持 vs 可導出的遷移判準（理論層）→ <a href="/blog/ddd/cross-boundary-reference-ownership/" data-link-title="跨邊界參照與狀態所有權" data-link-desc="下游持有上游資料的 id、操作靠這個 id 回寫時：上游的哪些操作會讓 id 死亡、有沒有跨操作不變的穩定身份、身份轉移後本端的狀態搬不搬家——參照設計的判準與遷移分工">跨邊界參照與狀態所有權</a></li>
<li>讀側階梯的理論層 → <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a></li>
<li>身份轉移的參照層問題 → <a href="/blog/work-log/pos_cross_boundary_reference_lifecycle/" data-link-title="跨邊界參照的生命週期：前端凍結的 id，死活由後端決定" data-link-desc="POS App 的前端把後端資料的 id 凍結在本地追蹤記錄裡，後端的「合併」操作會重建資料——舊 id 全部失效，取消與追加功能無聲死亡。判準：跨邊界持有的每一個參照，都要回答「對方的哪些操作會讓它死」；穩定身份是實測出來的事實，不是推理出來的假設。">跨邊界參照的生命週期</a></li>
<li>順序陷阱被抓到的過程 → <a href="/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6 流程測試首跑抓到順序 bug</a></li>
</ul>
]]></content:encoded></item></channel></rss>