
Day 8 處理的是餘額:當一個數字會持續被交易改變,就不能只保存最後結果。
菜單也有類似問題,但麻煩的地方不太一樣。
今天看到的「雞排飯 100 元」,下個月可能變成 105 元;某個品項可能停售,也可能多出半份、加量等變體。店家名稱還可能經過整理,歷史資料裡又保留著舊代碼。
這裡的餐點名稱、價格與 Vendor A 範例都是為了說明版本衝突而簡化的示意,不代表某一筆實際 Production 訂單。
如果系統永遠只維護一份最新版菜單,更新很簡單。但一旦有人回頭看舊訂單,就會碰到一個很實際的問題:
現在的菜單可以被整理,過去那筆訂單當時看到的菜單不能跟著被改寫。
Menu History 的責任,是讓店家、餐點、價格、variant 與生效時間組成可追溯的版本,而不只是多存幾張菜單圖片。
單一店家時,菜單很容易被當成一份固定設定。
Day 5 已經談過,當店家增加、價格改變、開團與截止條件開始不同,原本藏在環境裡的常數就會變成 Domain Data。
但把菜單存進資料庫,只解決了一半。
例如某個品項今天是:
Vendor A
A01
招牌便當
100
下週改成 105 元時,最直覺的做法是直接把 price 更新成 105。
對「現在要顯示什麼」來說,這完全合理。
對歷史訂單來說卻不一定合理。因為兩週前下單的人,當時看到的是 100 元。如果舊訂單畫面重新去查現在的 menu item,就可能把過去重新解釋成 105 元。
問題已經不只在 UI 顯示,現在狀態開始污染歷史語意。
整理這段資料後,我比較傾向把菜單拆成三個問題看。
| 層次 | 回答的問題 |
|---|---|
| Current Menu | 現在管理者看到、可以維護的是什麼? |
| Effective Menu | 某個指定日期,當時有效的菜單是什麼? |
| Historical Evidence | 過去訂單與舊資料當時實際代表什麼? |
這三者看起來很像,但不能完全共用同一種更新語意。
Current Menu 可以接受名稱整理、未來價格調整、品項啟停。
Effective Menu 要根據日期解析當時應該套用哪一版。
Historical Evidence 則更保守:只要已經成為過去交易的一部分,就不應因為今天做了 canonicalization,回頭把舊訂單的意思一起換掉。
這也是這套系統後來把 menu change 做成 append-only history 的原因。新的變更往後加,而不是把舊紀錄改成「現在看起來比較乾淨」的版本。
一開始很容易把 item_code 當成餐點身份。
例如:
H1 = 某個餐點
H2 = 另一個餐點
但真實菜單會慢慢出現更多維度。
同一個代碼可能需要區分不同店家;同一個餐點也可能存在 BASE、HALF、PLUS 等不同 variant。這時候只看 item_code,就無法完整回答「這到底是哪一個品項」。
目前 normalized menu 的 identity 會同時考慮:
vendor
+ item_code
+ variant_key
餐點身份因此不能只看一個孤立代碼,必須放在店家與變體脈絡裡判斷。
這個差異很重要。
假設兩家店剛好都有 A01,如果 resolver 只用 item_code 尋找最新版,就可能把 Vendor A 的變更套到 Vendor B。
Vendor isolation 同時影響畫面分類與資料身份。
只有 identity 還是不夠。
菜單版本還需要回答另一個維度:
這個變更從哪一天開始有效?
每筆 menu change 都有 effective_date。Resolver 查某一天的菜單時,會找出對那個日期已經生效的版本,而不是直接拿資料庫裡最後一筆紀錄。
這讓「今天的菜單」和「下週即將生效的菜單」可以同時存在。
但還有更細的情況:同一個 effective date 可能發生不只一次修正。
如果只靠日期排序,同一天的兩筆 revision 並沒有穩定先後。後來又需要 append-only sequence,讓同日 revision 也有 deterministic winner。
最後一個 menu identity 的解析,實際上比較接近:
Vendor
↓
Item Code
↓
Variant
↓
Effective Date
↓
Revision Order
↓
Resolved Menu Item

