<?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>Layered-Architecture on Tarragon</title><link>https://tarrragon.github.io/blog/tags/layered-architecture/</link><description>Recent content in Layered-Architecture 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/layered-architecture/index.xml" rel="self" type="application/rss+xml"/><item><title>Domain 層的 947 處硬編碼中文 — 訊息代碼跟顯示文字的分層責任</title><link>https://tarrragon.github.io/blog/work-log/flutter_domain_layer_i18n_hardcoded_text/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_domain_layer_i18n_hardcoded_text/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 專案要上多語言，盤點後發現 Domain 層有 947 處中文硬編碼——&lt;code>enum IssueType { missingTitle('缺失標題') }&lt;/code> 這類寫法散在七個模組
&lt;strong>疑問來源&lt;/strong>：這些字串當初寫起來很自然（enum 帶個顯示名稱、result 物件帶個 summary），為什麼會變成架構問題？
&lt;strong>整理目的&lt;/strong>：記下「訊息內容」與「訊息文字」的分層責任劃分、以及大量硬編碼的遷移策略
&lt;strong>本文邊界&lt;/strong>：素材是一個 Flutter 書籍管理 App 的重構記錄（第一個模組完成時的狀態）；分層原則語言無關、translator extension 是 Dart 的實作載體&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="947-處是怎麼長出來的">947 處是怎麼長出來的&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">// Domain 層
&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">enum&lt;/span> &lt;span class="n">IssueType&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">missingTitle&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="c1">// UI 文字在 Domain 層
&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="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>enum 定義同步問題的類型，順手帶上顯示名稱——單獨看每一處都是便利的選擇：呼叫端要顯示時直接取，少一層轉換。同樣模式的還有 &lt;code>SyncResult.summary&lt;/code> 這種直接組出中文句子的 getter、以及 value object 上的 &lt;code>displayName&lt;/code> 屬性。七個模組累積下來的分佈：synchronization 25 處、import 46、search 47、export 52、scanner 82、version_management 90、library 563。&lt;/p>
&lt;p>問題在多語言需求出現時一次引爆：文字寫死在 Domain 層，翻譯就得改 Domain——而 Domain 層理應對「呈現給誰、用什麼語言」一無所知。&lt;/p>
&lt;h2 id="劃分判準領域事實-vs-呈現">劃分判準：領域事實 vs 呈現&lt;/h2>
&lt;p>修法的核心是把每個字串問一次：&lt;strong>這是領域事實、還是呈現？&lt;/strong>&lt;/p>
&lt;p>「這筆書目缺標題」是領域事實——同步檢查的產出、業務邏輯的分支依據。「缺失標題」四個中文字是呈現——同一個事實在英文介面叫 missing title。兩者的變動理由不同（業務規則 vs 語言與文案），依變動理由分層：&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">// Domain 層：只有代碼
&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">enum&lt;/span> &lt;span class="n">SyncIssueCode&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">missingTitle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;MISSING_TITLE&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="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">// UI 層：translator extension
&lt;/span>&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 class="n">extension&lt;/span> &lt;span class="n">SyncIssueCodeTranslator&lt;/span> &lt;span class="n">on&lt;/span> &lt;span class="n">SyncIssueCode&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="kt">String&lt;/span> &lt;span class="n">toLocalizedTitle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">AppLocalizations&lt;/span> &lt;span class="n">l10n&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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="k">this&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">11&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="n">SyncIssueCode&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nl">missingTitle:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">l10n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">syncIssueMissingTitle&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 翻譯在 UI 層
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&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">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>exhaustive switch 讓這個橋接有編譯期保證：Domain 新增一個代碼、UI 層的 translator 沒跟上就編譯失敗——兩層的同步靠型別系統守、不靠人記得。&lt;/p>
&lt;h2 id="組合訊息的拆法結構化資料取代成品字串">組合訊息的拆法：結構化資料取代成品字串&lt;/h2>
&lt;p>純代碼替換解決不了所有 947 處。有些字串是組合出來的——「同步完成，3 筆受影響」這種帶數量的訊息，Domain 層原本直接回成品字串。重構的處理是把&lt;strong>資料&lt;/strong>跟&lt;strong>模板&lt;/strong>拆開：&lt;/p>
&lt;ul>
&lt;li>&lt;code>CheckResult.message&lt;/code> 改成 &lt;code>messageCode&lt;/code>（枚舉）、新增 &lt;code>affectedCount&lt;/code> 屬性——數量是領域事實、由 Domain 提供&lt;/li>
&lt;li>&lt;code>SyncResult.summary&lt;/code>（回中文字串的 getter）移除、改成 &lt;code>resultCode&lt;/code> 加 &lt;code>summaryData&lt;/code>（結構化資料）——句子怎麼組、單複數怎麼變化，是 UI 層拿著資料跟 arb 模板的事&lt;/li>
&lt;/ul>
&lt;p>判讀方式：Domain 層的職責邊界劃在「提供組句需要的全部事實」，句子本身屬於呈現層。&lt;/p>
&lt;h2 id="遷移策略從最小模組先行">遷移策略：從最小模組先行&lt;/h2>
&lt;p>七個模組的處理順序按硬編碼數量由少到多：synchronization（25 處）先做、library（563 處）最後。第一個模組承擔的是&lt;strong>驗證模式&lt;/strong>——6 個代碼枚舉怎麼切、translator extension 怎麼組織、arb 鍵怎麼命名、測試怎麼跟著改，全套模式在最小的模組上走通（103 個相關測試全過），後面六個模組就是重複已驗證的模式。反過來從 563 處的 library 開刀，模式還沒定就得承擔最大的返工面。&lt;/p>
&lt;p>測試的連動也在第一個模組現形：直接斷言中文字串的測試（&lt;code>expect(issue.description, '缺失標題')&lt;/code>）全部要改成斷言枚舉。這類測試本來就把呈現細節烤進了斷言——重構把它們一併修正成對領域事實的斷言。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>Domain 層的 enum 建構子參數是給人看的自然語言（而不是代碼常數）&lt;/li>
&lt;li>value object 或 result 物件上有 &lt;code>displayName&lt;/code>、&lt;code>summary&lt;/code>、&lt;code>message&lt;/code> 這類回傳成句文字的成員&lt;/li>
&lt;li>測試斷言裡出現 UI 文案字串&lt;/li>
&lt;li>多語言需求評估時，翻譯範圍清單裡出現 domain 目錄&lt;/li>
&lt;/ul>
&lt;p>四個訊號指向同一件事：呈現知識滲進了領域層，多語言只是讓它現形的第一個下游需求——文案改版、A/B 測試文案、依角色顯示不同措辭，都會撞上同一堵牆。&lt;/p>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>方法論全貌：&lt;a href="https://tarrragon.github.io/blog/record/business-layer-i18n-management-methodology/" data-link-title="i18n 的責任邊界：Domain 給錯誤碼，ViewModel 給訊息" data-link-desc="要加一種語言卻發現使用者訊息散在 Domain、ViewModel、UI 三層時，用來判定各層對 i18n 各自負責什麼、字串該住在哪一層">業務層 i18n 管理方法論&lt;/a>——本文是該方法論在 Domain 層的實機重構記錄&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a> 的分層責任章節&lt;/li>
&lt;li>同族判準：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計&lt;/a>——兩篇的共同結構是「每一處都便利的局部選擇、累積成架構問題」，差別在洩漏方向（呈現滲入領域 vs 變更繞過領域）&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 專案要上多語言，盤點後發現 Domain 層有 947 處中文硬編碼——<code>enum IssueType { missingTitle('缺失標題') }</code> 這類寫法散在七個模組
<strong>疑問來源</strong>：這些字串當初寫起來很自然（enum 帶個顯示名稱、result 物件帶個 summary），為什麼會變成架構問題？
<strong>整理目的</strong>：記下「訊息內容」與「訊息文字」的分層責任劃分、以及大量硬編碼的遷移策略
<strong>本文邊界</strong>：素材是一個 Flutter 書籍管理 App 的重構記錄（第一個模組完成時的狀態）；分層原則語言無關、translator extension 是 Dart 的實作載體</p></blockquote>
<hr>
<h2 id="947-處是怎麼長出來的">947 處是怎麼長出來的</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">// Domain 層
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">enum</span> <span class="n">IssueType</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">missingTitle</span><span class="p">(</span><span class="s1">&#39;缺失標題&#39;</span><span class="p">),</span>   <span class="c1">// UI 文字在 Domain 層
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="p">...</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>enum 定義同步問題的類型，順手帶上顯示名稱——單獨看每一處都是便利的選擇：呼叫端要顯示時直接取，少一層轉換。同樣模式的還有 <code>SyncResult.summary</code> 這種直接組出中文句子的 getter、以及 value object 上的 <code>displayName</code> 屬性。七個模組累積下來的分佈：synchronization 25 處、import 46、search 47、export 52、scanner 82、version_management 90、library 563。</p>
<p>問題在多語言需求出現時一次引爆：文字寫死在 Domain 層，翻譯就得改 Domain——而 Domain 層理應對「呈現給誰、用什麼語言」一無所知。</p>
<h2 id="劃分判準領域事實-vs-呈現">劃分判準：領域事實 vs 呈現</h2>
<p>修法的核心是把每個字串問一次：<strong>這是領域事實、還是呈現？</strong></p>
<p>「這筆書目缺標題」是領域事實——同步檢查的產出、業務邏輯的分支依據。「缺失標題」四個中文字是呈現——同一個事實在英文介面叫 missing title。兩者的變動理由不同（業務規則 vs 語言與文案），依變動理由分層：</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">// Domain 層：只有代碼
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">enum</span> <span class="n">SyncIssueCode</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">missingTitle</span><span class="p">(</span><span class="s1">&#39;MISSING_TITLE&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// UI 層：translator extension
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="n">extension</span> <span class="n">SyncIssueCodeTranslator</span> <span class="n">on</span> <span class="n">SyncIssueCode</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kt">String</span> <span class="n">toLocalizedTitle</span><span class="p">(</span><span class="n">AppLocalizations</span> <span class="n">l10n</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">switch</span> <span class="p">(</span><span class="k">this</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="k">case</span> <span class="n">SyncIssueCode</span><span class="p">.</span><span class="nl">missingTitle:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="n">l10n</span><span class="p">.</span><span class="n">syncIssueMissingTitle</span><span class="p">;</span>   <span class="c1">// 翻譯在 UI 層
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>exhaustive switch 讓這個橋接有編譯期保證：Domain 新增一個代碼、UI 層的 translator 沒跟上就編譯失敗——兩層的同步靠型別系統守、不靠人記得。</p>
<h2 id="組合訊息的拆法結構化資料取代成品字串">組合訊息的拆法：結構化資料取代成品字串</h2>
<p>純代碼替換解決不了所有 947 處。有些字串是組合出來的——「同步完成，3 筆受影響」這種帶數量的訊息，Domain 層原本直接回成品字串。重構的處理是把<strong>資料</strong>跟<strong>模板</strong>拆開：</p>
<ul>
<li><code>CheckResult.message</code> 改成 <code>messageCode</code>（枚舉）、新增 <code>affectedCount</code> 屬性——數量是領域事實、由 Domain 提供</li>
<li><code>SyncResult.summary</code>（回中文字串的 getter）移除、改成 <code>resultCode</code> 加 <code>summaryData</code>（結構化資料）——句子怎麼組、單複數怎麼變化，是 UI 層拿著資料跟 arb 模板的事</li>
</ul>
<p>判讀方式：Domain 層的職責邊界劃在「提供組句需要的全部事實」，句子本身屬於呈現層。</p>
<h2 id="遷移策略從最小模組先行">遷移策略：從最小模組先行</h2>
<p>七個模組的處理順序按硬編碼數量由少到多：synchronization（25 處）先做、library（563 處）最後。第一個模組承擔的是<strong>驗證模式</strong>——6 個代碼枚舉怎麼切、translator extension 怎麼組織、arb 鍵怎麼命名、測試怎麼跟著改，全套模式在最小的模組上走通（103 個相關測試全過），後面六個模組就是重複已驗證的模式。反過來從 563 處的 library 開刀，模式還沒定就得承擔最大的返工面。</p>
<p>測試的連動也在第一個模組現形：直接斷言中文字串的測試（<code>expect(issue.description, '缺失標題')</code>）全部要改成斷言枚舉。這類測試本來就把呈現細節烤進了斷言——重構把它們一併修正成對領域事實的斷言。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>Domain 層的 enum 建構子參數是給人看的自然語言（而不是代碼常數）</li>
<li>value object 或 result 物件上有 <code>displayName</code>、<code>summary</code>、<code>message</code> 這類回傳成句文字的成員</li>
<li>測試斷言裡出現 UI 文案字串</li>
<li>多語言需求評估時，翻譯範圍清單裡出現 domain 目錄</li>
</ul>
<p>四個訊號指向同一件事：呈現知識滲進了領域層，多語言只是讓它現形的第一個下游需求——文案改版、A/B 測試文案、依角色顯示不同措辭，都會撞上同一堵牆。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>方法論全貌：<a href="/blog/record/business-layer-i18n-management-methodology/" data-link-title="i18n 的責任邊界：Domain 給錯誤碼，ViewModel 給訊息" data-link-desc="要加一種語言卻發現使用者訊息散在 Domain、ViewModel、UI 三層時，用來判定各層對 i18n 各自負責什麼、字串該住在哪一層">業務層 i18n 管理方法論</a>——本文是該方法論在 Domain 層的實機重構記錄</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a> 的分層責任章節</li>
<li>同族判準：<a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>——兩篇的共同結構是「每一處都便利的局部選擇、累積成架構問題」，差別在洩漏方向（呈現滲入領域 vs 變更繞過領域）</li>
</ul>
]]></content:encoded></item></channel></rss>