<?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>State-Ownership on Tarragon</title><link>https://tarrragon.github.io/blog/tags/state-ownership/</link><description>Recent content in State-Ownership 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/state-ownership/index.xml" rel="self" type="application/rss+xml"/><item><title>Frozen vs Live Reference（凍結參照與活解析）</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/frozen-vs-live-reference/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/frozen-vs-live-reference/</guid><description>&lt;p>下游持有上游資料的 id 時，有兩條策略：凍結參照（寫死 id，後續操作直接使用）和活解析（以跨操作不變的識別鍵在查詢時動態解引用，取得當前有效的 id）。凍結參照在上游重建資料後失效；由測試餵資料的 stub 同時控制凍結值與回應資料，讓「id 失效」的狀態無法在測試中出現——這與 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽&lt;/a>是不同層次的盲區，遮蔽來自假設而非協議。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>凍結與活解析的分野是狀態所有權問題：凍結參照假設上游資料的 id 在下游生命週期內不會改變，活解析承認 id 可能失效、改由穩定的業務鍵重新定位。這個區分跨越測試與領域設計——領域層決定哪些 id 會被上游操作重建（合併、拆分、歸檔），測試層要覆蓋「重建後舊 id 指向已刪除資料」的劇本。使用&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端&lt;/a>自身模擬重建行為，是讓這類劇本可測的前提。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>操作在特定條件後全部靜默失敗（回寫目標不存在），且單元測試全綠。典型案例：後端合併兩張單據時重建全部明細並更換 id，前端凍結的舊 id 全部指向已刪除資料（&lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a>）。修復同樣受盲區支配——第一版只處理單據層 id、遺漏明細層，因為明細 id 在測試裡從未死過。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>測試設計要回答「哪些上游操作會讓凍結的 id 死亡」，並把每個死亡路徑做成測試劇本。用有狀態的假後端取代 stub（假後端自行模擬重建行為）才能讓活解析邏輯在測試中被真正驅動。「什麼會變、什麼不變」是事實問題——合法出處是可驗的契約文件或一次實測取證，推理與記憶都不算出處。跨邊界的 id 存活規則同時是領域設計的責任範圍。&lt;/p></description><content:encoded><![CDATA[<p>下游持有上游資料的 id 時，有兩條策略：凍結參照（寫死 id，後續操作直接使用）和活解析（以跨操作不變的識別鍵在查詢時動態解引用，取得當前有效的 id）。凍結參照在上游重建資料後失效；由測試餵資料的 stub 同時控制凍結值與回應資料，讓「id 失效」的狀態無法在測試中出現——這與 <a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽</a>是不同層次的盲區，遮蔽來自假設而非協議。</p>
<h2 id="概念位置">概念位置</h2>
<p>凍結與活解析的分野是狀態所有權問題：凍結參照假設上游資料的 id 在下游生命週期內不會改變，活解析承認 id 可能失效、改由穩定的業務鍵重新定位。這個區分跨越測試與領域設計——領域層決定哪些 id 會被上游操作重建（合併、拆分、歸檔），測試層要覆蓋「重建後舊 id 指向已刪除資料」的劇本。使用<a href="/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端</a>自身模擬重建行為，是讓這類劇本可測的前提。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>操作在特定條件後全部靜默失敗（回寫目標不存在），且單元測試全綠。典型案例：後端合併兩張單據時重建全部明細並更換 id，前端凍結的舊 id 全部指向已刪除資料（<a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a>）。修復同樣受盲區支配——第一版只處理單據層 id、遺漏明細層，因為明細 id 在測試裡從未死過。</p>
<h2 id="設計責任">設計責任</h2>
<p>測試設計要回答「哪些上游操作會讓凍結的 id 死亡」，並把每個死亡路徑做成測試劇本。用有狀態的假後端取代 stub（假後端自行模擬重建行為）才能讓活解析邏輯在測試中被真正驅動。「什麼會變、什麼不變」是事實問題——合法出處是可驗的契約文件或一次實測取證，推理與記憶都不算出處。跨邊界的 id 存活規則同時是領域設計的責任範圍。</p>
]]></content:encoded></item><item><title>跨邊界參照與狀態所有權</title><link>https://tarrragon.github.io/blog/ddd/cross-boundary-reference-ownership/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/cross-boundary-reference-ownership/</guid><description>&lt;p>領域模型跨越邊界持有的每一個 id，有效性由上游行為決定、持有端控制不了。上游把「更新」實作成「刪除重建」是實作自由，下游用推理猜「它應該保留」擋不住——合法的出處只有契約文件或一次實測取證。這條事實推導出本章三個判準的共同源頭：跨邊界參照的設計，從「對方會做什麼」開始、而不是從「我要存什麼」開始。&lt;/p>
&lt;p>本章承接 &lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a> 的身份語意討論，延伸到身份跨越系統邊界之後的生存問題；與 &lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a> 的凍結端點段落互補——凍結是時間軸上的問題（何時），本章談的是空間軸上的問題（誰的 id、誰決定它活著）。&lt;/p>
&lt;h2 id="參照有效性三問">參照有效性三問&lt;/h2>
&lt;p>下游持有上游資料的 id 時，設計階段逐一回答：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>對方的哪些操作會讓這個 id 死亡？&lt;/strong> 合併、拆分、覆蓋式更新、重建式修復——任何「刪舊建新」的實作都會讓舊 id 失效。盲區集中在業務動詞與實作的語意落差：同一個「合併」，有的實作是搬資料、有的實作是建新刪舊。持有端用前者的假設設計、遇到後者的實作時靜默失效。&lt;/li>
&lt;li>&lt;strong>有沒有一個跨越這些操作仍不變的身份？&lt;/strong> 如果存在——以它為錨、其餘參照動態解析。如果沒有——凍結參照只能當一次性用途、跨操作的功能改走查詢。&lt;/li>
&lt;li>&lt;strong>「不變」的出處是什麼？&lt;/strong> 文件說的、程式碼推理的、實測證實的——只有第三種算數。把實測結果固化為測試資產（&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>），從此不依賴任何人的記憶。&lt;/li>
&lt;/ol>
&lt;p>三個問題的回答決定參照的設計形態。未回答就動工的代價是靜默失效——操作對著死 id 回寫，回傳值看起來正常（目標不存在也沒有噪音），功能在特定操作之後全部失效、直到使用者回報。凍結參照在測試中的結構盲區另見 &lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a>：由測試餵資料的 stub 同時控制凍結值與回應資料，讓「id 死亡」這個狀態在測試裡不可能出現。&lt;/p>
&lt;h2 id="穩定身份為錨解引用延後到使用時">穩定身份為錨、解引用延後到使用時&lt;/h2>
&lt;p>第二題的答案若為「存在」，設計形態收斂為：持有穩定身份、其餘參照在使用時動態解析。&lt;/p>
&lt;p>「reference by identity + 解引用時機延後」把「id → 實體」的解析從持有時延後到使用時，讓解析結果反映上游當前的真相。額外的韌性：即使身份轉移發生在本端不知情的時候（另一台裝置觸發合併），使用時解析照樣找到新家。&lt;/p>
&lt;p>查無結果時退回凍結值，搭配存在性檢查兜底——穩定身份的值域（如 uuid）讓失效的凍結值只會被擋下、不會誤中別的資料。退路的成本是一次額外查詢；退路被命中的頻率反映的是同步的及時性——頻率高代表催同步的時機需要調整（見下節）。&lt;/p>
&lt;p>第二題的答案若為「不存在」，凍結參照是唯一手段，但它的有效期等於上游不做重建操作的期間。有效期內凍結合法；跨越有效期的功能需要改走查詢路徑——以業務鍵（訂單號、合約號這類人為穩定的識別碼）重新定位，而非依賴上游的技術 id。&lt;/p>
&lt;h2 id="自持狀態與可導出狀態遷移分工">自持狀態與可導出狀態：遷移分工&lt;/h2>
&lt;p>上游身份轉移（合併、拆分）發生後，下游持有的多份「以上游 id 為 key」的狀態面臨遷移問題。遷移判準一個問題就夠：&lt;strong>這份狀態消失後，能否單靠向上游重新查詢完整重建？&lt;/strong>&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;/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;td>誤當可導出 → 上游沒有這份資料、同步等不到救援、功能級事故&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩類狀態的程式碼形狀差異清楚：自持狀態有一個「身份轉移通知」的入口方法，且它是所有身份轉移操作的必經站；可導出狀態沒有任何遷移程式碼，只有既有的同步迴圈。&lt;/p>
&lt;p>可導出狀態「等下一輪同步」的延遲若不可接受，正確的加速做法是&lt;strong>催一次既有同步&lt;/strong>——走同一條同步路徑、只是提早跑。催同步與手工搬移的差異在於：催同步後資料來源仍然只有上游這一份，手工搬移會製造第二份——兩份資料的分岔只是時間問題。催同步時注意同步路徑內部的順序依賴（路徑內的過濾機制可能依賴某份前置資料的新鮮度），順序錯了催出來的是空結果。&lt;/p>
&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> 同構：讀模型問「讀的形狀還是聚合根的形狀」，這裡問「這份狀態的真相在誰手上」。&lt;/p>
&lt;h2 id="凍結快照的正當用途">凍結快照的正當用途&lt;/h2>
&lt;p>凍結參照應消滅、活解析應取代——這條原則有一個明確的邊界：呈現歷史事實的快照是凍結的正當用途。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>用途&lt;/th>
 &lt;th>正確形態&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>呈現歷史事實（結帳當下的價格）&lt;/td>
 &lt;td>凍結 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&lt;/a>——上游變動不該影響它&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>回寫當前狀態（取消、追加）&lt;/td>
 &lt;td>活解析——必須命中上游當前的資料&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>同一筆記錄可以同時包含兩種欄位。出事的案例往往是把「顯示用的凍結值」順手拿去做「回寫用的定位」——凍結值在這個用途下跨越了它的有效期，失效的時機由上游的重建行為決定、持有端察覺不到。凍結時機的判準在 &lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a> 各自從身份語意和稽核面到達同一個結論：歷史記錄反映事件發生當下的世界。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;ul>
