<?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>Aggregate on Tarragon</title><link>https://tarrragon.github.io/blog/tags/aggregate/</link><description>Recent content in Aggregate 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/aggregate/index.xml" rel="self" type="application/rss+xml"/><item><title>跨邊界參照的生命週期：前端凍結的 id，死活由後端決定</title><link>https://tarrragon.github.io/blog/work-log/pos_cross_boundary_reference_lifecycle/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_cross_boundary_reference_lifecycle/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS App 的前端為後端事件建立本地追蹤記錄，記錄裡存了事件當下的兩個後端 id（單據 id、明細列 id），後續的取消、追加操作用這兩個 id 回寫。後端執行「合併兩張單據」後，這些操作全部失效。
&lt;strong>疑問來源&lt;/strong>：合併後前端已把記錄「改掛」到新單據——為什麼還是壞？
&lt;strong>整理目的&lt;/strong>：把「跨邊界參照的生命週期」整理成可判準的問題清單：哪些 id 會死、哪個 id 不死、持有端該怎麼設計。
&lt;strong>本文邊界&lt;/strong>：測試層的對策（語意級假後端）另見 &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;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-事故解剖兩層-id兩種命運">1. 事故解剖：兩層 id、兩種命運&lt;/h2>
&lt;p>後端「合併兩張單據」的實際行為（埋 log 實測，非文件推理）：&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>凍結的單據 id 死亡&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>明細列&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;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第一版修復只處理了單據層（合併後通知本地記錄改掛新單據 id），明細層的 id 照樣死——因為修復者的心智模型裡「合併」是搬家，而後端的實作是&lt;strong>重生&lt;/strong>。同一個業務動詞，兩端對「什麼保留、什麼重建」的理解不同，這正是跨邊界參照最危險的地方。&lt;/p>
&lt;h2 id="2-判準每一個跨邊界參照都要回答三個問題">2. 判準：每一個跨邊界參照都要回答三個問題&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>（本案是事件記錄 id——後端只改它的外鍵）&lt;/li>
&lt;li>&lt;strong>這個「不變」是文件說的、推理出的，還是實測證實的？&lt;/strong>——只有第三種算數。上游把「更新」實作成「刪除重建」是實作自由，下游的推理攔不住。&lt;/li>
&lt;/ol>
&lt;p>第二題的答案決定架構：存在穩定身份 → 以它為錨、其餘參照動態解析；不存在 → 凍結參照只能當一次性用途，跨操作的功能要改走查詢。&lt;/p>
&lt;h2 id="3-模式穩定身份為錨活解析為主凍結值為退路">3. 模式：穩定身份為錨、活解析為主、凍結值為退路&lt;/h2>
&lt;p>修復後的形態：&lt;/p>
&lt;ul>
&lt;li>本地記錄仍凍結事件當下的 id（顯示用途夠用，也是最後退路）&lt;/li>
&lt;li>需要&lt;strong>回寫&lt;/strong>的操作（取消、追加）不信任凍結值——以穩定身份（事件 id）向當前快照反查「現在有效的單據 id＋明細 id」&lt;/li>
&lt;li>查無（同步空窗、資料已消失）才退回凍結值，且下游有存在性檢查兜底——id 是 uuid，失效的凍結值只會被擋下，不會誤中別的資料&lt;/li>
&lt;/ul>
&lt;p>這個模式的通用名字是「reference by identity + 解引用時機延後」：把「id → 實體」的解析從&lt;strong>持有時&lt;/strong>延後到&lt;strong>使用時&lt;/strong>，讓解析結果永遠反映上游當前的真相。額外的紅利是跨端韌性——即使身份轉移發生在本端不知情的時候（另一台裝置觸發合併），使用時解析照樣找得到新家。&lt;/p>
&lt;h2 id="4-邊界情況凍結有凍結的正當用途">4. 邊界情況：凍結有凍結的正當用途&lt;/h2>
&lt;p>不是所有凍結參照都該消滅。同一個專案裡，「已結帳品項」的商品資訊就是&lt;strong>刻意凍結的快照&lt;/strong>——結帳當下的名稱與價格，本來就不該隨商品目錄後續的修改而變。判準的分水嶺：&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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>回寫當前狀態（取消、追加）&lt;/td>
 &lt;td>活解析——必須命中上游當前的資料&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>同一筆記錄可以同時包含兩種欄位；出事的專案往往是把「顯示用的凍結快照」順手拿去做「回寫用的定位」。&lt;/p>
