<?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>Sqflite on Tarragon</title><link>https://tarrragon.github.io/blog/tags/sqflite/</link><description>Recent content in Sqflite 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/sqflite/index.xml" rel="self" type="application/rss+xml"/><item><title>1000 本書、1001 次 SQL — N+1 查詢藏在 async mapper 裡</title><link>https://tarrragon.github.io/blog/work-log/flutter_sqlite_n_plus_one_query/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_sqlite_n_plus_one_query/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 進效能優化階段（UC-08）的現況盤點，抓到一個 P0：&lt;code>getAllBooks()&lt;/code> 對 1000 本書發出 1001 次 SQL 查詢、耗時約 2 秒；推算 10000 本書要 20 秒、UI 完全卡死
&lt;strong>疑問來源&lt;/strong>：沒有人寫過「對每本書查一次資料庫」這種程式碼——這個 N+1 是怎麼長出來的？
&lt;strong>整理目的&lt;/strong>：記下 N+1 在 ORM 之外的手寫形態（async mapper）、修法的結構、以及為什麼它在開發期永遠感覺不到
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.19.0 的 Phase 0 評估記錄；耗時數字是該記錄的影響評估、量級可信&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="形成兩個各自合理的函式組合出災難">形成：兩個各自合理的函式、組合出災難&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">// 單筆轉換：把一列 DB map 轉成 Book——順便把它的 tags 查出來
&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">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_mapToBook&lt;/span>&lt;span class="p">(&lt;/span>&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">map&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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">final&lt;/span> &lt;span class="n">db&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">_database&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="kd">final&lt;/span> &lt;span class="n">tagResult&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;book_tags&amp;#39;&lt;/span>&lt;span class="p">,&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"> 5&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"> 6&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">// 列表查詢：撈全部、逐筆轉換
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Future&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_mapToBook&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">map&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>_mapToBook&lt;/code> 單看合理——Book 有 tags、轉換時補齊關聯是「完整的轉換」；&lt;code>getAllBooks&lt;/code> 單看也合理——每筆都用同一個轉換函式、&lt;code>Future.wait&lt;/code> 還做了並行。&lt;strong>N+1 不存在於任何一行、它存在於組合&lt;/strong>：一個做 IO 的轉換函式、被一個列表方法乘以 N。&lt;/p>
&lt;p>結構性的病灶是&lt;strong>mapper 做了 IO&lt;/strong>。轉換函式的職責是「資料形狀的映射」，把查詢塞進去的那一刻，它的成本從 O(1) 記憶體操作變成一次網路 / 磁碟往返——而呼叫端從簽名上看不出來（回傳本來就是 Future、多一次 await 沒有任何警訊）。&lt;/p>
&lt;h2 id="為什麼開發期永遠感覺不到">為什麼開發期永遠感覺不到&lt;/h2>
&lt;p>影響評估的三個數字說明了它的隱身機制：100 本約 200ms、1000 本約 2 秒、10000 本約 20 秒。開發跟測試用的資料集是幾十本——200ms 以下、混在正常的載入時間裡無法察覺。&lt;strong>N+1 的惡化是使用者資料量的線性函數&lt;/strong>，寫下它的人永遠不會遇到它爆炸的那天，遇到的是半年後書最多的那批使用者。&lt;/p>
&lt;p>這也解釋了為什麼它由效能盤點抓到、而不是被任何測試抓到：功能測試的資料量跟開發期一樣小，而「查詢次數」不在任何斷言的守備範圍——除非專門寫「操作 X 的 SQL 次數 ≤ K」這種預算型測試。&lt;/p>
&lt;h2 id="修法io-上移mapper-變純">修法：IO 上移、mapper 變純&lt;/h2>
&lt;p>修法的設計把查詢次數從 N+1 收斂到 2：&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">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&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="c1">// 1. 一次撈所有書
&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="kd">final&lt;/span> &lt;span class="n">books&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;books&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">orderBy:&lt;/span> &lt;span class="s1">&amp;#39;added_date DESC&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 2. 一次撈所有標籤（IN 批次）
&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="kd">final&lt;/span> &lt;span class="n">bookIds&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">books&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;id&amp;#39;&lt;/span>&lt;span class="p">]).&lt;/span>&lt;span class="n">toList&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="kd">final&lt;/span> &lt;span class="n">allTags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;book_tags&amp;#39;&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="nl">where:&lt;/span> &lt;span class="s1">&amp;#39;book_id IN (&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">bookIds&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="s1">&amp;#39;?&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">join&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="si">}&lt;/span>&lt;span class="s1">)&amp;#39;&lt;/span>&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="nl">whereArgs:&lt;/span> &lt;span class="n">bookIds&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 3. 記憶體組裝
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">tagsByBookId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_groupTagsByBookId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">allTags&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="k">return&lt;/span> &lt;span class="n">books&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_mapToBookWithTags&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tagsByBookId&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="n">toList&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>結構上的重點勝過次數本身：&lt;strong>IO 全部上移到列表方法、轉換函式變純&lt;/strong>——&lt;code>_mapToBookWithTags&lt;/code> 收「這本書的列」跟「查好的 tags 索引」、不碰資料庫。純轉換函式拿回了三個性質：成本可預期（呼叫 N 次就是 N 次記憶體操作）、可單獨測試（餵 map 斷言 Book）、且&lt;strong>結構上不可能再退化成 N+1&lt;/strong>——它沒有 db 可查。這跟 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">pure function 領域計算&lt;/a>是同一個藥方在資料層的應用：IO 在邊界、計算（轉換）在核心。&lt;/p>
&lt;h2 id="伴生發現能力早就存在預設路徑不經過它">伴生發現：能力早就存在、預設路徑不經過它&lt;/h2>
&lt;p>同一次盤點還抓到第二個 P0——&lt;code>allBooksProvider&lt;/code> 直呼 &lt;code>getAllBooks()&lt;/code> 全量載入、記憶體隨書量膨脹。反直覺的是修這個問題&lt;strong>不用寫新能力&lt;/strong>：repository 早就有 &lt;code>getBooks(limit, offset)&lt;/code> 分頁方法、批次新增也有、快取系統也完整。能力都在、只是 UI 的預設路徑（&lt;code>allBooksProvider&lt;/code>）不經過它們。&lt;/p>
&lt;p>「有能力」跟「預設路徑用它」是兩回事——provider 是所有畫面拿書的入口、它選全載、分頁能力就是死碼。這是&lt;a href="https://tarrragon.github.io/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣&lt;/a>的資料層版本：要讓分頁被用、要嘛預設 provider 就是分頁的、要嘛全載入口加上明確的成本標記，靠「大家記得用分頁版」跟靠任何慣例一樣不可靠。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 進效能優化階段（UC-08）的現況盤點，抓到一個 P0：<code>getAllBooks()</code> 對 1000 本書發出 1001 次 SQL 查詢、耗時約 2 秒；推算 10000 本書要 20 秒、UI 完全卡死
<strong>疑問來源</strong>：沒有人寫過「對每本書查一次資料庫」這種程式碼——這個 N+1 是怎麼長出來的？
<strong>整理目的</strong>：記下 N+1 在 ORM 之外的手寫形態（async mapper）、修法的結構、以及為什麼它在開發期永遠感覺不到
<strong>本文邊界</strong>：素材是該專案 v0.19.0 的 Phase 0 評估記錄；耗時數字是該記錄的影響評估、量級可信</p></blockquote>
<hr>
<h2 id="形成兩個各自合理的函式組合出災難">形成：兩個各自合理的函式、組合出災難</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">// 單筆轉換：把一列 DB map 轉成 Book——順便把它的 tags 查出來
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">Future</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;</span> <span class="n">_mapToBook</span><span class="p">(</span><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">map</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="n">db</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">_database</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">tagResult</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;book_tags&#39;</span><span class="p">,</span> <span class="p">...);</span>   <span class="c1">// 每本書一次
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="p">...</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">// 列表查詢：撈全部、逐筆轉換
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="n">Future</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">getAllBooks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">return</span> <span class="n">Future</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">map</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">_mapToBook</span><span class="p">(</span><span class="n">map</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>_mapToBook</code> 單看合理——Book 有 tags、轉換時補齊關聯是「完整的轉換」；<code>getAllBooks</code> 單看也合理——每筆都用同一個轉換函式、<code>Future.wait</code> 還做了並行。<strong>N+1 不存在於任何一行、它存在於組合</strong>：一個做 IO 的轉換函式、被一個列表方法乘以 N。</p>
<p>結構性的病灶是<strong>mapper 做了 IO</strong>。轉換函式的職責是「資料形狀的映射」，把查詢塞進去的那一刻，它的成本從 O(1) 記憶體操作變成一次網路 / 磁碟往返——而呼叫端從簽名上看不出來（回傳本來就是 Future、多一次 await 沒有任何警訊）。</p>
<h2 id="為什麼開發期永遠感覺不到">為什麼開發期永遠感覺不到</h2>
<p>影響評估的三個數字說明了它的隱身機制：100 本約 200ms、1000 本約 2 秒、10000 本約 20 秒。開發跟測試用的資料集是幾十本——200ms 以下、混在正常的載入時間裡無法察覺。<strong>N+1 的惡化是使用者資料量的線性函數</strong>，寫下它的人永遠不會遇到它爆炸的那天，遇到的是半年後書最多的那批使用者。</p>
<p>這也解釋了為什麼它由效能盤點抓到、而不是被任何測試抓到：功能測試的資料量跟開發期一樣小，而「查詢次數」不在任何斷言的守備範圍——除非專門寫「操作 X 的 SQL 次數 ≤ K」這種預算型測試。</p>
<h2 id="修法io-上移mapper-變純">修法：IO 上移、mapper 變純</h2>
<p>修法的設計把查詢次數從 N+1 收斂到 2：</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">Future</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">getAllBooks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 1. 一次撈所有書
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">books</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;books&#39;</span><span class="p">,</span> <span class="nl">orderBy:</span> <span class="s1">&#39;added_date DESC&#39;</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">// 2. 一次撈所有標籤（IN 批次）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">bookIds</span> <span class="o">=</span> <span class="n">books</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">b</span><span class="p">[</span><span class="s1">&#39;id&#39;</span><span class="p">]).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kd">final</span> <span class="n">allTags</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;book_tags&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nl">where:</span> <span class="s1">&#39;book_id IN (</span><span class="si">${</span><span class="n">bookIds</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s1">&#39;?&#39;</span><span class="p">).</span><span class="n">join</span><span class="p">(</span><span class="s1">&#39;,&#39;</span><span class="p">)</span><span class="si">}</span><span class="s1">)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">whereArgs:</span> <span class="n">bookIds</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="c1">// 3. 記憶體組裝
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">tagsByBookId</span> <span class="o">=</span> <span class="n">_groupTagsByBookId</span><span class="p">(</span><span class="n">allTags</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">return</span> <span class="n">books</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">_mapToBookWithTags</span><span class="p">(</span><span class="n">b</span><span class="p">,</span> <span class="n">tagsByBookId</span><span class="p">)).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>結構上的重點勝過次數本身：<strong>IO 全部上移到列表方法、轉換函式變純</strong>——<code>_mapToBookWithTags</code> 收「這本書的列」跟「查好的 tags 索引」、不碰資料庫。純轉換函式拿回了三個性質：成本可預期（呼叫 N 次就是 N 次記憶體操作）、可單獨測試（餵 map 斷言 Book）、且<strong>結構上不可能再退化成 N+1</strong>——它沒有 db 可查。這跟 <a href="/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">pure function 領域計算</a>是同一個藥方在資料層的應用：IO 在邊界、計算（轉換）在核心。</p>
<h2 id="伴生發現能力早就存在預設路徑不經過它">伴生發現：能力早就存在、預設路徑不經過它</h2>
<p>同一次盤點還抓到第二個 P0——<code>allBooksProvider</code> 直呼 <code>getAllBooks()</code> 全量載入、記憶體隨書量膨脹。反直覺的是修這個問題<strong>不用寫新能力</strong>：repository 早就有 <code>getBooks(limit, offset)</code> 分頁方法、批次新增也有、快取系統也完整。能力都在、只是 UI 的預設路徑（<code>allBooksProvider</code>）不經過它們。</p>
<p>「有能力」跟「預設路徑用它」是兩回事——provider 是所有畫面拿書的入口、它選全載、分頁能力就是死碼。這是<a href="/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣</a>的資料層版本：要讓分頁被用、要嘛預設 provider 就是分頁的、要嘛全載入口加上明確的成本標記，靠「大家記得用分頁版」跟靠任何慣例一樣不可靠。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li><strong>mapper / 轉換函式的簽名是 <code>async</code></strong>——最便宜的掃描：<code>rg &quot;Future&lt;\w+&gt; _mapTo&quot; lib/</code>，每個命中都問它裡面的 await 在等什麼</li>
<li>迴圈或 <code>Future.wait</code> 裡呼叫「單筆處理」函式、而該函式含查詢——N+1 的標準組合形</li>
<li>某操作的耗時隨資料量線性惡化、但單筆操作很快——次數問題、不是單次效能問題</li>
<li>repository 有分頁 / 批次 API、但 provider / service 層的預設入口全量載入——能力與預設路徑脫節</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同藥方的領域計算版：<a href="/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">「該收多少錢」抽成 pure function</a>——IO 在邊界、核心保持純，資料層與領域層各一個現場</li>
<li>預設路徑的原則：<a href="/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣</a>——分頁能力閒置的機制跟工具預設值是同一件事</li>
<li>查詢預算的守法：<a href="/blog/work-log/flutter_test_signal_credibility_three_layers/" data-link-title="紅燈在量什麼 — 測試訊號的三層失真：斷言、量測、環境" data-link-desc="「全套件降至 0」有意義的前置條件是紅燈只反映程式缺陷。三層各自會失真：絕對計時斷言量的是機器負載、compact reporter 高並行下行覆寫產生假陰性、fresh checkout 缺 gitignored 生成產物讓整包結果不可信。含 flaky 判定的取樣門檻與對照實驗定歸因的做法。">紅燈在量什麼</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></channel></rss>