&lt;li>特定操作之後某些功能靜默失效（回寫目標不存在、查詢結果為空），且單元測試全綠——跨邊界參照的 id 可能已死亡。先確認上游操作的「保留 vs 重建」語意，再看持有端的解析時機。&lt;/li>
&lt;li>修復 bug 只搬了一層 id、遺漏另一層——同一個業務動詞涉及多層資料（單據 + 明細 + 記錄），每層的重建行為要逐層實測、修復的完整性以層數計。&lt;/li>
&lt;li>手工搬移的程式碼與同步邏輯在同一份可導出狀態上各寫一次——搬移是多餘的第二份資料來源，改成催一次同步。&lt;/li>
&lt;li>同步催了卻拿到空結果——同步路徑有內部順序依賴（過濾器讀的前置資料還是舊的），催同步前先確保前置資料已更新。&lt;/li>
&lt;li>自持狀態的數量持續增長——逐項問「上游為什麼不保存」，每消除一項就少一類遷移 bug。&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>身份語意的入口判準 → &lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a>&lt;/li>
&lt;li>凍結與稽核的時間軸判準 → &lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&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/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽&lt;/a>&lt;/li>
&lt;li>假後端模擬重建行為 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>&lt;/li>
&lt;li>參照層的 case → &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>遷移層的 case → &lt;a href="https://tarrragon.github.io/blog/work-log/pos_held_vs_derived_state_migration/" data-link-title="自持狀態與可導出狀態：上游身份轉移時，誰要搬家、誰自動對齊" data-link-desc="後端合併操作讓資料換了身份，前端持有的多份「以舊 id 為 key」的狀態怎麼辦？POS App 的答案分兩類：能從上游重新導出的狀態不用管、下一輪同步自動對齊；必須自行持有的狀態（差異比對基準、追蹤記錄）才需要通知搬家。分類錯誤的代價是兩個方向的 bug。">自持狀態與可導出狀態&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>領域模型跨越邊界持有的每一個 id，有效性由上游行為決定、持有端控制不了。上游把「更新」實作成「刪除重建」是實作自由，下游用推理猜「它應該保留」擋不住——合法的出處只有契約文件或一次實測取證。這條事實推導出本章三個判準的共同源頭：跨邊界參照的設計，從「對方會做什麼」開始、而不是從「我要存什麼」開始。</p>
<p>本章承接 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a> 的身份語意討論，延伸到身份跨越系統邊界之後的生存問題；與 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a> 的凍結端點段落互補——凍結是時間軸上的問題（何時），本章談的是空間軸上的問題（誰的 id、誰決定它活著）。</p>
<h2 id="參照有效性三問">參照有效性三問</h2>
<p>下游持有上游資料的 id 時，設計階段逐一回答：</p>
<ol>
<li><strong>對方的哪些操作會讓這個 id 死亡？</strong> 合併、拆分、覆蓋式更新、重建式修復——任何「刪舊建新」的實作都會讓舊 id 失效。盲區集中在業務動詞與實作的語意落差：同一個「合併」，有的實作是搬資料、有的實作是建新刪舊。持有端用前者的假設設計、遇到後者的實作時靜默失效。</li>
<li><strong>有沒有一個跨越這些操作仍不變的身份？</strong> 如果存在——以它為錨、其餘參照動態解析。如果沒有——凍結參照只能當一次性用途、跨操作的功能改走查詢。</li>
<li><strong>「不變」的出處是什麼？</strong> 文件說的、程式碼推理的、實測證實的——只有第三種算數。把實測結果固化為測試資產（<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>、<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>），從此不依賴任何人的記憶。</li>
</ol>
<p>三個問題的回答決定參照的設計形態。未回答就動工的代價是靜默失效——操作對著死 id 回寫，回傳值看起來正常（目標不存在也沒有噪音），功能在特定操作之後全部失效、直到使用者回報。凍結參照在測試中的結構盲區另見 <a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a>：由測試餵資料的 stub 同時控制凍結值與回應資料，讓「id 死亡」這個狀態在測試裡不可能出現。</p>
<h2 id="穩定身份為錨解引用延後到使用時">穩定身份為錨、解引用延後到使用時</h2>
<p>第二題的答案若為「存在」，設計形態收斂為：持有穩定身份、其餘參照在使用時動態解析。</p>
<p>「reference by identity + 解引用時機延後」把「id → 實體」的解析從持有時延後到使用時，讓解析結果反映上游當前的真相。額外的韌性：即使身份轉移發生在本端不知情的時候（另一台裝置觸發合併），使用時解析照樣找到新家。</p>
<p>查無結果時退回凍結值，搭配存在性檢查兜底——穩定身份的值域（如 uuid）讓失效的凍結值只會被擋下、不會誤中別的資料。退路的成本是一次額外查詢；退路被命中的頻率反映的是同步的及時性——頻率高代表催同步的時機需要調整（見下節）。</p>
<p>第二題的答案若為「不存在」，凍結參照是唯一手段，但它的有效期等於上游不做重建操作的期間。有效期內凍結合法；跨越有效期的功能需要改走查詢路徑——以業務鍵（訂單號、合約號這類人為穩定的識別碼）重新定位，而非依賴上游的技術 id。</p>
<h2 id="自持狀態與可導出狀態遷移分工">自持狀態與可導出狀態：遷移分工</h2>
<p>上游身份轉移（合併、拆分）發生後，下游持有的多份「以上游 id 為 key」的狀態面臨遷移問題。遷移判準一個問題就夠：<strong>這份狀態消失後，能否單靠向上游重新查詢完整重建？</strong></p>
<table>
  <thead>
      <tr>
          <th>答案</th>
          <th>分類</th>
          <th>遷移策略</th>
          <th>錯分的代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>能</td>
          <td>可導出</td>
          <td>不搬家，讓既有同步機制自然收斂</td>
          <td>誤當自持 → 手工搬移引入第二個資料來源、搬移邏輯與同步邏輯分岔</td>
      </tr>
      <tr>
          <td>不能</td>
          <td>自持</td>
          <td>身份轉移時通知搬家，設明確的轉移入口方法</td>
          <td>誤當可導出 → 上游沒有這份資料、同步等不到救援、功能級事故</td>
      </tr>
  </tbody>
