<?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>Projection on Tarragon</title><link>https://tarrragon.github.io/blog/tags/projection/</link><description>Recent content in Projection on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 17 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/projection/index.xml" rel="self" type="application/rss+xml"/><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>