&lt;h2 id="5-可複用的判準">5. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>跨邊界持有的每個 id，設計時回答：誰會殺它、什麼不會死、證據是實測還是推理。&lt;/li>
&lt;li>回寫操作以穩定身份使用時解析；凍結值只做顯示與最後退路。&lt;/li>
&lt;li>上游動詞的「保留 vs 重建」語意要實測一次、固化為測試資產（假後端＋真實後端驗證），不要留在人的記憶。&lt;/li>
&lt;li>區分快照欄位的用途：呈現歷史 → 凍結正確；定位回寫 → 凍結是地雷。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&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;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>參照有效性與狀態所有權的理論層 → &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/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/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS App 的前端為後端事件建立本地追蹤記錄，記錄裡存了事件當下的兩個後端 id（單據 id、明細列 id），後續的取消、追加操作用這兩個 id 回寫。後端執行「合併兩張單據」後，這些操作全部失效。
<strong>疑問來源</strong>：合併後前端已把記錄「改掛」到新單據——為什麼還是壞？
<strong>整理目的</strong>：把「跨邊界參照的生命週期」整理成可判準的問題清單：哪些 id 會死、哪個 id 不死、持有端該怎麼設計。
<strong>本文邊界</strong>：測試層的對策（語意級假後端）另見 <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>。</p></blockquote>
<hr>
<h2 id="1-事故解剖兩層-id兩種命運">1. 事故解剖：兩層 id、兩種命運</h2>
<p>後端「合併兩張單據」的實際行為（埋 log 實測，非文件推理）：</p>
<table>
  <thead>
      <tr>
          <th>資料</th>
          <th>合併時的命運</th>
          <th>對前端凍結參照的意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單據</td>
          <td>建新單據、刪舊單據</td>
          <td>凍結的單據 id 死亡</td>
      </tr>
      <tr>
          <td>明細列</td>
          <td>全部重建（全新 id）</td>
          <td>凍結的明細 id 死亡</td>
      </tr>
      <tr>
          <td>事件記錄</td>
          <td><strong>保留，只改外鍵</strong></td>
          <td>事件 id 是唯一跨合併不變的身份</td>
      </tr>
  </tbody>
</table>
<p>第一版修復只處理了單據層（合併後通知本地記錄改掛新單據 id），明細層的 id 照樣死——因為修復者的心智模型裡「合併」是搬家，而後端的實作是<strong>重生</strong>。同一個業務動詞，兩端對「什麼保留、什麼重建」的理解不同，這正是跨邊界參照最危險的地方。</p>
<h2 id="2-判準每一個跨邊界參照都要回答三個問題">2. 判準：每一個跨邊界參照都要回答三個問題</h2>
<p>前端（或任何下游系統）持有上游資料的 id 時，這個參照的有效性完全由上游的行為決定。設計時逐一回答：</p>
<ol>
<li><strong>對方的哪些操作會讓這個 id 死亡？</strong>（合併、拆分、覆蓋式更新、重建式修復——任何「刪舊建新」的實作都是 id 屠宰場）</li>
<li><strong>有沒有一個跨越這些操作仍不變的身份？</strong>（本案是事件記錄 id——後端只改它的外鍵）</li>
<li><strong>這個「不變」是文件說的、推理出的，還是實測證實的？</strong>——只有第三種算數。上游把「更新」實作成「刪除重建」是實作自由，下游的推理攔不住。</li>
</ol>
<p>第二題的答案決定架構：存在穩定身份 → 以它為錨、其餘參照動態解析；不存在 → 凍結參照只能當一次性用途，跨操作的功能要改走查詢。</p>
<h2 id="3-模式穩定身份為錨活解析為主凍結值為退路">3. 模式：穩定身份為錨、活解析為主、凍結值為退路</h2>
<p>修復後的形態：</p>
<ul>
<li>本地記錄仍凍結事件當下的 id（顯示用途夠用，也是最後退路）</li>
<li>需要<strong>回寫</strong>的操作（取消、追加）不信任凍結值——以穩定身份（事件 id）向當前快照反查「現在有效的單據 id＋明細 id」</li>
<li>查無（同步空窗、資料已消失）才退回凍結值，且下游有存在性檢查兜底——id 是 uuid，失效的凍結值只會被擋下，不會誤中別的資料</li>
</ul>
<p>這個模式的通用名字是「reference by identity + 解引用時機延後」：把「id → 實體」的解析從<strong>持有時</strong>延後到<strong>使用時</strong>，讓解析結果永遠反映上游當前的真相。額外的紅利是跨端韌性——即使身份轉移發生在本端不知情的時候（另一台裝置觸發合併），使用時解析照樣找得到新家。</p>
<h2 id="4-邊界情況凍結有凍結的正當用途">4. 邊界情況：凍結有凍結的正當用途</h2>
<p>不是所有凍結參照都該消滅。同一個專案裡，「已結帳品項」的商品資訊就是<strong>刻意凍結的快照</strong>——結帳當下的名稱與價格，本來就不該隨商品目錄後續的修改而變。判準的分水嶺：</p>
<table>
  <thead>
      <tr>
          <th>用途</th>
          <th>正確形態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>呈現歷史事實（結帳當下的價格）</td>
          <td>凍結快照——上游變動不該影響它</td>
      </tr>
      <tr>
          <td>回寫當前狀態（取消、追加）</td>
          <td>活解析——必須命中上游當前的資料</td>
      </tr>
  </tbody>