</table>
<p>兩類狀態的程式碼形狀差異清楚：自持狀態有一個「身份轉移通知」的入口方法，且它是所有身份轉移操作的必經站；可導出狀態沒有任何遷移程式碼，只有既有的同步迴圈。</p>
<p>可導出狀態「等下一輪同步」的延遲若不可接受，正確的加速做法是<strong>催一次既有同步</strong>——走同一條同步路徑、只是提早跑。催同步與手工搬移的差異在於：催同步後資料來源仍然只有上游這一份，手工搬移會製造第二份——兩份資料的分岔只是時間問題。催同步時注意同步路徑內部的順序依賴（路徑內的過濾機制可能依賴某份前置資料的新鮮度），順序錯了催出來的是空結果。</p>
<p>自持狀態的每一份都是一筆負債，債主是所有會改變上游身份的操作。盤點自持狀態清單、逐項問「上游為什麼不保存這個」——若上游未來補上這份資料，自持狀態就能降級為投影，遷移程式碼隨之刪除。這個分類判準與 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a> 同構：讀模型問「讀的形狀還是聚合根的形狀」，這裡問「這份狀態的真相在誰手上」。</p>
<h2 id="凍結快照的正當用途">凍結快照的正當用途</h2>
<p>凍結參照應消滅、活解析應取代——這條原則有一個明確的邊界：呈現歷史事實的快照是凍結的正當用途。</p>
<table>
  <thead>
      <tr>
          <th>用途</th>
          <th>正確形態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>呈現歷史事實（結帳當下的價格）</td>
          <td>凍結 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a>——上游變動不該影響它</td>
      </tr>
      <tr>
          <td>回寫當前狀態（取消、追加）</td>
          <td>活解析——必須命中上游當前的資料</td>
      </tr>
  </tbody>
