<?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>Serialization on Tarragon</title><link>https://tarrragon.github.io/blog/tags/serialization/</link><description>Recent content in Serialization 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/serialization/index.xml" rel="self" type="application/rss+xml"/><item><title>有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題</title><link>https://tarrragon.github.io/blog/work-log/flutter_fake_backend_real_model_serialization/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_fake_backend_real_model_serialization/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：假後端的回應資料從哪來？手寫 JSON 字串、還是建構真實模型物件再 &lt;code>toJson()&lt;/code>？POS App 的&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>選了後者，理由不是美觀——是「後端回應形狀的知識」在專案裡應該只有一份（產品的模型解析層），fixture 自己再寫一份就會分岔。
&lt;strong>案例骨幹&lt;/strong>：有狀態假後端以 freezed 模型（單據、明細、記錄）持有狀態、handler 用 &lt;code>copyWith&lt;/code> 演變狀態、出口一律 &lt;code>toJson()&lt;/code>。同一時期另一批用 raw JSON 手刻請求的測試，重踩了「列表回應帶分頁包裝」的問題——產品的回應信封解析早就內建了這層 unwrap，手刻等於把已解決的問題再解一次。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-假後端的回應出口物件--tojson不是字串樣板">1. 假後端的回應出口：物件 → toJson，不是字串樣板&lt;/h2>
&lt;p>有狀態假後端攔在 HTTP adapter 層，對每個端點回放狀態。回應的組裝方式：&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">// 狀態就是真實模型物件
&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="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Cart&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">carts&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="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Record&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">records&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">// 出口統一序列化——服務層拿到的 JSON 與真實後端同構
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">isCartList&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">))&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="k">return&lt;/span> &lt;span class="n">envelope&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="n">carts&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toJson&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;/code>&lt;/pre>&lt;/div>&lt;p>freezed 模型天生雙向（&lt;code>fromJson&lt;/code>/&lt;code>toJson&lt;/code>），這保證了一個閉環：&lt;strong>假後端 seed 的物件 → toJson → 服務層 fromJson → 前端邏輯&lt;/strong>，服務層走的解析路徑與生產環境完全相同。手寫 JSON 樣板跳過的正是這個閉環的前半段——樣板對不對，靠人眼比對 API 文件。&lt;/p>
&lt;h2 id="2-對照組事故raw-手刻重踩分頁包裝">2. 對照組事故：raw 手刻重踩分頁包裝&lt;/h2>
&lt;p>同一時期，另一批對真實後端發請求的測試最初用 raw JSON 手刻（自組請求、手挖回應欄位）。首跑失敗：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">type &amp;#39;_Map&amp;lt;String, dynamic&amp;gt;&amp;#39; is not a subtype of type &amp;#39;List&amp;lt;dynamic&amp;gt;&amp;#39;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>真實後端的列表端點帶分頁包裝——&lt;code>data&lt;/code> 不是陣列，是 &lt;code>{ data: [...], previousPage, nextPage, ... }&lt;/code>。而產品的回應信封模型&lt;strong>早就內建&lt;/strong>了「嘗試解分頁包裝」的邏輯，生產 App 天天在正確處理這個形狀。raw 寫法等於把一個已解決的問題重新發現、重新修補（在測試裡加了一個 &lt;code>_listData&lt;/code> helper）——直到改用產品的 API client 與模型解析，這個 helper 連同它代表的重複知識一起刪除。&lt;/p>
&lt;p>教訓一句話：&lt;strong>回應形狀的知識只該存在一份&lt;/strong>。fixture 或測試裡出現第二份（手寫 JSON、手挖欄位），它與第一份的分岔只是時間問題。&lt;/p>
&lt;h2 id="3-狀態演變用-copywith行為住在-handler">3. 狀態演變用 copyWith，行為住在 handler&lt;/h2>
&lt;p>有狀態假後端與「回放固定回應的 stub」的分水嶺在於它模擬後端動詞的效果。freezed 的 &lt;code>copyWith&lt;/code> 讓這件事保持宣告式：&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">// 「合併」的效果：明細全部換新 id、記錄改掛新單據
&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">final&lt;/span> &lt;span class="n">newDetails&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">d&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="n">old&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">details&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">d&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">id:&lt;/span> &lt;span class="n">newId&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="n">records&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="n">records&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">parentId:&lt;/span> &lt;span class="n">mergedId&lt;/span>&lt;span class="p">)];&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 handler 頭上一句話描述它模擬的後端行為（來源是實測證實，不是猜測）；測試 seed 初始物件、跑真實服務鏈、斷言假後端的狀態變化與前端的對齊結果。方法論層的完整討論見&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;/p>
&lt;h2 id="4-邊界tojson-回放的前提是模型忠實">4. 邊界：toJson 回放的前提是模型忠實&lt;/h2>
&lt;p>這個做法有一個隱含前提：&lt;strong>產品模型的 &lt;code>fromJson&lt;/code>/&lt;code>toJson&lt;/code> 對真實後端是忠實的&lt;/strong>。若模型本身漏了欄位、轉換器不對稱，假後端的閉環會把錯誤一起閉進去——測試綠、對真實後端壞。補這個洞的是配對的&lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>：它走同一套模型打真實環境，模型與後端的形狀分岔會在那裡現形（分頁包裝那次事故正是被它抓到的）。&lt;/p>
&lt;h2 id="5-可複用的判準">5. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>假後端／fixture 的回應一律「建構模型物件 → toJson」，不手寫 JSON 字串。&lt;/li>
&lt;li>測試裡出現「手挖回應欄位」的 helper（&lt;code>data['data']&lt;/code> 之類）＝重複知識的訊號，改走產品解析層。&lt;/li>
&lt;li>有狀態假後端的狀態演變用 &lt;code>copyWith&lt;/code> 在 handler 裡宣告式完成，一個 handler 對應一條已證實的後端行為。&lt;/li>
&lt;li>toJson 回放閉環的忠實性由真實後端驗證測試把關——兩者是配對關係，不是二選一。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>方法論層 → &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/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>&lt;/li>
&lt;li>freezed 模型設計的機制層 → &lt;a href="https://tarrragon.github.io/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：假後端的回應資料從哪來？手寫 JSON 字串、還是建構真實模型物件再 <code>toJson()</code>？POS App 的<a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>選了後者，理由不是美觀——是「後端回應形狀的知識」在專案裡應該只有一份（產品的模型解析層），fixture 自己再寫一份就會分岔。
<strong>案例骨幹</strong>：有狀態假後端以 freezed 模型（單據、明細、記錄）持有狀態、handler 用 <code>copyWith</code> 演變狀態、出口一律 <code>toJson()</code>。同一時期另一批用 raw JSON 手刻請求的測試，重踩了「列表回應帶分頁包裝」的問題——產品的回應信封解析早就內建了這層 unwrap，手刻等於把已解決的問題再解一次。</p></blockquote>
<hr>
<h2 id="1-假後端的回應出口物件--tojson不是字串樣板">1. 假後端的回應出口：物件 → toJson，不是字串樣板</h2>
<p>有狀態假後端攔在 HTTP adapter 層，對每個端點回放狀態。回應的組裝方式：</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">// 狀態就是真實模型物件
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">List</span><span class="o">&lt;</span><span class="n">Cart</span><span class="o">&gt;</span> <span class="n">carts</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">List</span><span class="o">&lt;</span><span class="n">Record</span><span class="o">&gt;</span> <span class="n">records</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 出口統一序列化——服務層拿到的 JSON 與真實後端同構
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="n">isCartList</span><span class="p">(</span><span class="n">request</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">return</span> <span class="n">envelope</span><span class="p">([</span><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">c</span> <span class="k">in</span> <span class="n">carts</span><span class="p">)</span> <span class="n">c</span><span class="p">.</span><span class="n">toJson</span><span class="p">()]);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>freezed 模型天生雙向（<code>fromJson</code>/<code>toJson</code>），這保證了一個閉環：<strong>假後端 seed 的物件 → toJson → 服務層 fromJson → 前端邏輯</strong>，服務層走的解析路徑與生產環境完全相同。手寫 JSON 樣板跳過的正是這個閉環的前半段——樣板對不對，靠人眼比對 API 文件。</p>
<h2 id="2-對照組事故raw-手刻重踩分頁包裝">2. 對照組事故：raw 手刻重踩分頁包裝</h2>
<p>同一時期，另一批對真實後端發請求的測試最初用 raw JSON 手刻（自組請求、手挖回應欄位）。首跑失敗：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">type &#39;_Map&lt;String, dynamic&gt;&#39; is not a subtype of type &#39;List&lt;dynamic&gt;&#39;</span></span></code></pre></div><p>真實後端的列表端點帶分頁包裝——<code>data</code> 不是陣列，是 <code>{ data: [...], previousPage, nextPage, ... }</code>。而產品的回應信封模型<strong>早就內建</strong>了「嘗試解分頁包裝」的邏輯，生產 App 天天在正確處理這個形狀。raw 寫法等於把一個已解決的問題重新發現、重新修補（在測試裡加了一個 <code>_listData</code> helper）——直到改用產品的 API client 與模型解析，這個 helper 連同它代表的重複知識一起刪除。</p>
<p>教訓一句話：<strong>回應形狀的知識只該存在一份</strong>。fixture 或測試裡出現第二份（手寫 JSON、手挖欄位），它與第一份的分岔只是時間問題。</p>
<h2 id="3-狀態演變用-copywith行為住在-handler">3. 狀態演變用 copyWith，行為住在 handler</h2>
<p>有狀態假後端與「回放固定回應的 stub」的分水嶺在於它模擬後端動詞的效果。freezed 的 <code>copyWith</code> 讓這件事保持宣告式：</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">// 「合併」的效果：明細全部換新 id、記錄改掛新單據
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">newDetails</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">d</span> <span class="k">in</span> <span class="n">old</span><span class="p">.</span><span class="n">details</span><span class="p">)</span> <span class="n">d</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">id:</span> <span class="n">newId</span><span class="p">())];</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">records</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">r</span> <span class="k">in</span> <span class="n">records</span><span class="p">)</span> <span class="n">r</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">parentId:</span> <span class="n">mergedId</span><span class="p">)];</span></span></span></code></pre></div><p>每個 handler 頭上一句話描述它模擬的後端行為（來源是實測證實，不是猜測）；測試 seed 初始物件、跑真實服務鏈、斷言假後端的狀態變化與前端的對齊結果。方法論層的完整討論見<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>。</p>
<h2 id="4-邊界tojson-回放的前提是模型忠實">4. 邊界：toJson 回放的前提是模型忠實</h2>
<p>這個做法有一個隱含前提：<strong>產品模型的 <code>fromJson</code>/<code>toJson</code> 對真實後端是忠實的</strong>。若模型本身漏了欄位、轉換器不對稱，假後端的閉環會把錯誤一起閉進去——測試綠、對真實後端壞。補這個洞的是配對的<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>：它走同一套模型打真實環境，模型與後端的形狀分岔會在那裡現形（分頁包裝那次事故正是被它抓到的）。</p>
<h2 id="5-可複用的判準">5. 可複用的判準</h2>
<ol>
<li>假後端／fixture 的回應一律「建構模型物件 → toJson」，不手寫 JSON 字串。</li>
<li>測試裡出現「手挖回應欄位」的 helper（<code>data['data']</code> 之類）＝重複知識的訊號，改走產品解析層。</li>
<li>有狀態假後端的狀態演變用 <code>copyWith</code> 在 handler 裡宣告式完成，一個 handler 對應一條已證實的後端行為。</li>
<li>toJson 回放閉環的忠實性由真實後端驗證測試把關——兩者是配對關係，不是二選一。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li>方法論層 → <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/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>freezed 模型設計的機制層 → <a href="/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite 只吃三種型別 — value object 在持久化邊界的序列化契約</title><link>https://tarrragon.github.io/blog/work-log/flutter_sqlite_value_object_serialization_boundary/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_sqlite_value_object_serialization_boundary/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的資料庫整合測試全面失敗，錯誤訊息：&lt;code>Invalid argument 整合測試作者 with type BookAuthor. Only num, String and Uint8List are supported&lt;/code>——所有涉及 SQLite 的 CRUD 操作都掛
&lt;strong>疑問來源&lt;/strong>：同一個 map 裡 &lt;code>id&lt;/code> 跟 &lt;code>title&lt;/code> 都存得進去，為什麼 &lt;code>author&lt;/code> 炸了？
&lt;strong>整理目的&lt;/strong>：記下 value object 跨持久化邊界的轉換責任、以及 toString/fromString 這條隱性契約的風險
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.10.6 的修復規劃記錄；sqflite 的型別限制是 SQLite 本身的特性、不是套件的設計選擇&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="錯誤現場三個欄位兩種寫法">錯誤現場：三個欄位、兩種寫法&lt;/h2>
&lt;p>炸點在 repository 把 entity 轉成資料庫 map 的方法：&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="n">Map&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">dynamic&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_bookToMap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Book&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">)&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="k">return&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="s1">&amp;#39;id&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="c1">// BookId → String，存得進去
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="s1">&amp;#39;title&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="c1">// BookTitle → String，存得進去
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="s1">&amp;#39;author&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">author&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// BookAuthor 物件直接塞 → 炸
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="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;/code>&lt;/pre>&lt;/div>&lt;p>sqflite 底下的 SQLite 只接受 &lt;code>num&lt;/code>、&lt;code>String&lt;/code>、&lt;code>Uint8List&lt;/code> 三種型別。&lt;code>BookAuthor&lt;/code> 是帶內部狀態的 value object（作者清單、譯者），直接放進 map 就是把一個 Dart 物件遞給不認識它的儲存引擎。錯誤訊息其實說得很清楚——難的不是修，是這個錯誤揭露的責任問題：&lt;strong>誰負責把領域型別拆成儲存型別？&lt;/strong>&lt;/p>
&lt;h2 id="責任歸位轉換發生在-repository-邊界">責任歸位：轉換發生在 repository 邊界&lt;/h2>
&lt;p>修法本身一行：&lt;code>'author': book.author.toString()&lt;/code>；讀回的方向 &lt;code>_mapToBook&lt;/code> 已經在用 &lt;code>BookAuthor.fromString(map['author'])&lt;/code> 重建。架構上這是 adapter 的職責放在 repository 層——domain 的 value object 不知道 SQLite 存在、SQLite 不知道 value object 存在，兩個世界的轉換集中在 I/O 邊界的 &lt;code>_bookToMap&lt;/code> / &lt;code>_mapToBook&lt;/code> 一對方法裡。&lt;/p>
&lt;p>這個歸位讓錯誤的形態變得可預測：&lt;strong>每個新的 VO 欄位都要在這對方法裡出現一次&lt;/strong>，漏掉序列化端會炸 Invalid argument（吵、好抓）、漏掉反序列化端會在讀取時炸型別轉換（也吵）。真正安靜的坑在第三種情況——兩端都寫了、但不對稱。&lt;/p>
&lt;h2 id="隱性契約tostring-與-fromstring-的對稱性沒人強制">隱性契約：toString 與 fromString 的對稱性沒人強制&lt;/h2>
&lt;p>用 &lt;code>toString()&lt;/code> / &lt;code>fromString()&lt;/code> 當序列化通道，工作的前提是 &lt;code>fromString(x.toString()) == x&lt;/code>——而這條契約沒有任何機制在守。修復記錄自己就把風險寫進了已知限制：複雜物件轉字串可能遺失部分內部狀態、未來需要更精細的序列化機制。&lt;/p>
&lt;p>具體的斷裂點兩種。其一，&lt;code>toString()&lt;/code> 的本業是除錯表示，哪天有人為了 log 可讀性把格式改成 &lt;code>BookAuthor(name: ...)&lt;/code>，資料庫裡從此存進去的是新格式、舊資料用新 &lt;code>fromString&lt;/code> 讀不回來——&lt;strong>兩個消費者（除錯與持久化）寄生在同一個方法上、變更理由不同步&lt;/strong>。其二，格式本身有損：這個專案的 &lt;code>BookAuthor&lt;/code> 把多作者序列化成「作者1, 作者2」、譯者成「作者 (譯者 譯)」——作者名字裡出現逗號或括號時，roundtrip 就不再對稱。&lt;/p>
&lt;p>正解方向修復記錄也留了：語意明確的序列化介面（&lt;code>toDbValue()&lt;/code> / 專用 &lt;code>Serializable&lt;/code>、或 JSON 結構化），讓「持久化格式」成為一個有自己名字、自己測試、自己變更理由的東西。過渡期至少要補上對稱性測試——對每個 VO 斷言 &lt;code>fromString(v.toString()) == v&lt;/code>、含邊界值（空作者、多作者、含譯者），把隱性契約變成會紅的測試。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>&lt;code>Invalid argument ... with type X. Only num, String and Uint8List are supported&lt;/code>——X 就是漏轉換的 VO、去 &lt;code>_toMap&lt;/code> 系方法找它&lt;/li>
&lt;li>repository 的 map 轉換裡混用「物件直接放」跟「&lt;code>.toString()&lt;/code>」兩種寫法——前者是還沒炸的候選&lt;/li>
&lt;li>&lt;code>toString()&lt;/code> 同時服務除錯輸出跟持久化 / 快取 key——語意寄生，兩個消費者遲早有一個要改格式&lt;/li>
&lt;li>VO 有 &lt;code>fromString&lt;/code> 但測試裡沒有任何 roundtrip 斷言——對稱契約處於未驗證狀態&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>出口語意的原則版：&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>toString()&lt;/code> 被當出口用的實際風險清單&lt;/li>
&lt;li>持久化邊界的另一面：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化&lt;/a>——那篇是欄位沒進出邊界、本文是進了邊界但轉換錯誤，roundtrip 測試同時守住兩者&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a> 的 entity 持久化邊界章節&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的資料庫整合測試全面失敗，錯誤訊息：<code>Invalid argument 整合測試作者 with type BookAuthor. Only num, String and Uint8List are supported</code>——所有涉及 SQLite 的 CRUD 操作都掛
<strong>疑問來源</strong>：同一個 map 裡 <code>id</code> 跟 <code>title</code> 都存得進去，為什麼 <code>author</code> 炸了？
<strong>整理目的</strong>：記下 value object 跨持久化邊界的轉換責任、以及 toString/fromString 這條隱性契約的風險
<strong>本文邊界</strong>：素材是該專案 v0.10.6 的修復規劃記錄；sqflite 的型別限制是 SQLite 本身的特性、不是套件的設計選擇</p></blockquote>
<hr>
<h2 id="錯誤現場三個欄位兩種寫法">錯誤現場：三個欄位、兩種寫法</h2>
<p>炸點在 repository 把 entity 轉成資料庫 map 的方法：</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="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">_bookToMap</span><span class="p">(</span><span class="n">Book</span> <span class="n">book</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s1">&#39;id&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">id</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span>        <span class="c1">// BookId → String，存得進去
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;title&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">title</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span>  <span class="c1">// BookTitle → String，存得進去
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;author&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">author</span><span class="p">,</span>           <span class="c1">// BookAuthor 物件直接塞 → 炸
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="p">...</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>sqflite 底下的 SQLite 只接受 <code>num</code>、<code>String</code>、<code>Uint8List</code> 三種型別。<code>BookAuthor</code> 是帶內部狀態的 value object（作者清單、譯者），直接放進 map 就是把一個 Dart 物件遞給不認識它的儲存引擎。錯誤訊息其實說得很清楚——難的不是修，是這個錯誤揭露的責任問題：<strong>誰負責把領域型別拆成儲存型別？</strong></p>
<h2 id="責任歸位轉換發生在-repository-邊界">責任歸位：轉換發生在 repository 邊界</h2>
<p>修法本身一行：<code>'author': book.author.toString()</code>；讀回的方向 <code>_mapToBook</code> 已經在用 <code>BookAuthor.fromString(map['author'])</code> 重建。架構上這是 adapter 的職責放在 repository 層——domain 的 value object 不知道 SQLite 存在、SQLite 不知道 value object 存在，兩個世界的轉換集中在 I/O 邊界的 <code>_bookToMap</code> / <code>_mapToBook</code> 一對方法裡。</p>
<p>這個歸位讓錯誤的形態變得可預測：<strong>每個新的 VO 欄位都要在這對方法裡出現一次</strong>，漏掉序列化端會炸 Invalid argument（吵、好抓）、漏掉反序列化端會在讀取時炸型別轉換（也吵）。真正安靜的坑在第三種情況——兩端都寫了、但不對稱。</p>
<h2 id="隱性契約tostring-與-fromstring-的對稱性沒人強制">隱性契約：toString 與 fromString 的對稱性沒人強制</h2>
<p>用 <code>toString()</code> / <code>fromString()</code> 當序列化通道，工作的前提是 <code>fromString(x.toString()) == x</code>——而這條契約沒有任何機制在守。修復記錄自己就把風險寫進了已知限制：複雜物件轉字串可能遺失部分內部狀態、未來需要更精細的序列化機制。</p>
<p>具體的斷裂點兩種。其一，<code>toString()</code> 的本業是除錯表示，哪天有人為了 log 可讀性把格式改成 <code>BookAuthor(name: ...)</code>，資料庫裡從此存進去的是新格式、舊資料用新 <code>fromString</code> 讀不回來——<strong>兩個消費者（除錯與持久化）寄生在同一個方法上、變更理由不同步</strong>。其二，格式本身有損：這個專案的 <code>BookAuthor</code> 把多作者序列化成「作者1, 作者2」、譯者成「作者 (譯者 譯)」——作者名字裡出現逗號或括號時，roundtrip 就不再對稱。</p>
<p>正解方向修復記錄也留了：語意明確的序列化介面（<code>toDbValue()</code> / 專用 <code>Serializable</code>、或 JSON 結構化），讓「持久化格式」成為一個有自己名字、自己測試、自己變更理由的東西。過渡期至少要補上對稱性測試——對每個 VO 斷言 <code>fromString(v.toString()) == v</code>、含邊界值（空作者、多作者、含譯者），把隱性契約變成會紅的測試。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li><code>Invalid argument ... with type X. Only num, String and Uint8List are supported</code>——X 就是漏轉換的 VO、去 <code>_toMap</code> 系方法找它</li>
<li>repository 的 map 轉換裡混用「物件直接放」跟「<code>.toString()</code>」兩種寫法——前者是還沒炸的候選</li>
<li><code>toString()</code> 同時服務除錯輸出跟持久化 / 快取 key——語意寄生，兩個消費者遲早有一個要改格式</li>
<li>VO 有 <code>fromString</code> 但測試裡沒有任何 roundtrip 斷言——對稱契約處於未驗證狀態</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>——那篇論證「原始值要有語意明確的官方出口」，本文是 <code>toString()</code> 被當出口用的實際風險清單</li>
<li>持久化邊界的另一面：<a href="/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化</a>——那篇是欄位沒進出邊界、本文是進了邊界但轉換錯誤，roundtrip 測試同時守住兩者</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a> 的 entity 持久化邊界章節</li>
</ul>
]]></content:encoded></item><item><title>功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區</title><link>https://tarrragon.github.io/blog/work-log/flutter_feature_complete_never_persisted/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_feature_complete_never_persisted/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的借閱功能（UC-06）開發完成——value object、業務方法、狀態屬性、測試全過、宣告完成。兩個 use case 之後、準備跨裝置同步（UC-07）時的架構盤點才發現：借閱資料從未被持久化，App 重啟借閱狀態就消失
&lt;strong>疑問來源&lt;/strong>：測試全綠的功能怎麼會漏掉整個持久層？而且漏了兩個 use case 都沒人發現？
&lt;strong>整理目的&lt;/strong>：記下持久化缺口躲過驗收的機制、以及把它攔在完成宣告之前的檢查點
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.18（借閱持久化補齊）與 v0.32（tag 遷移盤點）兩份記錄；兩個 case 是同一個盲區的兩種形態&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="case-1整個持久層缺席功能照樣完成">Case 1：整個持久層缺席、功能照樣「完成」&lt;/h2>
&lt;p>UC-06 交付時的狀態：&lt;code>BookLoan&lt;/code> value object 完整（借閱類型、日期、歸還、逾期判斷）、&lt;code>Book&lt;/code> entity 有整組借閱管理業務方法、狀態屬性齊全、測試全過。但三個持久化環節全部缺席：&lt;/p>
&lt;ul>
&lt;li>&lt;code>Book.toJson()&lt;/code> 沒有序列化 &lt;code>activeLoan&lt;/code>&lt;/li>
&lt;li>&lt;code>Book.fromJson()&lt;/code> 沒有解析 &lt;code>activeLoan&lt;/code>&lt;/li>
&lt;li>資料庫沒有 &lt;code>book_loans&lt;/code> 表&lt;/li>
&lt;/ul>
&lt;p>後果是功能在單次執行內完全正常、App 重啟即失憶。這個洞存活了兩個 use case，直到 UC-07（跨裝置同步）的前置盤點把各層完成度攤開——Schema 層 95%、Domain 層 80%、&lt;strong>Book Entity 40%（缺借閱序列化）&lt;/strong>——才現形。發現它的不是測試、是「同步需要資料在裝置之間移動、而借閱資料根本出不了記憶體」這個下游需求。&lt;/p>
&lt;h2 id="躲過驗收的機制測試的邊界就是持久化的盲區">躲過驗收的機制：測試的邊界就是持久化的盲區&lt;/h2>
&lt;p>測試全綠跟資料沒落地並不矛盾——所有測試都在記憶體內驗證業務行為：借書之後 &lt;code>isActive&lt;/code> 為真、歸還之後 &lt;code>isReturned&lt;/code> 為真、逾期計算正確。&lt;strong>沒有任何一條測試走過「序列化、重建、比對」的迴圈&lt;/strong>，於是「這個物件能不能離開記憶體」從來不在任何斷言的守備範圍內。&lt;/p>
&lt;p>單元測試的這個邊界是結構性的：domain 測試本來就不該碰資料庫。問題出在完成定義——「domain 測試全過」被當成了「功能完成」，而持久化迴圈不在任何 use case 的驗收條件裡。修正時補上的測試正好說明缺的是哪一類：序列化測試（DM-35 到 DM-38、SER-01 到 SER-11）裡最關鍵的是&lt;strong>循環一致性&lt;/strong>——&lt;code>toJson&lt;/code> 再 &lt;code>fromJson&lt;/code> 回來的物件要跟原物件相等。roundtrip 測試不碰資料庫、成本跟單元測試相同，但它守住「這個物件的完整狀態能離開又回來」。&lt;/p>
&lt;p>修正本身是機械的：補 &lt;code>BookLoan.toJson/fromJson&lt;/code>、&lt;code>Book&lt;/code> 的 activeLoan 序列化、建 &lt;code>book_loans&lt;/code> 表加索引、資料庫版本 v4 升 v5 附遷移腳本。機械修正花一個 Phase 0 就完成——貴的從來不是修、是晚了兩個 use case 才發現。&lt;/p>
&lt;h2 id="case-2更陰險的形態欄位存在寫入層靜默丟棄">Case 2：更陰險的形態——欄位存在、寫入層靜默丟棄&lt;/h2>
&lt;p>同一個專案後期（v0.32）的 tag 系統遷移盤點，抓到同一個盲區的第二形態。&lt;code>Book&lt;/code> entity 有 &lt;code>genre&lt;/code>、&lt;code>source&lt;/code>、&lt;code>importance&lt;/code> 欄位，遷移設計假設七類資料都有來源；實際掃描 schema 與 repository 後發現：&lt;/p>
&lt;blockquote>
&lt;p>books 實體表僅持久化 4 個欄位……source 無實體欄位、&lt;code>_mapToBook&lt;/code> 恆回傳 &lt;code>BookSource.physical()&lt;/code>、未持久化；genre 無實體欄位、&lt;code>_bookToMap&lt;/code> 無 genre key。&lt;/p>&lt;/blockquote>
&lt;p>這比 Case 1 陰險：Case 1 是欄位不見（重啟後 &lt;code>activeLoan&lt;/code> 是 null、至少看得出來空了），Case 2 是&lt;strong>預設值冒充資料&lt;/strong>——每本書讀出來 source 都是 &lt;code>physical&lt;/code>，欄位「有值」、值是假的。統計、篩選照著假值運作，沒有任何一層會報錯。這次盤點的價值在於把落差變成顯式決策：遷移行為契約以實際有資料的 4 欄為準、對另外三類明確標記 no-op，而不是讓 Phase 2 測試對不存在的資料設斷言。&lt;/p>
&lt;h2 id="檢查點把持久化迴圈寫進完成定義">檢查點：把持久化迴圈寫進完成定義&lt;/h2>
&lt;p>兩個 case 收斂成三個可操作的檢查：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>roundtrip 測試是每個可持久化 entity 的標配&lt;/strong>：&lt;code>fromJson(toJson(x)) == x&lt;/code>，含每個 optional 欄位有值與無值的變體。它抓 Case 1 的整段缺席，也抓「加了欄位忘了序列化」的增量缺口&lt;/li>
&lt;li>&lt;strong>entity 欄位對 schema 欄位做差集&lt;/strong>：差集裡的每個欄位，要嘛有明確的「不持久化」決策記錄、要嘛就是一筆靜默失真。Case 2 的三個欄位在差集裡躺了很久、沒有人決策過&lt;/li>
&lt;li>&lt;strong>mapper 裡的常數是警訊&lt;/strong>：&lt;code>_mapToBook&lt;/code> 恆回 &lt;code>BookSource.physical()&lt;/code> 這種寫死的預設值，語意是「這個欄位的讀取路徑沒有來源」——它該是一個顯式的 TODO 或決策，不該長得跟正常映射一樣&lt;/li>
&lt;/ul>
&lt;p>「功能完成」的定義問題在這兩個 case 裡是同一個：完成度被「行為測試通過率」代表，而行為測試的守備範圍不含資料的出入境。驗收條件補一句「重啟後狀態仍在」，兩個洞都會在第一天現形。&lt;/p>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同族的增量版：&lt;a href="https://tarrragon.github.io/blog/work-log/reset_state_leak_cross_test/" data-link-title="新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因" data-link-desc="測試結果取決於執行順序、看似功能 bug 實為上一個 test case 狀態沒清乾淨。根因是新增 private 欄位時沒同步更新 reset，隱含契約沒被顯性化。">新增欄位忘記同步 reset&lt;/a>——entity 加欄位時的同步點清單（constructor / copyWith / toJson / fromJson / schema / mapper），本文的 Case 2 就是清單裡 schema 與 mapper 兩項長期缺席的結果&lt;/li>
&lt;li>同構原則：&lt;a href="https://tarrragon.github.io/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 檢查規則的作用域要顯式列舉&lt;/a>——「測試存在」與「測試涵蓋持久化迴圈」是兩個獨立的 fact，全綠只證明前者&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>——entity 的完整性包含它跨越持久化邊界的能力&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的借閱功能（UC-06）開發完成——value object、業務方法、狀態屬性、測試全過、宣告完成。兩個 use case 之後、準備跨裝置同步（UC-07）時的架構盤點才發現：借閱資料從未被持久化，App 重啟借閱狀態就消失
<strong>疑問來源</strong>：測試全綠的功能怎麼會漏掉整個持久層？而且漏了兩個 use case 都沒人發現？
<strong>整理目的</strong>：記下持久化缺口躲過驗收的機制、以及把它攔在完成宣告之前的檢查點
<strong>本文邊界</strong>：素材是該專案 v0.18（借閱持久化補齊）與 v0.32（tag 遷移盤點）兩份記錄；兩個 case 是同一個盲區的兩種形態</p></blockquote>
<hr>
<h2 id="case-1整個持久層缺席功能照樣完成">Case 1：整個持久層缺席、功能照樣「完成」</h2>
<p>UC-06 交付時的狀態：<code>BookLoan</code> value object 完整（借閱類型、日期、歸還、逾期判斷）、<code>Book</code> entity 有整組借閱管理業務方法、狀態屬性齊全、測試全過。但三個持久化環節全部缺席：</p>
<ul>
<li><code>Book.toJson()</code> 沒有序列化 <code>activeLoan</code></li>
<li><code>Book.fromJson()</code> 沒有解析 <code>activeLoan</code></li>
<li>資料庫沒有 <code>book_loans</code> 表</li>
</ul>
<p>後果是功能在單次執行內完全正常、App 重啟即失憶。這個洞存活了兩個 use case，直到 UC-07（跨裝置同步）的前置盤點把各層完成度攤開——Schema 層 95%、Domain 層 80%、<strong>Book Entity 40%（缺借閱序列化）</strong>——才現形。發現它的不是測試、是「同步需要資料在裝置之間移動、而借閱資料根本出不了記憶體」這個下游需求。</p>
<h2 id="躲過驗收的機制測試的邊界就是持久化的盲區">躲過驗收的機制：測試的邊界就是持久化的盲區</h2>
<p>測試全綠跟資料沒落地並不矛盾——所有測試都在記憶體內驗證業務行為：借書之後 <code>isActive</code> 為真、歸還之後 <code>isReturned</code> 為真、逾期計算正確。<strong>沒有任何一條測試走過「序列化、重建、比對」的迴圈</strong>，於是「這個物件能不能離開記憶體」從來不在任何斷言的守備範圍內。</p>
<p>單元測試的這個邊界是結構性的：domain 測試本來就不該碰資料庫。問題出在完成定義——「domain 測試全過」被當成了「功能完成」，而持久化迴圈不在任何 use case 的驗收條件裡。修正時補上的測試正好說明缺的是哪一類：序列化測試（DM-35 到 DM-38、SER-01 到 SER-11）裡最關鍵的是<strong>循環一致性</strong>——<code>toJson</code> 再 <code>fromJson</code> 回來的物件要跟原物件相等。roundtrip 測試不碰資料庫、成本跟單元測試相同，但它守住「這個物件的完整狀態能離開又回來」。</p>
<p>修正本身是機械的：補 <code>BookLoan.toJson/fromJson</code>、<code>Book</code> 的 activeLoan 序列化、建 <code>book_loans</code> 表加索引、資料庫版本 v4 升 v5 附遷移腳本。機械修正花一個 Phase 0 就完成——貴的從來不是修、是晚了兩個 use case 才發現。</p>
<h2 id="case-2更陰險的形態欄位存在寫入層靜默丟棄">Case 2：更陰險的形態——欄位存在、寫入層靜默丟棄</h2>
<p>同一個專案後期（v0.32）的 tag 系統遷移盤點，抓到同一個盲區的第二形態。<code>Book</code> entity 有 <code>genre</code>、<code>source</code>、<code>importance</code> 欄位，遷移設計假設七類資料都有來源；實際掃描 schema 與 repository 後發現：</p>
<blockquote>
<p>books 實體表僅持久化 4 個欄位……source 無實體欄位、<code>_mapToBook</code> 恆回傳 <code>BookSource.physical()</code>、未持久化；genre 無實體欄位、<code>_bookToMap</code> 無 genre key。</p></blockquote>
<p>這比 Case 1 陰險：Case 1 是欄位不見（重啟後 <code>activeLoan</code> 是 null、至少看得出來空了），Case 2 是<strong>預設值冒充資料</strong>——每本書讀出來 source 都是 <code>physical</code>，欄位「有值」、值是假的。統計、篩選照著假值運作，沒有任何一層會報錯。這次盤點的價值在於把落差變成顯式決策：遷移行為契約以實際有資料的 4 欄為準、對另外三類明確標記 no-op，而不是讓 Phase 2 測試對不存在的資料設斷言。</p>
<h2 id="檢查點把持久化迴圈寫進完成定義">檢查點：把持久化迴圈寫進完成定義</h2>
<p>兩個 case 收斂成三個可操作的檢查：</p>
<ul>
<li><strong>roundtrip 測試是每個可持久化 entity 的標配</strong>：<code>fromJson(toJson(x)) == x</code>，含每個 optional 欄位有值與無值的變體。它抓 Case 1 的整段缺席，也抓「加了欄位忘了序列化」的增量缺口</li>
<li><strong>entity 欄位對 schema 欄位做差集</strong>：差集裡的每個欄位，要嘛有明確的「不持久化」決策記錄、要嘛就是一筆靜默失真。Case 2 的三個欄位在差集裡躺了很久、沒有人決策過</li>
<li><strong>mapper 裡的常數是警訊</strong>：<code>_mapToBook</code> 恆回 <code>BookSource.physical()</code> 這種寫死的預設值，語意是「這個欄位的讀取路徑沒有來源」——它該是一個顯式的 TODO 或決策，不該長得跟正常映射一樣</li>
</ul>
<p>「功能完成」的定義問題在這兩個 case 裡是同一個：完成度被「行為測試通過率」代表，而行為測試的守備範圍不含資料的出入境。驗收條件補一句「重啟後狀態仍在」，兩個洞都會在第一天現形。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同族的增量版：<a href="/blog/work-log/reset_state_leak_cross_test/" data-link-title="新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因" data-link-desc="測試結果取決於執行順序、看似功能 bug 實為上一個 test case 狀態沒清乾淨。根因是新增 private 欄位時沒同步更新 reset，隱含契約沒被顯性化。">新增欄位忘記同步 reset</a>——entity 加欄位時的同步點清單（constructor / copyWith / toJson / fromJson / schema / mapper），本文的 Case 2 就是清單裡 schema 與 mapper 兩項長期缺席的結果</li>
<li>同構原則：<a href="/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 檢查規則的作用域要顯式列舉</a>——「測試存在」與「測試涵蓋持久化迴圈」是兩個獨立的 fact，全綠只證明前者</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>——entity 的完整性包含它跨越持久化邊界的能力</li>
</ul>
]]></content:encoded></item></channel></rss>