<?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>Facade on Tarragon</title><link>https://tarrragon.github.io/blog/tags/facade/</link><description>Recent content in Facade 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/facade/index.xml" rel="self" type="application/rss+xml"/><item><title>核心 entity 重寫、140+ 檔消費端不動 — Deprecated Getter Facade 的過渡設計</title><link>https://tarrragon.github.io/blog/work-log/flutter_deprecated_getter_facade_entity_migration/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_deprecated_getter_facade_entity_migration/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的 Book entity 要從固定欄位（author、publisher、isbn、genre……）重寫成 tag-based 結構。動手前的 ripple 盤點：&lt;code>.author&lt;/code> 有 64 個檔在用、&lt;code>.isbn&lt;/code> 60 個、&lt;code>.publisher&lt;/code> 45 個——加上測試合計 140+ 檔受影響
&lt;strong>疑問來源&lt;/strong>：核心 entity 是所有 Service / Repository / ViewModel 的上游、必須先改；但 140+ 檔的 ripple 又讓「先改它」等於同時打爆整個專案。怎麼解這個死結？
&lt;strong>整理目的&lt;/strong>：記下大 ripple entity 演化的三個選項、facade 策略的機制與配套、以及它跟「永久相容層」的一線之隔
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.32 的 ticket 記錄（含 PM 前置調查與 SA 審查結論）；「認知負擔閾值 &amp;gt; 5 檔必須拆分」是該專案自訂的工作規則&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="先量化-ripple再選策略">先量化 ripple、再選策略&lt;/h2>
&lt;p>這次重寫在動手前做了一件關鍵的事：把「影響很大」量化成數字。逐欄位 grep 消費端：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>廢除欄位&lt;/th>
 &lt;th>lib/ 引用檔數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>.author&lt;/code>&lt;/td>
 &lt;td>64&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.isbn&lt;/code>&lt;/td>
 &lt;td>60&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.publisher&lt;/code>&lt;/td>
 &lt;td>45&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.source&lt;/code>&lt;/td>
 &lt;td>27&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.importanceLevel&lt;/code>&lt;/td>
 &lt;td>17&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.readingStatus&lt;/code>&lt;/td>
 &lt;td>16&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>加上 77 個測試檔、合計 140+ 檔。這張表直接判定了原提案（單一 ticket 重寫 entity）的死刑——PM 調查的結論寫得直白：「多 Wave migration 偽裝成單一 ticket」。數字的價值在這裡：&lt;strong>策略選擇是 ripple 規模的函數&lt;/strong>，不先量化就選策略、等於矇著眼選。&lt;/p>