每多一層看起來都增加了一點複雜度,但每一層都在回答不同問題:誰的餐點、哪個變體、從哪天生效、同一天哪次修正最後成立。
這套系統還有一個很現實的條件:Menu History 不是第一天就用現在的模型建立。
早期已經存在一批 legacy menu changes。後來才加入 normalized identity,明確區分 legacy/raw 與 normalized schema,並讓新版支援 variant。
這時有兩種做法。
第一種,是把所有舊資料全部改寫成新版格式,讓資料庫看起來非常一致。
第二種,是承認舊資料就是在舊規則下產生的歷史,保留原 identity,再讓新的 resolver 知道該怎麼把它投影到現在。
這套系統採用的是後者。
既有 legacy row 保留原本身份;normalized v2 從新的邊界開始承擔新的 identity 規則。現在的 projection 可以更乾淨,但不需要假裝過去也是用今天的 schema 產生。
對 migration 來說,這是一個很實用的界線:
Schema 可以往前演進,Historical Evidence 不必被重寫成看起來像從第一天就設計正確。
店家名稱整理是另一個例子。
假設歷史上曾經出現不同寫法,現在系統決定統一成 canonical vendor。這可以改善 current UI、搜尋與管理,但不代表要把過去資料全部回寫。
比較安全的做法是:
這裡的重點不是把字串整理得更整齊,而是讓 current projection 與 historical evidence 各自保有清楚責任。
Menu History 最後還會回到 Order。
假設一筆訂單已經成立,它所代表的餐點、價格與 variant,應該依照交易當時保存下來的語意理解。
不能因為今天菜單改名、改價、停售,就重新用 current menu 解釋舊訂單。
這也是為什麼 current menu resolver 和 historical order evidence 要分開。
Current Menu 可以回答:
今天如果要下單,現在有哪些選擇?
Historical Order 則回答:
那一天,這筆交易當時成立的是什麼?
兩個畫面都可能顯示「菜單」,背後承擔的是不同時間語意。
如果系統裡的菜單、方案、費率或產品設定會持續改變,可以先問五個問題:
Identity 是什麼?
只靠 item_code,還是必須包含 vendor、variant 或其他維度?
何時生效?
變更是立即覆蓋,還是有 effective date?
同一時間能不能再次修正?
如果可以,需要穩定的 revision order。
歷史交易保存什麼?
舊訂單是否能在不查 current config 的情況下,仍然知道當時買了什麼?
Normalization 會不會改寫歷史?
現在的 canonical naming、mapping 或 schema upgrade,是只改 projection,還是會動到 historical evidence?
如果這五題沒有先回答,Menu History 很容易最後只剩「一張 changelog table」,但系統仍然不知道某一天到底該相信哪一版。
如果一個網站只展示今天最新菜單,沒有訂單歷史、沒有未來生效日期,也不需要回看過去價格,那麼直接覆蓋 current menu 可能已經足夠。
版本模型有成本。
它增加 schema、resolver、migration、測試與管理介面的複雜度;一旦加入 effective date 與 revision order,debug 時也不能只看「最後一列」。
是否採用 append-only,要先看系統是否需要同時回答兩個問題:
只要兩個答案可能不同,資料模型就不能只剩一份最新版。
Menu History 最後留下的重點,是同一份 Domain Data 會同時存在 current、future 與 historical 三種時間語意。
菜單因此從「現在有哪些餐點」的一張表,變成一條可以沿著日期與 identity 回看的時間線。
當資料開始有時間,系統就不能再只問:
現在是什麼?
還得能回答:
那一天,它是什麼?
這不是純概念示範,而是一套持續開發中的便當訂購系統。文章著重「為什麼」,GitHub 保留實際程式碼、文件與演進紀錄。
GitHub:https://github.com/henryfir456/bento-order-app
菜單需要 effective date,已經讓「日期」開始進入資料模型。
但能不能訂某一天,還不只看菜單有沒有生效。工作日、截止時間、Mode A / B、自動開團,都會一起決定那一天是否可下單。
下一篇會沿著這條線,繼續看時間怎麼從一個欄位,變成 Domain Logic。