</table>
<p>同一筆記錄可以同時包含兩種欄位；出事的專案往往是把「顯示用的凍結快照」順手拿去做「回寫用的定位」。</p>
<h2 id="5-可複用的判準">5. 可複用的判準</h2>
<ol>
<li>跨邊界持有的每個 id，設計時回答：誰會殺它、什麼不會死、證據是實測還是推理。</li>
<li>回寫操作以穩定身份使用時解析；凍結值只做顯示與最後退路。</li>
<li>上游動詞的「保留 vs 重建」語意要實測一次、固化為測試資產（假後端＋真實後端驗證），不要留在人的記憶。</li>
<li>區分快照欄位的用途：呈現歷史 → 凍結正確；定位回寫 → 凍結是地雷。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<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>、<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>參照有效性與狀態所有權的理論層 → <a href="/blog/ddd/cross-boundary-reference-ownership/" data-link-title="跨邊界參照與狀態所有權" data-link-desc="下游持有上游資料的 id、操作靠這個 id 回寫時：上游的哪些操作會讓 id 死亡、有沒有跨操作不變的穩定身份、身份轉移後本端的狀態搬不搬家——參照設計的判準與遷移分工">跨邊界參照與狀態所有權</a></li>
<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/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a></li>
</ul>
]]></content:encoded></item><item><title>文件裡的扁平 Product、程式碼裡的雙層聚合 — 宣稱型文件的半衰期</title><link>https://tarrragon.github.io/blog/work-log/pos_product_model_doc_vs_code_evolution/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_product_model_doc_vs_code_evolution/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：整理 POS 專案時對照 &lt;code>doc/PRODUCT_MODEL_REFACTOR.md&lt;/code> 跟 &lt;code>lib/data/models/product/product.dart&lt;/code>——文件描述的 Product 是扁平結構（barcode、price、stockCount 直接掛在商品上），現行程式碼是 Product + ProductSpecification 雙層、幾乎沒有一個欄位還在文件說的位置
&lt;strong>疑問來源&lt;/strong>：這份文件錯了嗎？還是它只是過期了？兩者的差異本身能教什麼？
&lt;strong>整理目的&lt;/strong>：記下扁平商品模型被真實業務打破的具體壓力、欄位歸屬的判準、以及宣稱型文件的正確用法
&lt;strong>本文邊界&lt;/strong>：素材是該專案的 refactor 總結文件（早期）與現行 model；「落差」是十個月演化的累積、不是單次重構的 before/after&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="文件的版本一商品一價一庫存">文件的版本：一商品、一價、一庫存&lt;/h2>
&lt;p>refactor 總結文件裡的 Product 是教科書式的扁平 model：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="kd">factory&lt;/span> &lt;span class="n">Product&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">required&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">barcode&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">required&lt;/span> &lt;span class="kt">double&lt;/span> &lt;span class="n">price&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">Default&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">0.0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">double&lt;/span> &lt;span class="n">discount&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="kd">required&lt;/span> &lt;span class="kt">double&lt;/span> &lt;span class="n">currentPrice&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">Default&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">stockCount&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">Default&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">category&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>搭配 &lt;code>updateStock&lt;/code> / &lt;code>reduceStock&lt;/code> / &lt;code>applyDiscount&lt;/code> 業務方法、跟一張「未來擴展」清單：商品分類管理、庫存管理、折扣策略、商品圖片。文件當時的重構是成立的（把散裝參數收成 model、UI 從六個參數變一個物件）——問題不在那次重構、在這個結構隱含的假設：&lt;strong>一個商品有一個條碼、一個價格、一份庫存&lt;/strong>。&lt;/p>
&lt;h2 id="業務打破它的方式規格">業務打破它的方式：規格&lt;/h2>
&lt;p>真實 POS 的第一批客戶就帶著飲料店跟零售的需求：中杯與大杯是同一個商品的兩個&lt;strong>規格&lt;/strong>，各自有條碼、售價、會員價、進價、庫存、甚至各自的圖片。現行的 model 把這個現實建成雙層：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">abstract&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">ProductSpecification&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="c1">// 會被規格分化的一切
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">barcode&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">required&lt;/span> &lt;span class="n">Money&lt;/span> &lt;span class="n">sellingPrice&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">purchasePrice&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">memberPrice&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">Money&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">cost&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rebate&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">Default&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">inventory&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kt">bool&lt;/span> &lt;span class="n">enableIgnoreInventory&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">Cover&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">cover&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">abstract&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">Product&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="c1">// 跨規格共用的資訊
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="n">ProductCategory&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">productCategory&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="n">Brand&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">brand&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">Supplier&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">supplier&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Tag&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">tags&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">CustomizationOption&lt;/span>&lt;span class="o">&amp;gt;?&lt;/span> &lt;span class="n">customizationOptions&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ProductSpecification&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">specifications&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="n">Device&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">kitchenDevice&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 廚房出單機路由
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>欄位歸屬的判準收成一句：&lt;strong>問「兩個規格會不會不同」&lt;/strong>。條碼會（中杯大杯各一個店內碼）、價格會（三種價都會）、庫存會——下沉到 spec；名稱、品牌、供應商、分類、客製化選項、廚房路由不會——留在聚合根。price 的取得也跟著變成規格層的方法（&lt;code>spec.getPrice(isMember)&lt;/code>），「商品的價格」這個問法在新結構裡根本不成立——只有「某規格對某身分的價格」。&lt;/p>
&lt;p>型別也在同一段演化裡逐級升級：&lt;code>double&lt;/code> 價格換成 &lt;code>Money&lt;/code>（&lt;a href="https://tarrragon.github.io/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">三段遷移&lt;/a>）、&lt;code>String category&lt;/code> 換成 &lt;code>ProductCategory&lt;/code> model、裸 URL 換成 &lt;code>Cover&lt;/code> model。扁平版留下的痕跡只剩一個向後兼容 getter（&lt;code>productName =&amp;gt; name&lt;/code>）。&lt;/p>
&lt;h2 id="預言全中路徑全錯">預言全中、路徑全錯&lt;/h2>
&lt;p>值得玩味的是文件的「未來擴展」清單——商品分類、庫存、折扣、圖片——&lt;strong>每一項都發生了&lt;/strong>，但沒有一項是「在扁平模型上加欄位」實現的：分類變成獨立 model、庫存變成 per-spec 欄位加 &lt;code>enableIgnoreInventory&lt;/code> 開關、折扣走進 &lt;code>CartItem.discount&lt;/code> 與會員價機制、圖片變成 spec 與商品兩層的 &lt;code>Cover&lt;/code>。&lt;/p>
&lt;p>這是 YAGNI 最好的論據形式：&lt;strong>預測「會有什麼需求」不難、預測「結構會怎麼長」幾乎不可能&lt;/strong>。如果當年順著清單先把欄位蓋起來（&lt;code>String category&lt;/code>、&lt;code>double discount&lt;/code>），每一個都會變成後來要遷移的錯誤結構——事實上 &lt;code>double price&lt;/code> 跟 &lt;code>String category&lt;/code> 正是這樣被遷移掉的。需求清單可以先列（它是雷達）、結構要等需求真的到場才定形。&lt;/p>
&lt;h2 id="宣稱型文件的正確用法考古不是導覽">宣稱型文件的正確用法：考古、不是導覽&lt;/h2>
&lt;p>這份文件沒有錯、它只是停在了自己的時刻。專案裡真正跟著程式碼走的知識在 &lt;strong>model 的註解&lt;/strong>——&lt;code>kitchenDevice&lt;/code> 欄位旁邊寫著業務規則與 fallback 行為、&lt;code>ShoppingCart&lt;/code> 的契約註解、&lt;code>unsettledCartView&lt;/code> 的擴充規劃——它們跟被註解的程式碼同檔、同 commit、同生死。&lt;/p>
&lt;p>分工可以講明白：&lt;strong>宣稱型文件（design doc、refactor 總結）記決策時刻的理由，讀法是考古&lt;/strong>——「當時為什麼這樣想」；&lt;strong>貼身註解記現行契約，讀法是導覽&lt;/strong>——「現在它怎麼運作」。把宣稱型文件當導覽讀是事故來源（照著文件的欄位名寫程式碼、發現一個都不存在）；反過來要求宣稱型文件永遠同步則是不可能的維護承諾——它的價值本來就是快照。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>doc 目錄的文件描述的 API / 欄位在 codebase 裡 grep 不到——文件已進入考古態，讀它時切換心態、別照著寫程式碼&lt;/li>
&lt;li>商品 / 資源類 model 出現「同一概念、多個變體」的需求（規格、方案、版本）——扁平模型的死期，先做歸屬判準（哪些欄位會被變體分化）再拆層&lt;/li>
&lt;li>「未來擴展」清單裡的項目被預先建成欄位——每一個都是將來的遷移債，清單留著、欄位等需求&lt;/li>
&lt;li>業務規則寫在文件而不是欄位旁——文件會漂移、貼身註解不會；規則跟著它約束的程式碼放&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同專案的型別演化：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">Money 三段遷移&lt;/a>——&lt;code>double price&lt;/code> 的下場&lt;/li>
&lt;li>「先蓋結構會蓋錯」的另一個現場：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪&lt;/a>——設計期想像的結構被砍、真結構從操作長出來&lt;/li>
&lt;li>原則層：&lt;a href="https://tarrragon.github.io/blog/report/predictive-index-needs-backfill-pass/" data-link-title="#206 預測性索引要有寫後回填輪" data-link-desc="大綱的案例支撐欄與 case 檔的對應章節欄是寫作前的預測、正文完成後不回填就系統性失真；回填是獨立的機械工序、要排進流程而非靠 review 撿">#206 預測性索引要有寫後回填輪&lt;/a>——寫作領域的同構：寫前的預測性宣告、完成後不回填就雙向失真&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型&lt;/a> 的「資料袋起步、訊號出現才升級」段（本文是該段的主案例）；聚合邊界的專章等 case 累積&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：整理 POS 專案時對照 <code>doc/PRODUCT_MODEL_REFACTOR.md</code> 跟 <code>lib/data/models/product/product.dart</code>——文件描述的 Product 是扁平結構（barcode、price、stockCount 直接掛在商品上），現行程式碼是 Product + ProductSpecification 雙層、幾乎沒有一個欄位還在文件說的位置
<strong>疑問來源</strong>：這份文件錯了嗎？還是它只是過期了？兩者的差異本身能教什麼？
<strong>整理目的</strong>：記下扁平商品模型被真實業務打破的具體壓力、欄位歸屬的判準、以及宣稱型文件的正確用法
<strong>本文邊界</strong>：素材是該專案的 refactor 總結文件（早期）與現行 model；「落差」是十個月演化的累積、不是單次重構的 before/after</p></blockquote>
<hr>
<h2 id="文件的版本一商品一價一庫存">文件的版本：一商品、一價、一庫存</h2>
<p>refactor 總結文件裡的 Product 是教科書式的扁平 model：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">const</span> <span class="kd">factory</span> <span class="n">Product</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">required</span> <span class="kt">String</span> <span class="n">barcode</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">required</span> <span class="kt">double</span> <span class="n">price</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="err">@</span><span class="n">Default</span><span class="p">(</span><span class="m">0.0</span><span class="p">)</span> <span class="kt">double</span> <span class="n">discount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kd">required</span> <span class="kt">double</span> <span class="n">currentPrice</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="err">@</span><span class="n">Default</span><span class="p">(</span><span class="m">0</span><span class="p">)</span> <span class="kt">int</span> <span class="n">stockCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="err">@</span><span class="n">Default</span><span class="p">(</span><span class="s1">&#39;&#39;</span><span class="p">)</span> <span class="kt">String</span> <span class="n">category</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>搭配 <code>updateStock</code> / <code>reduceStock</code> / <code>applyDiscount</code> 業務方法、跟一張「未來擴展」清單：商品分類管理、庫存管理、折扣策略、商品圖片。文件當時的重構是成立的（把散裝參數收成 model、UI 從六個參數變一個物件）——問題不在那次重構、在這個結構隱含的假設：<strong>一個商品有一個條碼、一個價格、一份庫存</strong>。</p>
<h2 id="業務打破它的方式規格">業務打破它的方式：規格</h2>
<p>真實 POS 的第一批客戶就帶著飲料店跟零售的需求：中杯與大杯是同一個商品的兩個<strong>規格</strong>，各自有條碼、售價、會員價、進價、庫存、甚至各自的圖片。現行的 model 把這個現實建成雙層：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">ProductSpecification</span> <span class="p">{</span>   <span class="c1">// 會被規格分化的一切
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span>  <span class="kd">required</span> <span class="kt">String</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">barcode</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">required</span> <span class="n">Money</span> <span class="n">sellingPrice</span><span class="p">,</span> <span class="n">purchasePrice</span><span class="p">,</span> <span class="n">memberPrice</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">Money</span><span class="o">?</span> <span class="n">cost</span><span class="p">,</span> <span class="n">rebate</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="err">@</span><span class="n">Default</span><span class="p">(</span><span class="m">0</span><span class="p">)</span> <span class="kt">int</span> <span class="n">inventory</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kt">bool</span> <span class="n">enableIgnoreInventory</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="n">Cover</span><span class="o">?</span> <span class="n">cover</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">Product</span> <span class="p">{</span>                <span class="c1">// 跨規格共用的資訊
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="kd">required</span> <span class="kt">String</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="n">ProductCategory</span><span class="o">?</span> <span class="n">productCategory</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="n">Brand</span><span class="o">?</span> <span class="n">brand</span><span class="p">;</span>  <span class="n">Supplier</span><span class="o">?</span> <span class="n">supplier</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="n">List</span><span class="o">&lt;</span><span class="n">Tag</span><span class="o">&gt;</span> <span class="n">tags</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="n">List</span><span class="o">&lt;</span><span class="n">CustomizationOption</span><span class="o">&gt;?</span> <span class="n">customizationOptions</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="n">List</span><span class="o">&lt;</span><span class="n">ProductSpecification</span><span class="o">&gt;</span> <span class="n">specifications</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="n">Device</span><span class="o">?</span> <span class="n">kitchenDevice</span><span class="p">;</span>                <span class="c1">// 廚房出單機路由
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>欄位歸屬的判準收成一句：<strong>問「兩個規格會不會不同」</strong>。條碼會（中杯大杯各一個店內碼）、價格會（三種價都會）、庫存會——下沉到 spec；名稱、品牌、供應商、分類、客製化選項、廚房路由不會——留在聚合根。price 的取得也跟著變成規格層的方法（<code>spec.getPrice(isMember)</code>），「商品的價格」這個問法在新結構裡根本不成立——只有「某規格對某身分的價格」。</p>
<p>型別也在同一段演化裡逐級升級：<code>double</code> 價格換成 <code>Money</code>（<a href="/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">三段遷移</a>）、<code>String category</code> 換成 <code>ProductCategory</code> model、裸 URL 換成 <code>Cover</code> model。扁平版留下的痕跡只剩一個向後兼容 getter（<code>productName =&gt; name</code>）。</p>
<h2 id="預言全中路徑全錯">預言全中、路徑全錯</h2>
<p>值得玩味的是文件的「未來擴展」清單——商品分類、庫存、折扣、圖片——<strong>每一項都發生了</strong>，但沒有一項是「在扁平模型上加欄位」實現的：分類變成獨立 model、庫存變成 per-spec 欄位加 <code>enableIgnoreInventory</code> 開關、折扣走進 <code>CartItem.discount</code> 與會員價機制、圖片變成 spec 與商品兩層的 <code>Cover</code>。</p>
<p>這是 YAGNI 最好的論據形式：<strong>預測「會有什麼需求」不難、預測「結構會怎麼長」幾乎不可能</strong>。如果當年順著清單先把欄位蓋起來（<code>String category</code>、<code>double discount</code>），每一個都會變成後來要遷移的錯誤結構——事實上 <code>double price</code> 跟 <code>String category</code> 正是這樣被遷移掉的。需求清單可以先列（它是雷達）、結構要等需求真的到場才定形。</p>
<h2 id="宣稱型文件的正確用法考古不是導覽">宣稱型文件的正確用法：考古、不是導覽</h2>
<p>這份文件沒有錯、它只是停在了自己的時刻。專案裡真正跟著程式碼走的知識在 <strong>model 的註解</strong>——<code>kitchenDevice</code> 欄位旁邊寫著業務規則與 fallback 行為、<code>ShoppingCart</code> 的契約註解、<code>unsettledCartView</code> 的擴充規劃——它們跟被註解的程式碼同檔、同 commit、同生死。</p>
<p>分工可以講明白：<strong>宣稱型文件（design doc、refactor 總結）記決策時刻的理由，讀法是考古</strong>——「當時為什麼這樣想」；<strong>貼身註解記現行契約，讀法是導覽</strong>——「現在它怎麼運作」。把宣稱型文件當導覽讀是事故來源（照著文件的欄位名寫程式碼、發現一個都不存在）；反過來要求宣稱型文件永遠同步則是不可能的維護承諾——它的價值本來就是快照。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>doc 目錄的文件描述的 API / 欄位在 codebase 裡 grep 不到——文件已進入考古態，讀它時切換心態、別照著寫程式碼</li>
<li>商品 / 資源類 model 出現「同一概念、多個變體」的需求（規格、方案、版本）——扁平模型的死期，先做歸屬判準（哪些欄位會被變體分化）再拆層</li>
<li>「未來擴展」清單裡的項目被預先建成欄位——每一個都是將來的遷移債，清單留著、欄位等需求</li>
<li>業務規則寫在文件而不是欄位旁——文件會漂移、貼身註解不會；規則跟著它約束的程式碼放</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同專案的型別演化：<a href="/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">Money 三段遷移</a>——<code>double price</code> 的下場</li>
<li>「先蓋結構會蓋錯」的另一個現場：<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪</a>——設計期想像的結構被砍、真結構從操作長出來</li>
<li>原則層：<a href="/blog/report/predictive-index-needs-backfill-pass/" data-link-title="#206 預測性索引要有寫後回填輪" data-link-desc="大綱的案例支撐欄與 case 檔的對應章節欄是寫作前的預測、正文完成後不回填就系統性失真；回填是獨立的機械工序、要排進流程而非靠 review 撿">#206 預測性索引要有寫後回填輪</a>——寫作領域的同構：寫前的預測性宣告、完成後不回填就雙向失真</li>
<li>概念地基：<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a> 的「資料袋起步、訊號出現才升級」段（本文是該段的主案例）；聚合邊界的專章等 case 累積</li>
</ul>
]]></content:encoded></item><item><title>桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦</title><link>https://tarrragon.github.io/blog/work-log/pos_table_cart_lifecycle_decoupling/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_table_cart_lifecycle_decoupling/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS 專案有一個「提前結帳」需求——客人中途先結一次帳、但人還沒離桌、要能繼續加點。追這個功能的實作時發現桌位跟購物車的關係設計比直覺版本複雜
&lt;strong>疑問來源&lt;/strong>：直覺的建模是「一桌一單」、桌子跟訂單一對一。這個專案為什麼把它們拆成兩個獨立資源？
&lt;strong>整理目的&lt;/strong>：記下「兩個資源的生命週期何時該解耦」的推導方式——從業務操作反推、不從名詞直覺
&lt;strong>本文邊界&lt;/strong>：素材是一個 Flutter POS App 的現行實作與 changelog；推導方式可遷移、具體切法是餐飲 domain 的結果&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="直覺建模在三個操作前撐不住">直覺建模在三個操作前撐不住&lt;/h2>
&lt;p>「一桌一單」的一對一建模，遇到這個專案實際支援的操作就露出縫：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>提前結帳&lt;/strong>：客人中途先結一次帳但不離桌，之後繼續加點。訂單結掉了、桌還在用——訂單的生命週期先於桌位結束&lt;/li>
&lt;li>&lt;strong>純佔桌&lt;/strong>：客人入座還沒點餐。桌被占用、購物車還不存在——桌位的生命週期先於購物車開始&lt;/li>
&lt;li>&lt;strong>外賣單&lt;/strong>：沒有桌的訂單。購物車存在、桌位從頭到尾缺席&lt;/li>
&lt;/ul>
&lt;p>三個操作各自證明一件事：桌位跟購物車的生命週期在真實業務裡會錯開。一對一綁死的 model 要支援這些操作，只能塞特例旗標，而特例會隨操作數量增生。&lt;/p>
&lt;h2 id="解耦後的模型獨立資源--綁定關係">解耦後的模型：獨立資源 + 綁定關係&lt;/h2>
&lt;p>這個專案的購物車 model 註解直接寫明了關係設計：&lt;/p>
&lt;blockquote>
&lt;ul>
&lt;li>桌子和購物車是獨立資源，透過綁定關係關聯&lt;/li>
&lt;li>開桌（occupyTable）同時建立購物車並綁定桌子&lt;/li>
&lt;li>釋放桌子（releaseTable）只解除綁定，購物車仍保留&lt;/li>
&lt;li>當購物車名下沒有任何桌子時，變成臨時購物車（外賣單）&lt;/li>
&lt;li>提前結帳且不釋放桌子時，品項被清空但購物車和桌子綁定仍在，可繼續追加點餐&lt;/li>
&lt;li>純佔桌（reserveTable）不建立購物車，要下單需另行開桌&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>每個業務操作對應到「建立 / 解除綁定」跟「建立 / 保留資源」的不同組合，而不是對單一聚合的特例處理。外賣單在這個模型裡甚至不是特例——它就是「綁定數為零的購物車」，自然落在模型的表達範圍內。&lt;/p>
&lt;h2 id="結帳模式兩個布林組合出生命週期決策">結帳模式：兩個布林組合出生命週期決策&lt;/h2>
&lt;p>結帳流程用兩個欄位決定結帳後兩個資源各自的命運：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模式&lt;/th>
 &lt;th>&lt;code>releaseTable&lt;/code>&lt;/th>
 &lt;th>&lt;code>deleteShoppingCart&lt;/code>&lt;/th>
 &lt;th>結帳後&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>完整結帳&lt;/td>
 &lt;td>true&lt;/td>
 &lt;td>true&lt;/td>
 &lt;td>釋放桌位、刪除掛單、畫面回購物頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>提前結帳&lt;/td>
 &lt;td>false&lt;/td>
 &lt;td>false&lt;/td>
 &lt;td>保留桌位與 cart 殼、同桌直接繼續追加點餐&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>結帳後的兩條路徑也各自獨立：完整結帳走 &lt;code>afterCheckout&lt;/code>（回購物頁）、提前結帳走 &lt;code>afterPartialCheckout&lt;/code>——後者的註解記了一個容易被清掉的細節：「刻意不回到購物頁、不清本機暫存品項：員工提前結完仍在服務同一位客人，若員工先前已在本機累積了尚未送出的追加品項，會保留下來」。提前結帳的語意一路貫穿到本機暫存的處理。&lt;/p>