</table>
<p>同一筆記錄可以同時包含兩種欄位。出事的案例往往是把「顯示用的凍結值」順手拿去做「回寫用的定位」——凍結值在這個用途下跨越了它的有效期，失效的時機由上游的重建行為決定、持有端察覺不到。凍結時機的判準在 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a> 與 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a> 各自從身份語意和稽核面到達同一個結論：歷史記錄反映事件發生當下的世界。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>特定操作之後某些功能靜默失效（回寫目標不存在、查詢結果為空），且單元測試全綠——跨邊界參照的 id 可能已死亡。先確認上游操作的「保留 vs 重建」語意，再看持有端的解析時機。</li>
<li>修復 bug 只搬了一層 id、遺漏另一層——同一個業務動詞涉及多層資料（單據 + 明細 + 記錄），每層的重建行為要逐層實測、修復的完整性以層數計。</li>
<li>手工搬移的程式碼與同步邏輯在同一份可導出狀態上各寫一次——搬移是多餘的第二份資料來源，改成催一次同步。</li>
<li>同步催了卻拿到空結果——同步路徑有內部順序依賴（過濾器讀的前置資料還是舊的），催同步前先確保前置資料已更新。</li>
<li>自持狀態的數量持續增長——逐項問「上游為什麼不保存」，每消除一項就少一類遷移 bug。</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>身份語意的入口判準 → <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a></li>
<li>凍結與稽核的時間軸判準 → <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</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/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽</a></li>
<li>假後端模擬重建行為 → <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>參照層的 case → <a href="/blog/work-log/pos_cross_boundary_reference_lifecycle/" data-link-title="跨邊界參照的生命週期：前端凍結的 id，死活由後端決定" data-link-desc="POS App 的前端把後端資料的 id 凍結在本地追蹤記錄裡，後端的「合併」操作會重建資料——舊 id 全部失效，取消與追加功能無聲死亡。判準：跨邊界持有的每一個參照，都要回答「對方的哪些操作會讓它死」；穩定身份是實測出來的事實，不是推理出來的假設。">跨邊界參照的生命週期</a></li>
<li>遷移層的 case → <a href="/blog/work-log/pos_held_vs_derived_state_migration/" data-link-title="自持狀態與可導出狀態：上游身份轉移時，誰要搬家、誰自動對齊" data-link-desc="後端合併操作讓資料換了身份，前端持有的多份「以舊 id 為 key」的狀態怎麼辦？POS App 的答案分兩類：能從上游重新導出的狀態不用管、下一輪同步自動對齊；必須自行持有的狀態（差異比對基準、追蹤記錄）才需要通知搬家。分類錯誤的代價是兩個方向的 bug。">自持狀態與可導出狀態</a></li>
</ul>
]]></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>