<?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>依賴方向 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E4%BE%9D%E8%B3%B4%E6%96%B9%E5%90%91/</link><description>Recent content in 依賴方向 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 04 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E4%BE%9D%E8%B3%B4%E6%96%B9%E5%90%91/index.xml" rel="self" type="application/rss+xml"/><item><title>層級架構品質檢查機制 - Clean Architecture 合規性驗證</title><link>https://tarrragon.github.io/blog/record/%E5%B1%A4%E7%B4%9A%E6%9E%B6%E6%A7%8B%E5%93%81%E8%B3%AA%E6%AA%A2%E6%9F%A5%E6%A9%9F%E5%88%B6-clean-architecture-%E5%90%88%E8%A6%8F%E6%80%A7%E9%A9%97%E8%AD%89/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E5%B1%A4%E7%B4%9A%E6%9E%B6%E6%A7%8B%E5%93%81%E8%B3%AA%E6%AA%A2%E6%9F%A5%E6%A9%9F%E5%88%B6-clean-architecture-%E5%90%88%E8%A6%8F%E6%80%A7%E9%A9%97%E8%AD%89/</guid><description>&lt;p>在 Flutter 專案裡導入 Clean Architecture 並不難，難的是讓整個團隊在每一次 commit 都確實遵守它。我們曾有過這樣的經驗：架構設計文件寫得很完整，但三個月後打開 codebase，Widget 裡藏著業務規則、Controller 開始自己做驗證、UseCase 直接依賴了具體的資料庫實作。&lt;/p>
&lt;p>問題在於沒有機制讓「做錯事」變得困難。&lt;/p></description><content:encoded><![CDATA[<p>在 Flutter 專案裡導入 Clean Architecture 並不難，難的是讓整個團隊在每一次 commit 都確實遵守它。我們曾有過這樣的經驗：架構設計文件寫得很完整，但三個月後打開 codebase，Widget 裡藏著業務規則、Controller 開始自己做驗證、UseCase 直接依賴了具體的資料庫實作。</p>
<p>問題在於沒有機制讓「做錯事」變得困難。</p>
<h2 id="為什麼架構會悄悄腐化">為什麼架構會悄悄腐化</h2>
<p>Clean Architecture 的核心是依賴方向：外層可以依賴內層，但內層絕對不能依賴外層。這個原則說起來簡單，但「快速解決問題」的衝動很容易讓人走捷徑。一個業務驗證邏輯，放在 Widget 裡只要三行；把它搬到正確的 Domain 層，可能需要新增 Entity 方法、更新 UseCase、再補上測試。</p>
<p>在時間壓力下，捷徑獲勝了。</p>
<p>更麻煩的是，這種腐化是漸進的。第一次違規很小；第二次引用了第一次的前例；到了第六次，層級的邊界已經模糊得看不清楚了。</p>
<p>解法是：不依賴自律，改依賴機制。把架構規則轉化為可以自動執行的檢查。</p>
<h2 id="用檔案路徑判斷層級歸屬">用檔案路徑判斷層級歸屬</h2>
<p>我們採用的策略是<strong>用檔案路徑作為層級的明確宣告</strong>。一個檔案放在什麼目錄，就代表它屬於哪一層：</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">lib/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── ui/                    // 展示層（Layer 1）
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── application/           // 應用行為層（Layer 2）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── usecases/              // UseCase 層（Layer 3）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── events/            // Domain 事件層（Layer 4）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── interfaces/        // 介面定義層（Layer 4）
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   ├── entities/          // Domain 實作層（Layer 5）
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│   ├── value_objects/     // 值物件（Layer 5）
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   └── services/          // Domain 服務（Layer 5）
</span></span><span class="line"><span class="ln">11</span><span class="cl">└── infrastructure/        // 基礎設施層</span></span></code></pre></div><p>這讓我們可以用簡單的字串比對判斷：這個 PR 動了哪些層的檔案？一個 Ticket 聲稱只修改展示層，但 diff 裡出現了 <code>lib/domain/</code> 的檔案，那就是需要解釋的信號。</p>
<p>測試目錄也採用相同的對應結構：</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">test/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── ui/           // 對應展示層修改
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── application/  // 對應應用行為層修改
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── usecases/     // 對應 UseCase 層修改
</span></span><span class="line"><span class="ln">5</span><span class="cl">└── domain/       // 對應 Domain 層修改</span></span></code></pre></div><p>修改了某個層，對應的測試目錄裡就必須有覆蓋。「測試覆蓋率」從一個抽象數字，變成了具體的結構性要求。</p>
<h2 id="三種最常見的違規模式">三種最常見的違規模式</h2>
<p>追蹤了幾十個架構違規案例之後，幾乎都落在以下三種模式。</p>
<h3 id="展示層包含業務邏輯">展示層包含業務邏輯</h3>
<p>Widget 直接呼叫過濾、排序、計算這類業務操作：</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">// 違規：Widget 自己做了業務邏輯
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookListWidget</span> <span class="kd">extends</span> <span class="n">StatelessWidget</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">Widget</span> <span class="n">build</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">)</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">books</span> <span class="o">=</span> <span class="n">_filterNewBooks</span><span class="p">(</span><span class="n">_getAllBooks</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">return</span> <span class="n">ListView</span><span class="p">.</span><span class="n">builder</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 class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">// 正確：Widget 只負責把 controller 的狀態渲染出來
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookListWidget</span> <span class="kd">extends</span> <span class="n">StatelessWidget</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kd">final</span> <span class="n">BookListController</span> <span class="n">controller</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="n">Widget</span> <span class="n">build</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">)</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">ListView</span><span class="p">.</span><span class="n">builder</span><span class="p">(</span><span class="nl">items:</span> <span class="n">controller</span><span class="p">.</span><span class="n">filteredBooks</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>「什麼樣的書算新書」是業務邏輯，應該在 Domain 層定義。Widget 只做一件事：把資料渲染成畫面。</p>
<h3 id="controller-包含業務規則">Controller 包含業務規則</h3>





