觸發場景:整理 POS 專案時對照 doc/PRODUCT_MODEL_REFACTOR.mdlib/data/models/product/product.dart——文件描述的 Product 是扁平結構(barcode、price、stockCount 直接掛在商品上),現行程式碼是 Product + ProductSpecification 雙層、幾乎沒有一個欄位還在文件說的位置 疑問來源:這份文件錯了嗎?還是它只是過期了?兩者的差異本身能教什麼? 整理目的:記下扁平商品模型被真實業務打破的具體壓力、欄位歸屬的判準、以及宣稱型文件的正確用法 本文邊界:素材是該專案的 refactor 總結文件(早期)與現行 model;「落差」是十個月演化的累積、不是單次重構的 before/after


文件的版本:一商品、一價、一庫存

refactor 總結文件裡的 Product 是教科書式的扁平 model:

1const factory Product({
2  required String barcode,
3  required double price,
4  @Default(0.0) double discount,
5  required double currentPrice,
6  @Default(0) int stockCount,
7  @Default('') String category,
8  ...
9});

搭配 updateStock / reduceStock / applyDiscount 業務方法、跟一張「未來擴展」清單:商品分類管理、庫存管理、折扣策略、商品圖片。文件當時的重構是成立的(把散裝參數收成 model、UI 從六個參數變一個物件)——問題不在那次重構、在這個結構隱含的假設:一個商品有一個條碼、一個價格、一份庫存

業務打破它的方式:規格

真實 POS 的第一批客戶就帶著飲料店跟零售的需求:中杯與大杯是同一個商品的兩個規格,各自有條碼、售價、會員價、進價、庫存、甚至各自的圖片。現行的 model 把這個現實建成雙層:

 1abstract class ProductSpecification {   // 會被規格分化的一切
 2  required String id, name, barcode;
 3  required Money sellingPrice, purchasePrice, memberPrice;
 4  Money? cost, rebate;
 5  @Default(0) int inventory;
 6  bool enableIgnoreInventory;
 7  Cover? cover;
 8}
 9
10abstract class Product {                // 跨規格共用的資訊
11  required String id, name;
12  ProductCategory? productCategory;
13  Brand? brand;  Supplier? supplier;
14  List<Tag> tags;
15  List<CustomizationOption>? customizationOptions;
16  List<ProductSpecification> specifications;
17  Device? kitchenDevice;                // 廚房出單機路由
18}

欄位歸屬的判準收成一句:問「兩個規格會不會不同」。條碼會(中杯大杯各一個店內碼)、價格會(三種價都會)、庫存會——下沉到 spec;名稱、品牌、供應商、分類、客製化選項、廚房路由不會——留在聚合根。price 的取得也跟著變成規格層的方法(spec.getPrice(isMember)),「商品的價格」這個問法在新結構裡根本不成立——只有「某規格對某身分的價格」。

型別也在同一段演化裡逐級升級:double 價格換成 Money三段遷移)、String category 換成 ProductCategory model、裸 URL 換成 Cover model。扁平版留下的痕跡只剩一個向後兼容 getter(productName => name)。

預言全中、路徑全錯

值得玩味的是文件的「未來擴展」清單——商品分類、庫存、折扣、圖片——每一項都發生了,但沒有一項是「在扁平模型上加欄位」實現的:分類變成獨立 model、庫存變成 per-spec 欄位加 enableIgnoreInventory 開關、折扣走進 CartItem.discount 與會員價機制、圖片變成 spec 與商品兩層的 Cover

這是 YAGNI 最好的論據形式:預測「會有什麼需求」不難、預測「結構會怎麼長」幾乎不可能。如果當年順著清單先把欄位蓋起來(String categorydouble discount),每一個都會變成後來要遷移的錯誤結構——事實上 double priceString category 正是這樣被遷移掉的。需求清單可以先列(它是雷達)、結構要等需求真的到場才定形。

宣稱型文件的正確用法:考古、不是導覽

這份文件沒有錯、它只是停在了自己的時刻。專案裡真正跟著程式碼走的知識在 model 的註解——kitchenDevice 欄位旁邊寫著業務規則與 fallback 行為、ShoppingCart 的契約註解、unsettledCartView 的擴充規劃——它們跟被註解的程式碼同檔、同 commit、同生死。

分工可以講明白:宣稱型文件(design doc、refactor 總結)記決策時刻的理由,讀法是考古——「當時為什麼這樣想」;貼身註解記現行契約,讀法是導覽——「現在它怎麼運作」。把宣稱型文件當導覽讀是事故來源(照著文件的欄位名寫程式碼、發現一個都不存在);反過來要求宣稱型文件永遠同步則是不可能的維護承諾——它的價值本來就是快照。

判讀徵兆

  • doc 目錄的文件描述的 API / 欄位在 codebase 裡 grep 不到——文件已進入考古態,讀它時切換心態、別照著寫程式碼
  • 商品 / 資源類 model 出現「同一概念、多個變體」的需求(規格、方案、版本)——扁平模型的死期,先做歸屬判準(哪些欄位會被變體分化)再拆層
  • 「未來擴展」清單裡的項目被預先建成欄位——每一個都是將來的遷移債,清單留著、欄位等需求
  • 業務規則寫在文件而不是欄位旁——文件會漂移、貼身註解不會;規則跟著它約束的程式碼放

相關閱讀