&lt;h2 id="組合空間大於業務空間非法組合要顯式封鎖">組合空間大於業務空間：非法組合要顯式封鎖&lt;/h2>
&lt;p>兩個布林有四種組合、業務上只定義了兩種。這個縫隙真的被踩過——changelog 的修正記錄：&lt;/p>
&lt;blockquote>
&lt;p>fix: 禁止在提前結帳的時候釋放桌子&lt;/p>&lt;/blockquote>
&lt;p>「提前結帳 + 釋放桌子」在模型上可以表達（兩個獨立欄位、各自可設），在業務上是矛盾的（客人還在桌上、桌卻被釋放給下一組客人）。解耦買到表達力的同時，也把「組合空間大於業務空間」的問題帶進來——多出來的組合不會自己消失，要嘛在 UI 層擋住操作、要嘛在 model 層宣告不變式。這個專案選了前者，而更早關掉這個縫的做法是讓結帳模式成為一個 enum（&lt;code>fullCheckout&lt;/code> / &lt;code>partialCheckout&lt;/code>）、由模式推導兩個布林，非法組合從一開始就不可表達。&lt;/p>
&lt;h2 id="契約的另一半details-只含未結帳品項">契約的另一半：details 只含未結帳品項&lt;/h2>
&lt;p>提前結帳還牽動購物車內容的契約。購物車 model 註明 &lt;code>details&lt;/code> 代表「當下未結帳的活動品項」，已結帳品項由後端移除；把 details 轉成結帳清單的方法直接把違約後果寫在註解裡：&lt;/p>
&lt;blockquote>
&lt;p>依賴 ShoppingCart 的 details 契約：假設 details 不含已結帳品項。若契約被破壞，已結帳的舊單金額會被重複算進下一輪結帳。&lt;/p>&lt;/blockquote>
&lt;p>這是「重複收錢」等級的後果，靠一條跨前後端的資料契約撐住。契約寫在消費端的註解上，是因為違約的症狀會在消費端爆炸、但成因在資料來源端——除錯的人會先找到這裡。&lt;/p>
&lt;h2 id="判準有沒有操作需要其中一方獨立存活">判準：有沒有操作需要其中一方獨立存活&lt;/h2>
&lt;p>把推導收束成一句：兩個業務資源該不該共用生命週期，看&lt;strong>有沒有業務操作需要其中一方在另一方缺席時存活&lt;/strong>。有——佔桌不點、提前結帳、外賣——就解耦成獨立資源加綁定；沒有，一對一的簡單模型是正確選擇、解耦反而引入要管理的組合空間。這是「從操作推導領域」的實例：聚合邊界不是從名詞關係（桌子「有」訂單）推出來的，是從操作對生命週期的要求推出來的。&lt;/p>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a> 的「從操作推導領域」章節&lt;/li>
&lt;li>非法組合封鎖的原則層：&lt;a href="https://tarrragon.github.io/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通&lt;/a>——「提前結帳 + 釋放桌子」正是「不允許任意組合的欄位」判準的另一個實例&lt;/li>
&lt;li>同專案的品項生命週期：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_pos_item_four_lifecycle_models/" data-link-title="同一個品項、四個 model — value object 什麼時候該升級成 entity" data-link-desc="同一個業務概念要不要拆成多個 model、value object 什麼時候該升級成 entity——判準是操作需不需要 identity-based 回寫。以 POS 品項從點選、掛單、結算到歷史訂單的四階段模型為例，含 snapshot 與 live reference 的凍結時機。">同一個品項、四個 model&lt;/a>——桌位與購物車是資源層的生命週期、品項是資料層的生命週期，同一個 domain 的兩個切面&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS 專案有一個「提前結帳」需求——客人中途先結一次帳、但人還沒離桌、要能繼續加點。追這個功能的實作時發現桌位跟購物車的關係設計比直覺版本複雜
<strong>疑問來源</strong>：直覺的建模是「一桌一單」、桌子跟訂單一對一。這個專案為什麼把它們拆成兩個獨立資源？
<strong>整理目的</strong>：記下「兩個資源的生命週期何時該解耦」的推導方式——從業務操作反推、不從名詞直覺
<strong>本文邊界</strong>：素材是一個 Flutter POS App 的現行實作與 changelog；推導方式可遷移、具體切法是餐飲 domain 的結果</p></blockquote>
<hr>
<h2 id="直覺建模在三個操作前撐不住">直覺建模在三個操作前撐不住</h2>
<p>「一桌一單」的一對一建模，遇到這個專案實際支援的操作就露出縫：</p>
<ul>
<li><strong>提前結帳</strong>：客人中途先結一次帳但不離桌，之後繼續加點。訂單結掉了、桌還在用——訂單的生命週期先於桌位結束</li>
<li><strong>純佔桌</strong>：客人入座還沒點餐。桌被占用、購物車還不存在——桌位的生命週期先於購物車開始</li>
<li><strong>外賣單</strong>：沒有桌的訂單。購物車存在、桌位從頭到尾缺席</li>
</ul>
<p>三個操作各自證明一件事：桌位跟購物車的生命週期在真實業務裡會錯開。一對一綁死的 model 要支援這些操作，只能塞特例旗標，而特例會隨操作數量增生。</p>
<h2 id="解耦後的模型獨立資源--綁定關係">解耦後的模型：獨立資源 + 綁定關係</h2>
<p>這個專案的購物車 model 註解直接寫明了關係設計：</p>
<blockquote>
<ul>
<li>桌子和購物車是獨立資源，透過綁定關係關聯</li>
<li>開桌（occupyTable）同時建立購物車並綁定桌子</li>
<li>釋放桌子（releaseTable）只解除綁定，購物車仍保留</li>
<li>當購物車名下沒有任何桌子時，變成臨時購物車（外賣單）</li>
<li>提前結帳且不釋放桌子時，品項被清空但購物車和桌子綁定仍在，可繼續追加點餐</li>
<li>純佔桌（reserveTable）不建立購物車，要下單需另行開桌</li>
</ul></blockquote>
<p>每個業務操作對應到「建立 / 解除綁定」跟「建立 / 保留資源」的不同組合，而不是對單一聚合的特例處理。外賣單在這個模型裡甚至不是特例——它就是「綁定數為零的購物車」，自然落在模型的表達範圍內。</p>
<h2 id="結帳模式兩個布林組合出生命週期決策">結帳模式：兩個布林組合出生命週期決策</h2>
<p>結帳流程用兩個欄位決定結帳後兩個資源各自的命運：</p>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th><code>releaseTable</code></th>
          <th><code>deleteShoppingCart</code></th>
          <th>結帳後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>完整結帳</td>
          <td>true</td>
          <td>true</td>
          <td>釋放桌位、刪除掛單、畫面回購物頁</td>
      </tr>
      <tr>
          <td>提前結帳</td>
          <td>false</td>
          <td>false</td>
          <td>保留桌位與 cart 殼、同桌直接繼續追加點餐</td>
      </tr>
  </tbody>
