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