&lt;h2 id="三個選項兩個否決理由">三個選項、兩個否決理由&lt;/h2>
&lt;p>SA 審查列了三條路、否決理由都寫進了記錄：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>直接移除 + 全量遷移&lt;/strong>：140+ 檔同時修改，違反該專案的認知負擔閾值（單次修改 &amp;gt; 5 檔必須拆分）、且無法在「測試 100% 通過」的前提下原子完成——改到一半的每個中間狀態都是編譯不過的&lt;/li>
&lt;li>&lt;strong>長期分支開發&lt;/strong>：分支與 main 的 merge conflict 成本隨時間指數成長，而且其他 Wave 的 ticket 依賴新 entity——分支隔離了風險、也隔離了下游的進度&lt;/li>
&lt;li>&lt;strong>Deprecated Getter Facade&lt;/strong>（勝出）：entity 換新結構、舊介面保留為過渡層&lt;/li>
&lt;/ul>
&lt;h2 id="facade-機制舊介面成為新資料的-view">Facade 機制：舊介面成為新資料的 view&lt;/h2>
&lt;p>核心手法一段程式碼講完：&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="c1">// Book entity 內：新結構是 tag 關聯
&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">// 舊欄位保留為 deprecated getter、從新結構回讀
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="err">@&lt;/span>&lt;span class="n">Deprecated&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Use tagRepository.getTagsForBook(bookId, category: &amp;#34;author&amp;#34;) instead&amp;#39;&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">BookAuthor&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">author&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_legacyAuthorFromTags&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>entity 持有 repository 注入的 &lt;code>List&amp;lt;BookTag&amp;gt;&lt;/code>，每個廢除欄位各有一個 deprecated getter、從 tags 過濾對應分類、回傳&lt;strong>舊型別&lt;/strong>。效果分三層：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>消費端 140+ 檔零修改編譯通過&lt;/strong>——舊介面的形狀完整保留，只是資料來源換了&lt;/li>
&lt;li>&lt;strong>&lt;code>@Deprecated&lt;/code> 把遷移清單交給編譯器&lt;/strong>——每個舊呼叫點自動變成 warning，「還剩多少沒遷」隨時可查、不靠人工盤點&lt;/li>
&lt;li>&lt;strong>新程式碼從第一天用新 API&lt;/strong>（&lt;code>getTagsByCategory&lt;/code>）——新舊並行、但增量只往新的走&lt;/li>
&lt;/ul>
&lt;p>配套的兩個細節同樣值得記：新集合命名 &lt;code>bookTags&lt;/code> 刻意避開 entity 既有的 &lt;code>tags&lt;/code> 欄位（遷移期兩者並存、同名會災難）；序列化走雙軌——&lt;code>toJson&lt;/code> 維持 v1 格式相容既有持久化、新的交換格式獨立成 &lt;code>toInterchangeJson&lt;/code> v2，讀寫兩個世界互不干擾。&lt;/p>
&lt;h2 id="facade-與永久相容層的一線之隔退場計畫">facade 與永久相容層的一線之隔：退場計畫&lt;/h2>
&lt;p>這個策略跟同專案早年&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 擺盪&lt;/a>裡「加回 &lt;code>.value&lt;/code> getter 當相容性介面」在機制上是同一件事——差別全在配套。那次的 getter 加回來就沒有然後了、成為永久的一部分；這次的 facade 在 ticket 系統裡直接 spawn 了九張後續票、逐 Wave 遷移各消費端，deprecated getter 的死期寫在 backlog 上。&lt;/p>
&lt;p>&lt;strong>facade 的性質由退場計畫決定&lt;/strong>：有計畫、它是分期償還的過渡層；沒計畫、它是把重構宣告完成的化妝——新舊兩套 API 永久並存、每個新人都要學「哪個是真的」。判斷一個 codebase 裡的 deprecated 標記是哪一種，看它有沒有對應的遷移工作項、以及 warning 數量的趨勢是降是平。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的 Book entity 要從固定欄位（author、publisher、isbn、genre……）重寫成 tag-based 結構。動手前的 ripple 盤點：<code>.author</code> 有 64 個檔在用、<code>.isbn</code> 60 個、<code>.publisher</code> 45 個——加上測試合計 140+ 檔受影響
<strong>疑問來源</strong>：核心 entity 是所有 Service / Repository / ViewModel 的上游、必須先改；但 140+ 檔的 ripple 又讓「先改它」等於同時打爆整個專案。怎麼解這個死結？
<strong>整理目的</strong>：記下大 ripple entity 演化的三個選項、facade 策略的機制與配套、以及它跟「永久相容層」的一線之隔
<strong>本文邊界</strong>：素材是該專案 v0.32 的 ticket 記錄（含 PM 前置調查與 SA 審查結論）；「認知負擔閾值 &gt; 5 檔必須拆分」是該專案自訂的工作規則</p></blockquote>
<hr>
<h2 id="先量化-ripple再選策略">先量化 ripple、再選策略</h2>
<p>這次重寫在動手前做了一件關鍵的事：把「影響很大」量化成數字。逐欄位 grep 消費端：</p>
<table>
  <thead>
      <tr>
          <th>廢除欄位</th>
          <th>lib/ 引用檔數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.author</code></td>
          <td>64</td>
      </tr>
      <tr>
          <td><code>.isbn</code></td>
          <td>60</td>
      </tr>
      <tr>
          <td><code>.publisher</code></td>
          <td>45</td>
      </tr>
      <tr>
          <td><code>.source</code></td>
          <td>27</td>
      </tr>
      <tr>
          <td><code>.importanceLevel</code></td>
          <td>17</td>
      </tr>
      <tr>
          <td><code>.readingStatus</code></td>
          <td>16</td>
      </tr>
  </tbody>
</table>
<p>加上 77 個測試檔、合計 140+ 檔。這張表直接判定了原提案（單一 ticket 重寫 entity）的死刑——PM 調查的結論寫得直白：「多 Wave migration 偽裝成單一 ticket」。數字的價值在這裡：<strong>策略選擇是 ripple 規模的函數</strong>，不先量化就選策略、等於矇著眼選。</p>
<h2 id="三個選項兩個否決理由">三個選項、兩個否決理由</h2>
<p>SA 審查列了三條路、否決理由都寫進了記錄：</p>
<ul>
<li><strong>直接移除 + 全量遷移</strong>：140+ 檔同時修改，違反該專案的認知負擔閾值（單次修改 &gt; 5 檔必須拆分）、且無法在「測試 100% 通過」的前提下原子完成——改到一半的每個中間狀態都是編譯不過的</li>
<li><strong>長期分支開發</strong>：分支與 main 的 merge conflict 成本隨時間指數成長，而且其他 Wave 的 ticket 依賴新 entity——分支隔離了風險、也隔離了下游的進度</li>
<li><strong>Deprecated Getter Facade</strong>（勝出）：entity 換新結構、舊介面保留為過渡層</li>
</ul>
<h2 id="facade-機制舊介面成為新資料的-view">Facade 機制：舊介面成為新資料的 view</h2>
<p>核心手法一段程式碼講完：</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="c1">// Book entity 內：新結構是 tag 關聯
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 舊欄位保留為 deprecated getter、從新結構回讀
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="err">@</span><span class="n">Deprecated</span><span class="p">(</span><span class="s1">&#39;Use tagRepository.getTagsForBook(bookId, category: &#34;author&#34;) instead&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">BookAuthor</span> <span class="kd">get</span> <span class="n">author</span> <span class="o">=&gt;</span> <span class="n">_legacyAuthorFromTags</span><span class="p">();</span></span></span></code></pre></div><p>entity 持有 repository 注入的 <code>List&lt;BookTag&gt;</code>，每個廢除欄位各有一個 deprecated getter、從 tags 過濾對應分類、回傳<strong>舊型別</strong>。效果分三層：</p>
<ul>
<li><strong>消費端 140+ 檔零修改編譯通過</strong>——舊介面的形狀完整保留，只是資料來源換了</li>
<li><strong><code>@Deprecated</code> 把遷移清單交給編譯器</strong>——每個舊呼叫點自動變成 warning，「還剩多少沒遷」隨時可查、不靠人工盤點</li>
<li><strong>新程式碼從第一天用新 API</strong>（<code>getTagsByCategory</code>）——新舊並行、但增量只往新的走</li>
</ul>
<p>配套的兩個細節同樣值得記：新集合命名 <code>bookTags</code> 刻意避開 entity 既有的 <code>tags</code> 欄位（遷移期兩者並存、同名會災難）；序列化走雙軌——<code>toJson</code> 維持 v1 格式相容既有持久化、新的交換格式獨立成 <code>toInterchangeJson</code> v2，讀寫兩個世界互不干擾。</p>
<h2 id="facade-與永久相容層的一線之隔退場計畫">facade 與永久相容層的一線之隔：退場計畫</h2>
<p>這個策略跟同專案早年<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 擺盪</a>裡「加回 <code>.value</code> getter 當相容性介面」在機制上是同一件事——差別全在配套。那次的 getter 加回來就沒有然後了、成為永久的一部分；這次的 facade 在 ticket 系統裡直接 spawn 了九張後續票、逐 Wave 遷移各消費端，deprecated getter 的死期寫在 backlog 上。</p>
<p><strong>facade 的性質由退場計畫決定</strong>：有計畫、它是分期償還的過渡層；沒計畫、它是把重構宣告完成的化妝——新舊兩套 API 永久並存、每個新人都要學「哪個是真的」。判斷一個 codebase 裡的 deprecated 標記是哪一種，看它有沒有對應的遷移工作項、以及 warning 數量的趨勢是降是平。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>核心 entity / 介面要重寫、而「先量 ripple」沒做——grep 出消費端檔數再開會，策略討論會短很多</li>
<li>單一 ticket 的影響檔數超過團隊的認知閾值——它是偽裝成 ticket 的 migration，拆 wave</li>
<li>deprecated getter 存在超過 N 個版本、warning 數量不降——facade 已變永久相容層，補退場計畫或誠實移除 @Deprecated</li>
<li>遷移期新舊集合 / 方法同名或近名——先改名再並行，同名並存的每一天都在累積誤用</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>對照組：<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪</a>——同樣的 getter 相容層、沒有退場計畫的版本</li>
<li>原則層：<a href="/blog/report/incremental-shipping-criteria/" data-link-title="分批 ship：低風險可見價值先行、結構性下輪" data-link-desc="「一次 ship 全部」的衝動 vs 「分批 ship」的設計：判準三軸（使用者可見性 / 風險暴露面 / 驗證需求）。低風險 &#43; 高可見 = 立刻 ship；高風險 &#43; 需驗證 = 下輪。對抗「完整才完整」的全做衝動、避免一次塞太多 review surface 拖延上線。">#76 分批 ship：低風險可見價值先行</a>——facade + wave 遷移就是分批 ship 在 entity 演化上的形態</li>
<li>這次遷移的下游驚險：<a href="/blog/work-log/flutter_migration_read_path_gap_fake_green/" data-link-title="遷移計畫有寫入、有消費、缺讀出 — read-path 缺口與 fixture 假綠" data-link-desc="資料模型遷移的通路要三段齊：寫入 backfill、讀取路徑、消費端 API。缺讀出那段時，新 API 拿到的永遠是空集合——而消費端測試的 fixture 自己建物件、不走真實讀取路徑，測試全綠掩蓋 runtime 靜默失效。依賴圖只列「誰先做」不列語意前提時，dashboard 的 ready 是假訊號。">read-path 缺口與 fixture 假綠</a>——facade 讓編譯過了、但新結構的資料通路要自己驗證</li>
</ul>
]]></content:encoded></item></channel></rss>