</table>
<p>結帳後的兩條路徑也各自獨立：完整結帳走 <code>afterCheckout</code>（回購物頁）、提前結帳走 <code>afterPartialCheckout</code>——後者的註解記了一個容易被清掉的細節：「刻意不回到購物頁、不清本機暫存品項：員工提前結完仍在服務同一位客人，若員工先前已在本機累積了尚未送出的追加品項，會保留下來」。提前結帳的語意一路貫穿到本機暫存的處理。</p>
<h2 id="組合空間大於業務空間非法組合要顯式封鎖">組合空間大於業務空間：非法組合要顯式封鎖</h2>
<p>兩個布林有四種組合、業務上只定義了兩種。這個縫隙真的被踩過——changelog 的修正記錄：</p>
<blockquote>
<p>fix: 禁止在提前結帳的時候釋放桌子</p></blockquote>
<p>「提前結帳 + 釋放桌子」在模型上可以表達（兩個獨立欄位、各自可設），在業務上是矛盾的（客人還在桌上、桌卻被釋放給下一組客人）。解耦買到表達力的同時，也把「組合空間大於業務空間」的問題帶進來——多出來的組合不會自己消失，要嘛在 UI 層擋住操作、要嘛在 model 層宣告不變式。這個專案選了前者，而更早關掉這個縫的做法是讓結帳模式成為一個 enum（<code>fullCheckout</code> / <code>partialCheckout</code>）、由模式推導兩個布林，非法組合從一開始就不可表達。</p>
<h2 id="契約的另一半details-只含未結帳品項">契約的另一半：details 只含未結帳品項</h2>
<p>提前結帳還牽動購物車內容的契約。購物車 model 註明 <code>details</code> 代表「當下未結帳的活動品項」，已結帳品項由後端移除；把 details 轉成結帳清單的方法直接把違約後果寫在註解裡：</p>
<blockquote>
<p>依賴 ShoppingCart 的 details 契約：假設 details 不含已結帳品項。若契約被破壞，已結帳的舊單金額會被重複算進下一輪結帳。</p></blockquote>
<p>這是「重複收錢」等級的後果，靠一條跨前後端的資料契約撐住。契約寫在消費端的註解上，是因為違約的症狀會在消費端爆炸、但成因在資料來源端——除錯的人會先找到這裡。</p>
<h2 id="判準有沒有操作需要其中一方獨立存活">判準：有沒有操作需要其中一方獨立存活</h2>
<p>把推導收束成一句：兩個業務資源該不該共用生命週期，看<strong>有沒有業務操作需要其中一方在另一方缺席時存活</strong>。有——佔桌不點、提前結帳、外賣——就解耦成獨立資源加綁定；沒有，一對一的簡單模型是正確選擇、解耦反而引入要管理的組合空間。這是「從操作推導領域」的實例：聚合邊界不是從名詞關係（桌子「有」訂單）推出來的，是從操作對生命週期的要求推出來的。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a> 的「從操作推導領域」章節</li>
<li>非法組合封鎖的原則層：<a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>——「提前結帳 + 釋放桌子」正是「不允許任意組合的欄位」判準的另一個實例</li>
<li>同專案的品項生命週期：<a href="/blog/work-log/dart_pos_item_four_lifecycle_models/" data-link-title="同一個品項、四個 model — value object 什麼時候該升級成 entity" data-link-desc="同一個業務概念要不要拆成多個 model、value object 什麼時候該升級成 entity——判準是操作需不需要 identity-based 回寫。以 POS 品項從點選、掛單、結算到歷史訂單的四階段模型為例，含 snapshot 與 live reference 的凍結時機。">同一個品項、四個 model</a>——桌位與購物車是資源層的生命週期、品項是資料層的生命週期，同一個 domain 的兩個切面</li>
</ul>
]]></content:encoded></item></channel></rss>