<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">// 違規：Controller 自己在做 ISBN 驗證
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookController</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">addBook</span><span class="p">(</span><span class="n">Book</span> <span class="n">book</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">book</span><span class="p">.</span><span class="n">isbn</span><span class="p">.</span><span class="n">length</span> <span class="o">!=</span> <span class="m">13</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="k">throw</span> <span class="n">ValidationException</span><span class="p">(</span><span class="s1">&#39;ISBN 必須為 13 碼&#39;</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 class="kd">await</span> <span class="n">bookRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">book</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><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">// 正確：Controller 只負責呼叫 UseCase
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookController</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="kd">final</span> <span class="n">AddBookUseCase</span> <span class="n">addBookUseCase</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">addBook</span><span class="p">(</span><span class="n">Book</span> <span class="n">book</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="kd">await</span> <span class="n">addBookUseCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">book</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>「ISBN 必須為 13 碼」是業務規則，應該活在 <code>Book</code> Entity 或 Value Object 裡。Controller 的角色是協調，不是決策。</p>
<h3 id="usecase-依賴具體實作">UseCase 依賴具體實作</h3>





<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">// 違規：依賴具體的 SQLite 實作
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">SearchBookUseCase</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">SqliteBookRepository</span> <span class="n">repository</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 正確：依賴抽象介面
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">SearchBookUseCase</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="kd">final</span> <span class="n">IBookRepository</span> <span class="n">repository</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>依賴介面讓 UseCase 在測試時注入 Mock，生產環境注入真實實作，兩者互換自如。</p>
<h2 id="把檢查機制自動化">把檢查機制自動化</h2>
<p>辨識出違規模式之後，我們做的第一件事是把檢查寫進工具裡。</p>
<h3 id="pre-commit-hook">Pre-commit Hook</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cp"></span>./scripts/check_single_layer_modification.sh <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">flutter <span class="nb">test</span> --coverage <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div><p><code>check_single_layer_modification.sh</code> 分析 commit 的 diff，確認被修改的檔案是否都屬於同一個架構層。一個本來只應動展示層的 commit，如果同時修改了 Domain 層的檔案，腳本就會退出並阻止 commit。</p>
<h3 id="cicd-整合">CI/CD 整合</h3>
<p>Pre-commit Hook 可以被繞過，但 CI/CD 不會：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">PR Architecture Check</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">architecture_check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">檢查單層修改原則</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./scripts/check_single_layer_in_pr.sh</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">執行測試並確認覆蓋率</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">flutter test --coverage</span></span></span></code></pre></div><p>架構合規性成為 PR 合併的硬性前置條件。</p>
<h2 id="每次-commit-前的自我檢查">每次 commit 前的自我檢查</h2>
<p>自動化工具處理可以被程式判斷的規則，剩下的需要開發者自己過一遍：</p>
<ul>
<li>這次修改的檔案，是否都屬於同一個架構層？</li>
<li>import 方向是否正確——只有外層依賴內層？</li>
<li>測試檔案路徑和被測試程式碼是否在對應的層級目錄？</li>
<li>有沒有 Widget 直接做業務計算、Controller 直接做驗證？</li>
</ul>
<p>三十秒可以過完，但幾乎每次都能在 commit 前抓住一兩個值得重新考慮的決定。</p>
<h2 id="機制比自律更可靠">機制比自律更可靠</h2>
<p>導入這套機制之後，code review 上花的精力少了很多——大多數架構層面的問題在進入 review 之前就已經被攔截。reviewer 可以把注意力放在邏輯正確性和設計決策上，不用反覆提醒「這段邏輯不應該放在 Widget 裡」。</p>
<p>對新加入的開發者也很友善：不需要先把架構文件背熟才能開始開發，工具會在走錯方向時給出明確的反饋。</p>
<p>架構的生命力在於它能不能在日常開發壓力下被維護下去。</p>]]></content:encoded></item></channel></rss>