<?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>Design-Principles on Tarragon</title><link>https://tarrragon.github.io/blog/tags/design-principles/</link><description>Recent content in Design-Principles on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 03 Nov 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/design-principles/index.xml" rel="self" type="application/rss+xml"/><item><title>Single Responsibility Principle（單一職責原則）</title><link>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/</guid><description>&lt;p>單一職責原則（Single Responsibility Principle，SRP）的定義是：一個模組應該只有一個改變的理由。Robert C. Martin 把「理由」對到提出變更的人——一個模組的變更應該只來自一群緊密合作、代表單一業務職能的人，這樣一群人算一個理由。違反這條原則的模組，在修改紀錄上呈現成 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）&lt;/a>：同一個模組因為不同的理由、以不同的方式反覆被修改；把同一個理由的程式碼拆散到好幾個模組，則是與 Divergent Change 成對的 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>SRP 是 SOLID 五原則的第一條。Martin 在《Agile Software Development: Principles, Patterns, and Practices》（2002）把它寫成「一個類別應該只有一個改變的理由」；2014 年他在〈The Single Responsibility Principle〉一文說明「理由」指的是人，並把原則改寫成兩句方向相反的指示：&lt;/p>
&lt;blockquote>
&lt;p>Gather together the things that change for the same reasons. Separate those things that change for different reasons.&lt;/p>&lt;/blockquote>
&lt;p>前一句要求把因同一個理由改變的程式碼放在一起，後一句要求把因不同理由改變的程式碼分開。切分的人只執行後一句，會把同一個理由的程式碼拆散到很多檔案，於是那群人每次提出變更，修改的人都要同時改好幾處，這就是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery&lt;/a>；只執行前一句的極端是把所有程式碼都放在一起，於是不同理由的變更擠在同一個模組，這就是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change&lt;/a>。SRP 要求兩句同時滿足：因同一個理由改變的程式碼在同一個模組，因不同理由改變的程式碼在不同模組。在《Clean Architecture》（2017）裡，Martin 把定義收斂成「A module should be responsible to one, and only one, actor」，actor 指的是要求同一類變更的一群人。&lt;/p>
&lt;p>&lt;strong>理由的計數單位是改動的種類，提出的人用來辨認種類。&lt;/strong> 一個 actor 要求的是同一類改動，所以理由可以從兩邊辨認：從提出變更的人，或從他們要求的改動種類。兩邊對得上時（財務部門要求的都是薪資規則的改動）看哪一邊結果都一樣；對不上時以改動種類為準——同一個人轉達好幾個職能的需求時，他提出的改動屬於不同理由；不同的人提出同一類改動時，仍是同一個理由。&lt;/p>
&lt;p>Martin 在 2014 年那篇文章裡說明這條原則的源頭是 David Parnas 1972 年的論文〈On the Criteria To Be Used in Decomposing Systems into Modules〉與 Edsger Dijkstra 提出的 separation of concerns：模組的切法要依「什麼會一起變」決定，不依程式執行的步驟決定。他在《Agile Software Development》的單一職責那一章也寫明，這條原則就是 Tom DeMarco 與 Meilir Page-Jones 所說的 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">Cohesion（內聚）&lt;/a>，只是把判斷依據從功能上的相關換成讓模組改變的力量。&lt;/p>
&lt;p>&lt;strong>「只做一件事」是另一條原則。&lt;/strong> SRP 最常見的誤讀是「一個類別只做一件事」。Martin 在《Clean Architecture》第七章說確實有一條原則是這樣講的，那條原則的對象是函式：一個函式只做一件事，用在把大函式拆小的重構裡。那一條管的是函式內部的步驟，判斷依據是程式碼在做什麼；SRP 管的是模組的邊界，判斷依據是誰會要求它改變。兩條原則的單位與依據都不同，所以一個只做一件事的類別仍然可能有兩個改變的理由，一個做了好幾件事的類別也可能只有一個。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>Martin 的例子是一個 &lt;code>Employee&lt;/code> 類別，裡面有三個方法：&lt;code>calculatePay&lt;/code> 依財務部門的規則計算薪資，&lt;code>reportHours&lt;/code> 依營運部門的需要產生工時報表，&lt;code>save&lt;/code> 依技術團隊選定的方式寫進資料庫。三個方法服務三群人——財務長、營運長、技術長各自管轄的部門——所以這個類別有三個改變的理由。三個方法放在同一個類別，損害出現在它們共用程式碼的時候：Martin 在《Clean Architecture》裡舉的是 &lt;code>calculatePay&lt;/code> 與 &lt;code>reportHours&lt;/code> 共用一個計算正常工時的 &lt;code>regularHours&lt;/code> 方法：財務部門要求調整加班時數的算法，負責這次修改的工程師改了那段共用計算，營運部門的工時報表跟著變，而營運部門沒有提出任何變更。&lt;/p>
&lt;p>判斷一個模組有幾個理由，要看它的變更要求的是哪幾種改動、各由哪一個職能決定，不看它有幾個方法：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>修改紀錄裡有好幾個不相干的需求改同一個檔案&lt;/strong>（不相干指要求的改動屬於不同種類）：這是改變理由不只一個的間接量測。不同理由的修改由不同的人同時進行時，改動落在同一處就會合併衝突，沒有文字衝突時也要重跑測試確認沒有改壞對方；Martin 把合併列為違反單一職責的另一個症狀。用 git 歷史統計每個檔案被幾個不同的需求修改，再逐個 commit 歸類，做法在&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界&lt;/a>，造成等待的幾種情形在那一篇的〈修改競爭與單一職責原則〉。&lt;/li>
&lt;li>&lt;strong>一個需求的修改改壞了另一個需求在乎的行為&lt;/strong>：上面 &lt;code>Employee&lt;/code> 的情形。兩個需求互不相干，卻因為住在同一個模組而互相影響。&lt;/li>
&lt;li>&lt;strong>bug 修正與重構不另算一個理由&lt;/strong>。Martin 的說法是 bug 修正與重構是程式設計師的責任，不是程式的；歸類時，一個 bug 修正併進它所修的那段程式碼原本屬於的那一類。&lt;/li>
&lt;li>&lt;strong>所有需求都由同一個人提出時&lt;/strong>（例如所有票都由同一位 PM 開），看提出的人分不出理由，要看每個需求要求的是哪一種改動。以一個擋下跨工作目錄 commit 的守衛腳本為例：解析 git 命令語法的改動跟著 git 的寫法變，判斷其他代理人是否正在工作的改動跟著團隊的並行流程變，擋下時印給使用者的訊息措辭跟著使用者回饋變，這三種改動各是一個理由。&lt;/li>
&lt;/ul>
&lt;p>依改動種類計數理由的判斷也適用於文件：一份文件被好幾個不相干的主題修改，就有好幾個改變它的理由，做法在&lt;a href="https://tarrragon.github.io/blog/record/document-split-by-responsibility/" data-link-title="依職責拆分文字文件：修改紀錄的訊號、切法與拆完之後的引用" data-link-desc="規則文件、操作手冊與範本的拆分：從版本歷史讀出一份文件承擔幾種職責，依職責而非長度決定切法，原檔保留導覽表、同一條規則只定義一次，以及拆完之後更新引用與檢查新檔之間的指引缺口">依職責拆分文字文件&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>套用 SRP 的設計責任是讓每一群提出變更的人，各自只需要改一個地方，而且改的時候不會動到別人在乎的行為。執行的判斷問句是：列出一段時間內改過這個模組的需求（範圍要涵蓋幾輪需求，選法見&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界&lt;/a>〈用 git 歷史量出熱點〉的「統計的時間範圍」一條），逐一寫出要求的是哪一種改動、由哪一個職能決定；改動種類有兩種以上，就有兩個以上的理由，可以考慮依理由切開。&lt;/p>
&lt;p>依技術層次切（驗證器、資料存取、寄信服務各一個類別）常常與依理由切重合，因為驗證規則、資料庫結構與信件內容通常由不同的人決定；兩種切法分岔時，以每個類別會被哪一個職能要求改變為準。切開之後反過來檢查：同一個理由的變更是否需要同時改好幾個新模組，是的話就切過頭了，要把那幾個新模組併回來。&lt;/p>
&lt;p>SRP 與 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/open-closed-principle/" data-link-title="Open-Closed Principle（開放封閉原則）" data-link-desc="要在新增一種格式、一種類型或一種規則時不必改動既有程式碼，或要判斷一個擴充點值不值得先做時：模組對選定的那一類變更封閉，靠的是使用端只依賴一個固定的抽象">開放封閉原則&lt;/a>常一起使用：SRP 決定模組的邊界畫在哪裡，開放封閉原則決定模組裡哪些部分對外公開、讓其他模組不必修改它就能擴充，哪些留在內部。開放封閉原則在 Python 的寫法與例子在&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/00-philosophy/open-closed-principle/" data-link-title="開放封閉原則與認知負擔" data-link-desc="從認知負擔的視角重新理解 SOLID 原則">開放封閉原則&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>單一職責原則（Single Responsibility Principle，SRP）的定義是：一個模組應該只有一個改變的理由。Robert C. Martin 把「理由」對到提出變更的人——一個模組的變更應該只來自一群緊密合作、代表單一業務職能的人，這樣一群人算一個理由。違反這條原則的模組，在修改紀錄上呈現成 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）</a>：同一個模組因為不同的理由、以不同的方式反覆被修改；把同一個理由的程式碼拆散到好幾個模組，則是與 Divergent Change 成對的 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>SRP 是 SOLID 五原則的第一條。Martin 在《Agile Software Development: Principles, Patterns, and Practices》（2002）把它寫成「一個類別應該只有一個改變的理由」；2014 年他在〈The Single Responsibility Principle〉一文說明「理由」指的是人，並把原則改寫成兩句方向相反的指示：</p>
<blockquote>
<p>Gather together the things that change for the same reasons. Separate those things that change for different reasons.</p></blockquote>
<p>前一句要求把因同一個理由改變的程式碼放在一起，後一句要求把因不同理由改變的程式碼分開。切分的人只執行後一句，會把同一個理由的程式碼拆散到很多檔案，於是那群人每次提出變更，修改的人都要同時改好幾處，這就是 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery</a>；只執行前一句的極端是把所有程式碼都放在一起，於是不同理由的變更擠在同一個模組，這就是 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change</a>。SRP 要求兩句同時滿足：因同一個理由改變的程式碼在同一個模組，因不同理由改變的程式碼在不同模組。在《Clean Architecture》（2017）裡，Martin 把定義收斂成「A module should be responsible to one, and only one, actor」，actor 指的是要求同一類變更的一群人。</p>
<p><strong>理由的計數單位是改動的種類，提出的人用來辨認種類。</strong> 一個 actor 要求的是同一類改動，所以理由可以從兩邊辨認：從提出變更的人，或從他們要求的改動種類。兩邊對得上時（財務部門要求的都是薪資規則的改動）看哪一邊結果都一樣；對不上時以改動種類為準——同一個人轉達好幾個職能的需求時，他提出的改動屬於不同理由；不同的人提出同一類改動時，仍是同一個理由。</p>
<p>Martin 在 2014 年那篇文章裡說明這條原則的源頭是 David Parnas 1972 年的論文〈On the Criteria To Be Used in Decomposing Systems into Modules〉與 Edsger Dijkstra 提出的 separation of concerns：模組的切法要依「什麼會一起變」決定，不依程式執行的步驟決定。他在《Agile Software Development》的單一職責那一章也寫明，這條原則就是 Tom DeMarco 與 Meilir Page-Jones 所說的 <a href="/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">Cohesion（內聚）</a>，只是把判斷依據從功能上的相關換成讓模組改變的力量。</p>
<p><strong>「只做一件事」是另一條原則。</strong> SRP 最常見的誤讀是「一個類別只做一件事」。Martin 在《Clean Architecture》第七章說確實有一條原則是這樣講的，那條原則的對象是函式：一個函式只做一件事，用在把大函式拆小的重構裡。那一條管的是函式內部的步驟，判斷依據是程式碼在做什麼；SRP 管的是模組的邊界，判斷依據是誰會要求它改變。兩條原則的單位與依據都不同，所以一個只做一件事的類別仍然可能有兩個改變的理由，一個做了好幾件事的類別也可能只有一個。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>Martin 的例子是一個 <code>Employee</code> 類別，裡面有三個方法：<code>calculatePay</code> 依財務部門的規則計算薪資，<code>reportHours</code> 依營運部門的需要產生工時報表，<code>save</code> 依技術團隊選定的方式寫進資料庫。三個方法服務三群人——財務長、營運長、技術長各自管轄的部門——所以這個類別有三個改變的理由。三個方法放在同一個類別，損害出現在它們共用程式碼的時候：Martin 在《Clean Architecture》裡舉的是 <code>calculatePay</code> 與 <code>reportHours</code> 共用一個計算正常工時的 <code>regularHours</code> 方法：財務部門要求調整加班時數的算法，負責這次修改的工程師改了那段共用計算，營運部門的工時報表跟著變，而營運部門沒有提出任何變更。</p>
<p>判斷一個模組有幾個理由，要看它的變更要求的是哪幾種改動、各由哪一個職能決定，不看它有幾個方法：</p>
<ul>
<li><strong>修改紀錄裡有好幾個不相干的需求改同一個檔案</strong>（不相干指要求的改動屬於不同種類）：這是改變理由不只一個的間接量測。不同理由的修改由不同的人同時進行時，改動落在同一處就會合併衝突，沒有文字衝突時也要重跑測試確認沒有改壞對方；Martin 把合併列為違反單一職責的另一個症狀。用 git 歷史統計每個檔案被幾個不同的需求修改，再逐個 commit 歸類，做法在<a href="/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界</a>，造成等待的幾種情形在那一篇的〈修改競爭與單一職責原則〉。</li>
<li><strong>一個需求的修改改壞了另一個需求在乎的行為</strong>：上面 <code>Employee</code> 的情形。兩個需求互不相干，卻因為住在同一個模組而互相影響。</li>
<li><strong>bug 修正與重構不另算一個理由</strong>。Martin 的說法是 bug 修正與重構是程式設計師的責任，不是程式的；歸類時，一個 bug 修正併進它所修的那段程式碼原本屬於的那一類。</li>
<li><strong>所有需求都由同一個人提出時</strong>（例如所有票都由同一位 PM 開），看提出的人分不出理由，要看每個需求要求的是哪一種改動。以一個擋下跨工作目錄 commit 的守衛腳本為例：解析 git 命令語法的改動跟著 git 的寫法變，判斷其他代理人是否正在工作的改動跟著團隊的並行流程變，擋下時印給使用者的訊息措辭跟著使用者回饋變，這三種改動各是一個理由。</li>
</ul>
<p>依改動種類計數理由的判斷也適用於文件：一份文件被好幾個不相干的主題修改，就有好幾個改變它的理由，做法在<a href="/blog/record/document-split-by-responsibility/" data-link-title="依職責拆分文字文件：修改紀錄的訊號、切法與拆完之後的引用" data-link-desc="規則文件、操作手冊與範本的拆分：從版本歷史讀出一份文件承擔幾種職責，依職責而非長度決定切法，原檔保留導覽表、同一條規則只定義一次，以及拆完之後更新引用與檢查新檔之間的指引缺口">依職責拆分文字文件</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>套用 SRP 的設計責任是讓每一群提出變更的人，各自只需要改一個地方，而且改的時候不會動到別人在乎的行為。執行的判斷問句是：列出一段時間內改過這個模組的需求（範圍要涵蓋幾輪需求，選法見<a href="/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界</a>〈用 git 歷史量出熱點〉的「統計的時間範圍」一條），逐一寫出要求的是哪一種改動、由哪一個職能決定；改動種類有兩種以上，就有兩個以上的理由，可以考慮依理由切開。</p>
<p>依技術層次切（驗證器、資料存取、寄信服務各一個類別）常常與依理由切重合，因為驗證規則、資料庫結構與信件內容通常由不同的人決定；兩種切法分岔時，以每個類別會被哪一個職能要求改變為準。切開之後反過來檢查：同一個理由的變更是否需要同時改好幾個新模組，是的話就切過頭了，要把那幾個新模組併回來。</p>
<p>SRP 與 <a href="/blog/software-design/design-principles/knowledge-cards/open-closed-principle/" data-link-title="Open-Closed Principle（開放封閉原則）" data-link-desc="要在新增一種格式、一種類型或一種規則時不必改動既有程式碼，或要判斷一個擴充點值不值得先做時：模組對選定的那一類變更封閉，靠的是使用端只依賴一個固定的抽象">開放封閉原則</a>常一起使用：SRP 決定模組的邊界畫在哪裡，開放封閉原則決定模組裡哪些部分對外公開、讓其他模組不必修改它就能擴充，哪些留在內部。開放封閉原則在 Python 的寫法與例子在<a href="/blog/programming/python/basic/00-philosophy/open-closed-principle/" data-link-title="開放封閉原則與認知負擔" data-link-desc="從認知負擔的視角重新理解 SOLID 原則">開放封閉原則</a>。</p>
]]></content:encoded></item><item><title>從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證</title><link>https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/</guid><description>&lt;p>本篇是軟體設計原則模組的章節，範圍從用腳本讀 git 歷史、統計每個檔案被幾個不同的需求修改開始，到熱點的分類、從修改紀錄決定切法、拆分時與語言無關的做法、拆分合併的時機，以及拆完之後怎麼驗證切法；量測與歸類只讀 git 歷史，與專案用什麼語言寫無關，單一職責等原則的定義在本模組的&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/" data-link-title="Knowledge Cards" data-link-desc="軟體設計原則的術語卡片：模組邊界怎麼畫、哪些程式碼該放在一起">知識卡片&lt;/a>。範例是一支以 Python 寫成的 commit 守衛 &lt;code>guard.py&lt;/code>：多個 AI 代理人各自在自己的工作目錄同時開發時（下文稱並行期間），代理人工具在執行每一條 shell 指令之前把整條指令字串交給它判斷，它擋下把 commit 寫進其他代理人工作目錄的寫法。命令解析、狀態判斷與訊息產生三種職責寫在同一個檔案裡。&lt;/p>
&lt;p>保留原本的匯入路徑、改寫分派、避免循環匯入這幾件事的寫法依語言而定，Python 的做法在&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/07-refactoring/python-module-split/" data-link-title="拆分 Python 模組：pytest 的特徵測試、同名套件與重新匯出、自動登記的分派表、避免循環匯入" data-link-desc="職責邊界已經從修改紀錄歸類出來之後，用 Python 拆分一個模組而不改變對外行為：用 pytest 寫特徵測試與 monkeypatch 的替換目標、改成同名套件並重新匯出、把巨型分支改成自動登記的分派表，以及拆出模組之間循環匯入在 Python 的症狀與解法">拆分 Python 模組&lt;/a>；Go 搬移時用 type alias 保留舊的匯入路徑、一次只搬一個邊界，在 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">7.5 以 domain 重新整理 package&lt;/a>，多檔案、多個 package 與 import cycle 的基礎在 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">1.5 從單檔到多檔案&lt;/a>；規則文件、操作手冊這類文字文件的拆分寫在&lt;a href="https://tarrragon.github.io/blog/record/document-split-by-responsibility/" data-link-title="依職責拆分文字文件：修改紀錄的訊號、切法與拆完之後的引用" data-link-desc="規則文件、操作手冊與範本的拆分：從版本歷史讀出一份文件承擔幾種職責，依職責而非長度決定切法，原檔保留導覽表、同一條規則只定義一次，以及拆完之後更新引用與檢查新檔之間的指引缺口">依職責拆分文字文件&lt;/a>。&lt;/p>
&lt;h2 id="修改競爭與單一職責原則">修改競爭與單一職責原則&lt;/h2>
&lt;p>修改競爭指的是處理不相干需求的人或代理人要改同一個檔案。兩個需求&lt;strong>不相干&lt;/strong>，指它們要求的改動屬於不同種類（例如一個改命令的解析方式、一個改提示訊息的措辭）；讀修改紀錄的人要逐個 commit 歸類之後才判得出來，下文〈從修改紀錄找出職責邊界〉說明做法。git 只在兩邊改動的行重疊或相鄰時產生合併衝突，所以同一個檔案的修改不一定互相阻擋；會讓它們互相等待的是三種情形：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>改動落在同一處&lt;/strong>：例如兩個需求都在同一份清單或檔尾追加一筆。處理第一個需求的人先改、先合併，處理第二個需求的人拉最新版、解衝突、重跑測試。&lt;/li>
&lt;li>&lt;strong>合併沒有文字衝突，而行為互相影響&lt;/strong>：兩個需求各改一個函式，git 合併成功，而後合併的那一方 rebase 之後仍要重跑測試，確認兩邊沒有改壞對方。&lt;/li>
&lt;li>&lt;strong>並行的代理人共用同一個工作目錄&lt;/strong>：兩方的改動還沒提交就混在同一份檔案裡，而 commit 記錄的是整份檔案的內容，任一方提交都會帶走另一方的改動，所以一方要等另一方提交或清空改動才能動手。&lt;/li>
&lt;/ul>
&lt;p>團隊排好修改的順序、加強溝通，能縮短每次等待的時間，而一個檔案承載的職責越多，落在它身上的不相干修改越多，等待的次數跟著增加。只有一個人依序開發時沒有等待成本，不相干的修改落在同一個檔案仍然有代價：一個需求的改動有機會改壞另一個需求在乎的行為。讓一個需求的改動碰不到另一個需求在乎的行為，這種隔離正是單一職責原則要保護的對象。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則（SRP）&lt;/a>把職責定義成改變的理由：一個模組應該只有一個改變的理由，而理由的計數單位是改動的種類。提出變更的那群人（需求的提出者，例如某個業務部門）通常各自要求同一類改動，所以可以拿來辨認種類；同一群人要求了好幾類改動、或不同的人要求同一類改動時，照改動的種類算（定義、Martin 的原文與「只做一件事」為什麼是另一條原則，在那張卡片）。這個定義把抽象的「職責」換成了可以觀察的對象——&lt;strong>改變的理由&lt;/strong>。版本歷史記下了每一次改變，也記下了它是為了哪一個需求（票、issue、PR）而做，需求數因此可以當成改變理由的間接量測（proxy measure）。&lt;/p>
&lt;p>本篇說的模組是有對外介面的程式單位（&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/coupling/" data-link-title="Coupling（耦合）" data-link-desc="改一個模組總是連帶要改另一個、或要判斷兩個模組之間的依賴可不可以接受時：耦合是模組之間互相依賴的程度，依兩個模組共享什麼分成由強到弱的幾種">Coupling（耦合）&lt;/a>卡片有同樣的界定），量測的單位則是檔案。Python 一個檔案就是一個模組，Dart 不用 &lt;code>part&lt;/code> 時一個檔案就是一個 library，兩者等價；Go 的一個 package 由好幾個檔案組成，熱點檔依職責拆成同一個 package 裡的幾個檔案，不同需求改的就是不同檔案，git 層面的修改競爭就解掉了；拆出的這幾類是否需要各自的對外介面、是否再拆成不同的 package，是另一個判斷。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>現象&lt;/th>
 &lt;th>代表什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>同一個需求的多次 commit 都改同一個檔&lt;/td>
 &lt;td>正常。同一主題的演進集中在一處，這是&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">內聚（cohesion）&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>三、四個&lt;strong>不同的需求&lt;/strong>都要改同一個檔&lt;/td>
 &lt;td>這個檔可能有好幾種改變的理由；實際幾種，由逐個 commit 歸類決定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>腳本數的是不同的需求，不判斷這些需求相不相干，所以需求數只是篩選訊號：讀排行的人用它決定要回頭讀哪幾個檔案的修改紀錄，一個檔案實際有幾種改變的理由，要等他逐個 commit 歸類之後才判定。讀排行的人只從前段挑檔案，排在後段的檔案不會再被逐個 commit 檢查，所以會讓需求數少算的情形（見〈用 git 歷史量出熱點〉的「一張票涵蓋好幾類改動」）要另外留意。本篇範例 commit 守衛的需求都服務同一件事（擋下跨工作目錄的 commit），而它們要求的改動分成三種：命令語法跟著 git 的寫法變、並行政策跟著團隊的工作流程變、提示措辭跟著使用者的回饋變，三種改動的起因不同、發生的時間也不同，即使所有票都由同一位 PM 開，只看開票者是誰分不出來，看每張票要求的改動種類才分得出來。這正是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）&lt;/a>：一個模組因為不同的理由、以不同的方式被修改。&lt;/p>
&lt;h3 id="行數與不同需求數的量測對象">行數與不同需求數的量測對象&lt;/h3>
&lt;p>團隊判斷一個檔案要不要拆時，常用的門檻是行數，例如超過 300 行就考慮拆。行數是檔案過大的症狀，而它在兩種檔案上給出的結果與不同需求數相反：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情況&lt;/th>
 &lt;th>行數判斷&lt;/th>
 &lt;th>不同需求數判斷&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>200 行的骨架產生器，被 6 個不同需求改過&lt;/td>
 &lt;td>不到門檻&lt;/td>
 &lt;td>排在前段，回頭讀修改紀錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>900 行的解析器，只屬於同一個需求的連續演進&lt;/td>
 &lt;td>超過門檻（誤判）&lt;/td>
 &lt;td>只有一個需求，不會排在前段&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>行數量的是檔案的大小，不同需求數量的是一個檔案可能混了幾種改變的理由，SRP 在意的是改變的理由有幾種。開發者照行數把 900 行的解析器切成三份、不看修改落在哪裡時，同一主題之後的修改常常要同時改好幾個檔；一個需求改多個檔案是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）&lt;/a>。與它成對的發散式變更，正是本篇用修改紀錄量的那種壞味道（從程式碼結構偵測的規則在 &lt;a href="https://tarrragon.github.io/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單&lt;/a>的〈B1. Divergent Change〉一節），發散式變更在並行開發時造成的等待，就是修改競爭的成本。&lt;/p>
&lt;h2 id="用-git-歷史量出熱點">用 git 歷史量出熱點&lt;/h2>
&lt;p>量測的前提是需求與檔案的對應查得到。本篇從 commit 標題裡的需求編號取（commit 訊息帶票號的慣例見 &lt;a href="https://tarrragon.github.io/blog/record/commit-message-vs-source-doc/" data-link-title="Commit message vs source code doc：兩份不同職責的文件" data-link-desc="Source code doc 寫給未來讀者、commit message 寫給回顧歷史的考古者。時序敏感的資訊（為什麼這次改、考慮過什麼方案）放 commit、持續適用的契約放 source。配合 git blame 工作流讓考古路徑清楚、source 不必背所有歷史。">Commit message vs source code doc&lt;/a> 的〈Bug 號、ticket 連結、incident 紀錄〉一節；那裡的例子把票號寫在內文，下面的腳本只讀標題，處理方式見〈只讀 commit 標題〉那一條）。想避開只讀標題這個限制的團隊，也可以從 issue tracker 取：匯出每個 PR 連到的 issue 與 PR 改的檔案，整理成「檔案 → 需求編號集合」的對應，就是腳本裡 &lt;code>collect_changes&lt;/code> 回傳的那個形狀，之後的排行與判讀照用，而且沒有只讀標題帶來的限制。&lt;/p></description><content:encoded><![CDATA[<p>本篇是軟體設計原則模組的章節，範圍從用腳本讀 git 歷史、統計每個檔案被幾個不同的需求修改開始，到熱點的分類、從修改紀錄決定切法、拆分時與語言無關的做法、拆分合併的時機，以及拆完之後怎麼驗證切法；量測與歸類只讀 git 歷史，與專案用什麼語言寫無關，單一職責等原則的定義在本模組的<a href="/blog/software-design/design-principles/knowledge-cards/" data-link-title="Knowledge Cards" data-link-desc="軟體設計原則的術語卡片：模組邊界怎麼畫、哪些程式碼該放在一起">知識卡片</a>。範例是一支以 Python 寫成的 commit 守衛 <code>guard.py</code>：多個 AI 代理人各自在自己的工作目錄同時開發時（下文稱並行期間），代理人工具在執行每一條 shell 指令之前把整條指令字串交給它判斷，它擋下把 commit 寫進其他代理人工作目錄的寫法。命令解析、狀態判斷與訊息產生三種職責寫在同一個檔案裡。</p>
<p>保留原本的匯入路徑、改寫分派、避免循環匯入這幾件事的寫法依語言而定，Python 的做法在<a href="/blog/programming/python/basic/07-refactoring/python-module-split/" data-link-title="拆分 Python 模組：pytest 的特徵測試、同名套件與重新匯出、自動登記的分派表、避免循環匯入" data-link-desc="職責邊界已經從修改紀錄歸類出來之後，用 Python 拆分一個模組而不改變對外行為：用 pytest 寫特徵測試與 monkeypatch 的替換目標、改成同名套件並重新匯出、把巨型分支改成自動登記的分派表，以及拆出模組之間循環匯入在 Python 的症狀與解法">拆分 Python 模組</a>；Go 搬移時用 type alias 保留舊的匯入路徑、一次只搬一個邊界，在 <a href="/blog/programming/go/basic/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">7.5 以 domain 重新整理 package</a>，多檔案、多個 package 與 import cycle 的基礎在 <a href="/blog/programming/go/basic/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">1.5 從單檔到多檔案</a>；規則文件、操作手冊這類文字文件的拆分寫在<a href="/blog/record/document-split-by-responsibility/" data-link-title="依職責拆分文字文件：修改紀錄的訊號、切法與拆完之後的引用" data-link-desc="規則文件、操作手冊與範本的拆分：從版本歷史讀出一份文件承擔幾種職責，依職責而非長度決定切法，原檔保留導覽表、同一條規則只定義一次，以及拆完之後更新引用與檢查新檔之間的指引缺口">依職責拆分文字文件</a>。</p>
<h2 id="修改競爭與單一職責原則">修改競爭與單一職責原則</h2>
<p>修改競爭指的是處理不相干需求的人或代理人要改同一個檔案。兩個需求<strong>不相干</strong>，指它們要求的改動屬於不同種類（例如一個改命令的解析方式、一個改提示訊息的措辭）；讀修改紀錄的人要逐個 commit 歸類之後才判得出來，下文〈從修改紀錄找出職責邊界〉說明做法。git 只在兩邊改動的行重疊或相鄰時產生合併衝突，所以同一個檔案的修改不一定互相阻擋；會讓它們互相等待的是三種情形：</p>
<ul>
<li><strong>改動落在同一處</strong>：例如兩個需求都在同一份清單或檔尾追加一筆。處理第一個需求的人先改、先合併，處理第二個需求的人拉最新版、解衝突、重跑測試。</li>
<li><strong>合併沒有文字衝突，而行為互相影響</strong>：兩個需求各改一個函式，git 合併成功，而後合併的那一方 rebase 之後仍要重跑測試，確認兩邊沒有改壞對方。</li>
<li><strong>並行的代理人共用同一個工作目錄</strong>：兩方的改動還沒提交就混在同一份檔案裡，而 commit 記錄的是整份檔案的內容，任一方提交都會帶走另一方的改動，所以一方要等另一方提交或清空改動才能動手。</li>
</ul>
<p>團隊排好修改的順序、加強溝通，能縮短每次等待的時間，而一個檔案承載的職責越多，落在它身上的不相干修改越多，等待的次數跟著增加。只有一個人依序開發時沒有等待成本，不相干的修改落在同一個檔案仍然有代價：一個需求的改動有機會改壞另一個需求在乎的行為。讓一個需求的改動碰不到另一個需求在乎的行為，這種隔離正是單一職責原則要保護的對象。</p>
<p><a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則（SRP）</a>把職責定義成改變的理由：一個模組應該只有一個改變的理由，而理由的計數單位是改動的種類。提出變更的那群人（需求的提出者，例如某個業務部門）通常各自要求同一類改動，所以可以拿來辨認種類；同一群人要求了好幾類改動、或不同的人要求同一類改動時，照改動的種類算（定義、Martin 的原文與「只做一件事」為什麼是另一條原則，在那張卡片）。這個定義把抽象的「職責」換成了可以觀察的對象——<strong>改變的理由</strong>。版本歷史記下了每一次改變，也記下了它是為了哪一個需求（票、issue、PR）而做，需求數因此可以當成改變理由的間接量測（proxy measure）。</p>
<p>本篇說的模組是有對外介面的程式單位（<a href="/blog/software-design/design-principles/knowledge-cards/coupling/" data-link-title="Coupling（耦合）" data-link-desc="改一個模組總是連帶要改另一個、或要判斷兩個模組之間的依賴可不可以接受時：耦合是模組之間互相依賴的程度，依兩個模組共享什麼分成由強到弱的幾種">Coupling（耦合）</a>卡片有同樣的界定），量測的單位則是檔案。Python 一個檔案就是一個模組，Dart 不用 <code>part</code> 時一個檔案就是一個 library，兩者等價；Go 的一個 package 由好幾個檔案組成，熱點檔依職責拆成同一個 package 裡的幾個檔案，不同需求改的就是不同檔案，git 層面的修改競爭就解掉了；拆出的這幾類是否需要各自的對外介面、是否再拆成不同的 package，是另一個判斷。</p>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>代表什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同一個需求的多次 commit 都改同一個檔</td>
          <td>正常。同一主題的演進集中在一處，這是<a href="/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">內聚（cohesion）</a></td>
      </tr>
      <tr>
          <td>三、四個<strong>不同的需求</strong>都要改同一個檔</td>
          <td>這個檔可能有好幾種改變的理由；實際幾種，由逐個 commit 歸類決定</td>
      </tr>
  </tbody>
</table>
<p>腳本數的是不同的需求，不判斷這些需求相不相干，所以需求數只是篩選訊號：讀排行的人用它決定要回頭讀哪幾個檔案的修改紀錄，一個檔案實際有幾種改變的理由，要等他逐個 commit 歸類之後才判定。讀排行的人只從前段挑檔案，排在後段的檔案不會再被逐個 commit 檢查，所以會讓需求數少算的情形（見〈用 git 歷史量出熱點〉的「一張票涵蓋好幾類改動」）要另外留意。本篇範例 commit 守衛的需求都服務同一件事（擋下跨工作目錄的 commit），而它們要求的改動分成三種：命令語法跟著 git 的寫法變、並行政策跟著團隊的工作流程變、提示措辭跟著使用者的回饋變，三種改動的起因不同、發生的時間也不同，即使所有票都由同一位 PM 開，只看開票者是誰分不出來，看每張票要求的改動種類才分得出來。這正是 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）</a>：一個模組因為不同的理由、以不同的方式被修改。</p>
<h3 id="行數與不同需求數的量測對象">行數與不同需求數的量測對象</h3>
<p>團隊判斷一個檔案要不要拆時，常用的門檻是行數，例如超過 300 行就考慮拆。行數是檔案過大的症狀，而它在兩種檔案上給出的結果與不同需求數相反：</p>
<table>
  <thead>
      <tr>
          <th>情況</th>
          <th>行數判斷</th>
          <th>不同需求數判斷</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>200 行的骨架產生器，被 6 個不同需求改過</td>
          <td>不到門檻</td>
          <td>排在前段，回頭讀修改紀錄</td>
      </tr>
      <tr>
          <td>900 行的解析器，只屬於同一個需求的連續演進</td>
          <td>超過門檻（誤判）</td>
          <td>只有一個需求，不會排在前段</td>
      </tr>
  </tbody>
</table>
<p>行數量的是檔案的大小，不同需求數量的是一個檔案可能混了幾種改變的理由，SRP 在意的是改變的理由有幾種。開發者照行數把 900 行的解析器切成三份、不看修改落在哪裡時，同一主題之後的修改常常要同時改好幾個檔；一個需求改多個檔案是 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）</a>。與它成對的發散式變更，正是本篇用修改紀錄量的那種壞味道（從程式碼結構偵測的規則在 <a href="/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單</a>的〈B1. Divergent Change〉一節），發散式變更在並行開發時造成的等待，就是修改競爭的成本。</p>
<h2 id="用-git-歷史量出熱點">用 git 歷史量出熱點</h2>
<p>量測的前提是需求與檔案的對應查得到。本篇從 commit 標題裡的需求編號取（commit 訊息帶票號的慣例見 <a href="/blog/record/commit-message-vs-source-doc/" data-link-title="Commit message vs source code doc：兩份不同職責的文件" data-link-desc="Source code doc 寫給未來讀者、commit message 寫給回顧歷史的考古者。時序敏感的資訊（為什麼這次改、考慮過什麼方案）放 commit、持續適用的契約放 source。配合 git blame 工作流讓考古路徑清楚、source 不必背所有歷史。">Commit message vs source code doc</a> 的〈Bug 號、ticket 連結、incident 紀錄〉一節；那裡的例子把票號寫在內文，下面的腳本只讀標題，處理方式見〈只讀 commit 標題〉那一條）。想避開只讀標題這個限制的團隊，也可以從 issue tracker 取：匯出每個 PR 連到的 issue 與 PR 改的檔案，整理成「檔案 → 需求編號集合」的對應，就是腳本裡 <code>collect_changes</code> 回傳的那個形狀，之後的排行與判讀照用，而且沒有只讀標題帶來的限制。</p>
<p>commit 標題帶有需求編號（票號、issue 號）時，下面的腳本算出每個檔案被幾個不同需求改過。下文把排行前段、與同一次統計裡的其他檔案相比被明顯更多不同需求修改的檔案稱為修改熱點（簡稱熱點），例如排行前幾名與其後的檔案之間有一段明顯的落差；它不設絕對的門檻，也沒有固定的名次。它與效能分析裡「最常執行的程式路徑」那種熱點是兩件事；git 歷史分析的文獻裡也有 hotspot（Adam Tornhill 在《Your Code as a Crime Scene》裡結合修改頻率與程式碼的複雜度），本篇數的是不同需求數，不數 commit 次數，也不乘複雜度。腳本以 Python 寫成，只呼叫 <code>git log</code>、不讀程式碼，放進 Go 或 Dart 的 repo 照樣能跑。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="s2">&#34;&#34;&#34;依 commit 標題中的需求編號，統計每個檔案被幾個不同需求修改。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">collections</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="nn">fnmatch</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</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="n">ISSUE_ID</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;#(\d+)&#34;</span><span class="p">)</span>  <span class="c1"># 依專案的 commit 慣例調整</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">PR_SUFFIX</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\s*\(#\d+\)\s*$&#34;</span><span class="p">)</span>  <span class="c1"># squash merge 在標題結尾附加的 (#PR號)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">STRIP_PR_SUFFIX</span> <span class="o">=</span> <span class="kc">True</span>  <span class="c1"># 只在用 squash merge、標題結尾的 (#N) 是 PR 號時設 True</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 工具產生的檔案，不列入統計；樣式比對檔名或相對 repo 根目錄的路徑，依專案的套件管理與程式碼產生工具調整</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># generated/* 只比對根目錄那一層，子目錄裡的要另外寫 */generated/*</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">EXCLUDE</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;*.lock&#34;</span><span class="p">,</span> <span class="s2">&#34;package-lock.json&#34;</span><span class="p">,</span> <span class="s2">&#34;go.sum&#34;</span><span class="p">,</span> <span class="s2">&#34;*.g.dart&#34;</span><span class="p">,</span> <span class="s2">&#34;*.pb.go&#34;</span><span class="p">,</span> <span class="s2">&#34;*_pb2.py&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">           <span class="s2">&#34;generated/*&#34;</span><span class="p">,</span> <span class="s2">&#34;*/generated/*&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">EXCLUDE_ISSUES</span><span class="p">:</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>  <span class="c1"># 橫切的需求編號（相依套件升級、全域改名），不列入統計</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">def</span> <span class="nf">excluded</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="nb">any</span><span class="p">(</span><span class="n">fnmatch</span><span class="o">.</span><span class="n">fnmatch</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="ow">or</span> <span class="n">fnmatch</span><span class="o">.</span><span class="n">fnmatch</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">basename</span><span class="p">(</span><span class="n">path</span><span class="p">),</span> <span class="n">p</span><span class="p">)</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">EXCLUDE</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="k">def</span> <span class="nf">collect_changes</span><span class="p">(</span><span class="n">since</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">&#34;.&#34;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">]],</span> <span class="nb">int</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="n">log</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="c1"># core.quotePath=false：非 ASCII 檔名照原樣輸出，不轉成跳脫碼</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="p">[</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;-c&#34;</span><span class="p">,</span> <span class="s2">&#34;core.quotePath=false&#34;</span><span class="p">,</span> <span class="s2">&#34;log&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;--since=</span><span class="si">{</span><span class="n">since</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;--no-merges&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">         <span class="s2">&#34;--name-only&#34;</span><span class="p">,</span> <span class="s2">&#34;--format=@@</span><span class="si">%s</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;--&#34;</span><span class="p">,</span> <span class="n">path</span><span class="p">],</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="n">issues_per_file</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">]]</span> <span class="o">=</span> <span class="n">collections</span><span class="o">.</span><span class="n">defaultdict</span><span class="p">(</span><span class="nb">set</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="n">current</span><span class="p">:</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="n">skipped</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">log</span><span class="o">.</span><span class="n">splitlines</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="k">if</span> <span class="n">line</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;@@&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="n">subject</span> <span class="o">=</span> <span class="n">line</span><span class="p">[</span><span class="mi">2</span><span class="p">:]</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="k">if</span> <span class="n">subject</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s1">&#39;Revert &#34;&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">                <span class="n">current</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>  <span class="c1"># revert 的標題帶著被還原那個 commit 的 PR 號</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="k">if</span> <span class="n">STRIP_PR_SUFFIX</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">                <span class="n">subject</span> <span class="o">=</span> <span class="n">PR_SUFFIX</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="n">subject</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="n">current</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">ISSUE_ID</span><span class="o">.</span><span class="n">findall</span><span class="p">(</span><span class="n">subject</span><span class="p">))</span> <span class="o">-</span> <span class="n">EXCLUDE_ISSUES</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">            <span class="n">skipped</span> <span class="o">+=</span> <span class="ow">not</span> <span class="n">current</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">        <span class="k">if</span> <span class="n">line</span> <span class="ow">and</span> <span class="n">current</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">excluded</span><span class="p">(</span><span class="n">line</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">            <span class="k">for</span> <span class="n">issue</span> <span class="ow">in</span> <span class="n">current</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">                <span class="n">issues_per_file</span><span class="p">[</span><span class="n">line</span><span class="p">]</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">issue</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">    <span class="k">return</span> <span class="n">issues_per_file</span><span class="p">,</span> <span class="n">skipped</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">
</span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="k">def</span> <span class="nf">report</span><span class="p">(</span><span class="n">since</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">top</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">20</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">    <span class="n">changes</span><span class="p">,</span> <span class="n">skipped</span> <span class="o">=</span> <span class="n">collect_changes</span><span class="p">(</span><span class="n">since</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">    <span class="n">ranked</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">changes</span><span class="o">.</span><span class="n">items</span><span class="p">(),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">kv</span><span class="p">:</span> <span class="o">-</span><span class="nb">len</span><span class="p">(</span><span class="n">kv</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">    <span class="k">for</span> <span class="n">path</span><span class="p">,</span> <span class="n">issues</span> <span class="ow">in</span> <span class="n">ranked</span><span class="p">[:</span><span class="n">top</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">issues</span><span class="p">)</span><span class="si">:</span><span class="s2">3d</span><span class="si">}</span><span class="s2"> 個需求  </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="s1">&#39; &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">sorted</span><span class="p">(</span><span class="n">issues</span><span class="p">))</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;略過 </span><span class="si">{</span><span class="n">skipped</span><span class="si">}</span><span class="s2"> 個標題沒有需求編號的 commit&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">
</span></span><span class="line"><span class="ln">54</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">    <span class="n">report</span><span class="p">(</span><span class="s2">&#34;2 weeks ago&#34;</span><span class="p">)</span></span></span></code></pre></div>




<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="c1"># 存成 hotspots.py 之後，在 repo 的根目錄執行（Python 3.9 以上）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">python3 hotspots.py</span></span></code></pre></div><p>腳本裡影響計數的設計：</p>
<ul>
<li>
<p><strong>統計的時間範圍</strong>：<code>report()</code> 的引數是 <code>git log --since</code> 接受的時間字串（<code>&quot;2 weeks ago&quot;</code>、<code>&quot;2026-01-01&quot;</code>）。執行腳本的人設定的範圍要涵蓋幾輪需求，讀排行時才看得出同一個檔案被不同需求反覆修改；範例的兩週適合需求密集的專案，修改頻率低的專案要拉長。一個檔案被幾個需求修改才算多，這個數字跟著時間範圍變，所以讀排行的人比較的是檔案在排行裡的相對位置，不比絕對數字。</p>
</li>
<li>
<p><strong>統計的目錄</strong>：<code>collect_changes</code> 的 <code>path</code> 預設是 <code>&quot;.&quot;</code>，指的是執行腳本時所在的目錄；執行腳本的人從子目錄執行時，腳本只統計那個子目錄底下的檔案。repo 裡文件與程式碼混在一起、執行腳本的人只想統計其中幾個目錄時，在 <code>git log</code> 的 <code>--</code> 之後列多個路徑。</p>
</li>
<li>
<p><strong>只讀 commit 標題</strong>：腳本傳給 <code>git log</code> 的 <code>--format=@@%s</code> 讓 git 對每個 commit 只輸出標題。把需求編號寫在內文或 trailer（訊息結尾 <code>Refs: #160</code> 這種 <code>鍵: 值</code> 形式的欄位）的專案，腳本會靜默略過這些 commit；這類專案改用 <code>%B</code> 取完整訊息時，內文的行會和檔名混在一起，分隔方式與解析都要跟著改。</p>
</li>
<li>
<p><strong>PR 號</strong>：GitHub 的 issue 與 PR 共用同一組 <code>#N</code> 編號，squash merge（把整個 PR 壓成一個 commit 合併）又會在標題結尾附上 <code>(#PR號)</code>，只改 <code>ISSUE_ID</code> 分不開兩者；<code>STRIP_PR_SUFFIX</code> 為 <code>True</code> 時，腳本先去掉標題結尾的 <code>(#N)</code> 再比對。<code>STRIP_PR_SUFFIX</code> 這個設定只適用用 squash merge 的 repo，而且需求編號要寫在 PR 標題裡；習慣在標題結尾寫 <code>(#45)</code> 引用 issue 的專案，要把 <code>STRIP_PR_SUFFIX</code> 設成 <code>False</code>，否則那個 issue 號會被刪掉。需求編號與 PR 編號格式不同的專案（例如需求用 <code>PROJ-123</code>），改 <code>ISSUE_ID</code> 就分得開。revert 的標題會把被還原那個 commit 的 PR 號帶進引號裡，腳本略過 <code>Revert &quot;</code> 開頭的標題（不計入最後印出的略過數）。</p>
</li>
<li>
<p><strong>工具產生的檔案</strong>：lock 檔、每個 PR 都跟著升號的版號檔、產生的程式碼，每個需求都會改到，排行會被它們佔滿，而它們沒有任何人帶著意圖去編輯；執行腳本的人在統計前把檔名或檔名樣式（<code>*.g.dart</code>、<code>*/generated/*</code>）加進 <code>EXCLUDE</code>；路徑樣式比對的是相對 repo 根目錄的完整路徑。</p>
</li>
<li>
<p><strong>改名與橫切的需求</strong>：<code>git log</code> 加了 <code>--name-only</code> 不追蹤改名，大量重新命名之後，舊路徑與新路徑各自計數。相依套件升級、全域改名、格式化這類橫切的需求會讓大量檔案各加一，排行前段一整批同時出現的檔案，多半來自同一個橫切需求；<code>EXCLUDE_ISSUES</code> 用來排除這類需求的編號。</p>
</li>
<li>
<p><strong>沒有編號的 commit</strong>：腳本最後印出略過的 commit 數。Dependabot、Renovate、發版機器人這類 bot 的 commit 也不帶需求編號，讀排行的人判讀前先扣掉它們（<code>git log --format</code> 加 <code>%an</code> 看作者）。扣掉之後的數字仍接近全部 commit（例如九成以上），統計沒有意義；commit 完全不帶編號時排行是空的、只印出這一行。這時讀排行的人改用〈從修改紀錄找出職責邊界〉的做法，讀 commit 標題與 <code>git show</code> 人工歸類；沒有排行可以挑檔案時，先數每個檔案被幾個 commit 改過，取前段當候選：</p>





<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="c1"># 時間範圍用與熱點統計相同的範圍</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git log --no-merges --since<span class="o">=</span><span class="s2">&#34;2 weeks ago&#34;</span> --name-only --format<span class="o">=</span> <span class="p">|</span> <span class="nv">LC_ALL</span><span class="o">=</span>C sort <span class="p">|</span> <span class="nv">LC_ALL</span><span class="o">=</span>C uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -20</span></span></code></pre></div><p>這條指令數的是每個檔案被幾個 commit 改過：同一個需求分成多次 commit 時每次各算一次，大檔案又常在開發者處理別的需求時被順手修改，所以一個檔案排在前段，不代表它的改變理由比較多。讀排行的人只拿這份清單挑出要讀修改紀錄的檔案，每個檔案有幾種改變的理由，要等逐個 commit 歸類之後才判定。</p>
</li>
<li>
<p><strong>編號是不是需求</strong>：略過數量的是多少 commit 沒有編號，量不出比對到的 <code>#N</code> 是不是需求——標題裡的 <code>#N</code> 也可能是文件編號、討論串引用或內部卡片的序號。執行腳本的人跑完之後，抽幾筆被計入的 commit 讀標題，確認編號指的是需求，再看排行。</p>
</li>
<li>
<p><strong><code>--no-merges</code></strong>：<code>git log</code> 預設不列出 merge commit 改了哪些檔案，所以 merge 不影響排行；標題沒有編號的 merge（<code>Merge branch 'x'</code>）會被算進略過的 commit 數，腳本加的 <code>--no-merges</code> 讓 git 不輸出它們。改用 <code>-m</code> 時，merge 對每個 parent 各列一次檔案：對第一個 parent 列出的是分支帶進來的檔案，它們除了分支 commit 的編號，又記到 merge 標題的編號下；對第二個 parent 列出的是主線同期改過的檔案，它們被記到一個沒改過它們的需求名下。改用 <code>--first-parent</code> 時只走主線，分支上的 commit 不列出，改動改由 merge commit 列出、記在 merge 標題的編號下；<code>--first-parent</code> 與 <code>--no-merges</code> 一起用時，以 merge commit 合併的 repo 會失去全部分支上的改動。</p>
</li>
<li>
<p><strong>一張票涵蓋好幾類改動</strong>：一張概括性的票（一張「維護」票、一個 epic 編號）底下做了好幾類改動時，有好幾個理由的檔案只算一個需求，排不進前段。這一類少算的方向腳本看不出來，常用這類票的專案要把它們拆回實際的需求，或把那些票號另外列出來，對它們改過的檔案直接做逐個 commit 歸類。</p>
</li>
<li>
<p><strong>一則 commit 帶多個編號</strong>：腳本用 <code>ISSUE_ID.findall</code> 取出標題裡的全部編號。一個 commit 同時處理多個需求時，它改過的每個檔都會記到每個需求名下。排行裡一整批檔案同分、而它們共有的編號都來自同一個 commit 時，讀排行的人要排除的是那個 commit，不是那幾個編號（排除編號會連帶扣掉其他檔案正當的計數）：修改腳本的人把 <code>--format</code> 改成 <code>@@%h %s</code>，<code>subject = line[2:]</code> 之後先切出開頭的 hash，讀到要排除的 hash 時清空 <code>current</code>，其餘照原本比對標題。</p>
</li>
<li>
<p><strong>把子需求歸到同一個主題</strong>：點號格式的子需求（例如 <code>#120.3</code>），正規式 <code>#(\d+)</code> 只比對到第一段數字，本來就會算進根需求 120。子需求若有獨立編號（例如父需求 <code>#120</code>、子需求 <code>#135</code>），執行統計的人要另備一份父子對照表，把子需求歸到根需求再計數；不歸併的話，同一主題的連續修改會被誤算成多個改變理由。〈熱點的分類與處理方式〉表格裡「同一主題的連續演進」那一列，指的是子票已經歸到根需求之後的計數。</p>
</li>
<li>
<p><strong>同一功能的各輪追修另開新編號</strong>：同一個功能經過好幾輪審查時，每一輪的追修常另開一個新需求，編號不同、彼此沒有父子關係，父子對照表也歸併不了。這些編號在排行裡各算一個，一個被反覆修改的功能看起來像好幾個需求在搶改同一個檔，這個檔因此排進前段，讀排行的人逐個 commit 歸類時，時間會先花在它身上。讀排行的人對排在前段的檔案，先用〈從修改紀錄找出職責邊界〉那條 <code>git log --format='%h %s'</code> 列出 commit 標題，把屬於同一功能的編號合併成一個主題，再用合併後的主題數與其他檔案比較；合併之後仍排在前段，才進入逐個 commit 歸類。腳本不讀標題的語意，哪些編號屬於同一功能要由人判讀。</p>
</li>
</ul>
<h2 id="熱點的分類與處理方式">熱點的分類與處理方式</h2>
<p>排行前段的檔案有些該拆、有些不該拆，讀排行的人先依下表認出每個檔案的熱點類型，再照該列處理：</p>
<table>
  <thead>
      <tr>
          <th>熱點類型</th>
          <th>例子</th>
          <th>處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一般程式碼，排在前段、被多個不同的需求修改</td>
          <td>commit 守衛 <code>guard.py</code>：命令解析、狀態判斷、訊息產生寫在同一個檔</td>
          <td>依〈從修改紀錄找出職責邊界〉評估拆分</td>
      </tr>
      <tr>
          <td>同一主題的連續演進</td>
          <td>一個佇列模組，被同一個「排隊機制」需求的 5 張子票陸續修改（子票歸到根需求後只算一個需求）</td>
          <td>不拆，這是內聚</td>
      </tr>
      <tr>
          <td>集中登記檔：每加一個功能就要在這裡登記一筆的檔案</td>
          <td>URL 路由表、外掛註冊表、<code>settings.json</code>、依類型分支的長 if/elif 或 switch</td>
          <td>不以職責拆分，改成每個項目一個片段檔</td>
      </tr>
      <tr>
          <td>只有測試檔是熱點</td>
          <td>一個大測試檔被多個需求加測項</td>
          <td>見表下說明</td>
      </tr>
      <tr>
          <td>工具產生或衍生的檔案</td>
          <td>lock 檔、每個 PR 都升號的版號檔、產生的程式碼</td>
          <td>不拆，從統計排除；衝突靠重新產生解</td>
      </tr>
  </tbody>
</table>
<p>讀排行的人對照這張表時，有些檔案同時符合兩種類型，例如 i18n 字串檔：來源語系的字串由開發者在每加一個功能時登記一筆，屬於集中登記檔；翻譯語系的字串由翻譯平台寫入，屬於工具產生的檔案。兩種類型的處置不同，所以他依每次修改的寫入者是人還是工具，把這個檔案的修改分到兩種類型，各自照該列處理。</p>
<p>熱點統計以檔案為單位計數，所以不落在單一檔案上的競爭不會出現在排行裡。例如 schema 遷移：開發者每次改資料庫結構都新增一個遷移檔，兩個分支同時各加一個遷移時，合併後會出現兩個都排在最後的遷移，競爭發生在遷移的先後順序上，而每個遷移檔在統計裡都只被一個需求改過。</p>
<p>lock 檔與產生的程式碼由工具改寫：開發者執行套件管理或程式碼產生工具時，工具依套件清單或來源定義重新寫出整個檔，內容由工具的輸入決定，所以這些修改歸不進任何一種職責：執行腳本的人在統計前把它們加進 <code>EXCLUDE</code> 排除；兩個分支同時改到同一個 lock 檔而衝突時，解衝突的人重新執行產生它的工具，不逐行挑選兩邊的內容。每個 PR 都升號的版號檔，團隊改由發版時打的 tag 衍生版號之後，PR 就不必再改它。</p>
<p>版號寫在開發者手動編輯的檔案裡時（<code>package.json</code> 的 <code>version</code>、<code>__init__.py</code> 的 <code>__version__</code>），同一個檔也有人為了需求修改其他內容，把整個檔加進 <code>EXCLUDE</code> 會連這些修改一起從統計拿掉；逐個 commit 歸類的人略過只改版號的那幾個 commit。根本的解法同樣是改由 tag 衍生版號，開發者就不必再手動修改這個欄位。</p>
<p>集中登記檔的處置跟一般程式碼相反。註冊表的職責是集中列出所有項目，每加一個功能就要登記一次，所以修改它的需求數會隨功能數一起增加。開發者把它依職責拆成三份註冊表之後，每次登記仍然要改其中一份，衝突只是換到另一個檔案；兩個需求都在同一份清單的尾端追加，照樣是相鄰的改動。這類檔案的解法是讓每次登記不碰同一個檔案：每個項目一個片段檔，由載入端掃描目錄合併。這要載入端由自己控制；設定檔由外部工具讀取、而工具只認單一檔案時，片段檔進版控，由一個產生步驟（例如建置前執行的一支合併腳本）合併成那一個檔，合併出來的檔案當成工具產生的檔案從統計排除。</p>
<p>登記的對象是程式碼時，由誰掃描依語言而定：Python 在執行期匯入目錄底下的每個模組（寫法見<a href="/blog/programming/python/basic/07-refactoring/python-module-split/" data-link-title="拆分 Python 模組：pytest 的特徵測試、同名套件與重新匯出、自動登記的分派表、避免循環匯入" data-link-desc="職責邊界已經從修改紀錄歸類出來之後，用 Python 拆分一個模組而不改變對外行為：用 pytest 寫特徵測試與 monkeypatch 的替換目標、改成同名套件並重新匯出、把巨型分支改成自動登記的分派表，以及拆出模組之間循環匯入在 Python 的症狀與解法">拆分 Python 模組</a>的〈把巨型分支改成自動登記的分派表〉），Go 在同一個 package 裡讓每個檔案用自己的 <code>init()</code> 登記，不需要掃描；既沒有執行期掃描、也沒有這類機制的語言，走上面那個產生步驟。</p>
<p>片段檔的代價是登記關係不再寫在一個看得到的地方，放進目錄的半成品檔也會被登記。登記項目之間有先後的語意時（路由表先比對到的先生效、middleware 依序執行、依類型分支裡子類別要排在父類別之前），掃描目錄得到的是檔名或檔案系統列出的順序，改成片段檔可能悄悄改掉哪個項目先被比對到；每個片段要宣告自己的排序鍵，由載入端依它排序，特徵測試要涵蓋會被兩個項目同時比對到的輸入。</p>
<p>集中本身有用途時（例如公開 API 的介面檔，集中是為了讓每次新增都經過同一處審查），或衝突多半是單行、由工具重新產生就能解決時（例如套件清單檔），保留集中、接受衝突也是一個出口。</p>
<p>測試檔的修改跟著它測的產品檔走，所以只有測試檔是熱點時，讀排行的人回到它測的產品檔判讀。產品檔也是熱點，測試隨產品檔一起拆。產品檔不是熱點，多半代表這個測試檔一次測了好幾個產品模組；產品檔已經拆成幾個模組、測試卻還集中在一個檔，也是同一種情形，兩者都依它測的模組拆成對應的測試檔。另一種情形是這個測試檔是共用的 fixture 或測試案例清單，每個需求都在裡面登記一筆，那是集中登記檔，依上一段處理。</p>
<h2 id="從修改紀錄找出職責邊界">從修改紀錄找出職責邊界</h2>
<p>職責的邊界記在修改紀錄裡：每個需求改的是哪一種職責，讀它的 commit 就知道。熱點統計腳本的 <code>report()</code> 在每個檔案後面印出了改過它的需求編號，負責歸類的人要看每個需求做了什麼時，列出這個檔案的 commit 標題：</p>





<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="c1"># 時間範圍用與熱點統計相同的範圍，路徑換成要拆的那個檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git log --no-merges --since<span class="o">=</span><span class="s2">&#34;2 weeks ago&#34;</span> --format<span class="o">=</span><span class="s1">&#39;%h %s&#39;</span> -- guard.py</span></span></code></pre></div><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">需求 A：修正命令解析遇到引號時的錯誤        → 命令解析
</span></span><span class="line"><span class="ln">2</span><span class="cl">需求 B：新增「並行期間」的判斷條件           → 狀態判斷
</span></span><span class="line"><span class="ln">3</span><span class="cl">需求 C：提示訊息改成指引使用者改寫命令       → 訊息產生
</span></span><span class="line"><span class="ln">4</span><span class="cl">需求 D：解析器支援 -C 參數                   → 命令解析
</span></span><span class="line"><span class="ln">5</span><span class="cl">需求 E：並行期間改依工作位置過濾             → 狀態判斷</span></span></code></pre></div><p>逐個 commit 歸類的人看的單位是每個 commit 改到的程式：<code>git show &lt;hash&gt;</code> 看得到那個 commit 改了哪些函式。類別依需求要求的改動種類建立，與歸類的先後順序無關。長期計畫（橫跨多張票、為同一個目標分批進行的工作，例如分好幾輪做完的一次重構）有根需求編號時照根需求歸併；沒有的話，它的 commit 改到好幾處，也只歸進其他需求已經要求過的那些種類，計畫本身不構成一個模組。只有一個需求、改到同一個檔案的好幾處，是同一主題的連續演進，不產生新的類別；那個需求若要同時改好幾個檔案，是散彈式修改的訊號，處理見 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery</a> 卡片。bug 修正與重構不另算一個理由，依它修的那段程式碼原本屬於哪一類歸進那一類。一段程式（例如兩類都呼叫的計算函式）被兩類需求各自改過時，記成兩類的共用處，不歸給先改它的那一類，拆分時再判斷它跟著哪一類走。這個例子裡每個需求只改到一類，所以逐個需求列出來就是逐個 commit 的結果。</p>
<p>多數需求（需求數超過一半；這是判斷用的經驗值，不是量測結果）都同時改到某兩類時，這兩類對需求而言是同一個改變的理由，歸類的人把它們歸成同一類——依它們切開，每個需求都要同時改兩個新檔，就是散彈式修改。需求本來就是跨層的功能切片時（例如每個新參數都要同時加解析規則與擋下訊息），另外看有沒有需求只改了其中一類：一個都沒有，才把兩類併成一類。</p>
<p>歸類之後，每一類職責是一個候選模組，這個例子分出命令解析、狀態判斷與訊息產生。歸類後只剩一類時，這些需求要求的是同一類改動，這個檔案只有一個改變的理由（內聚），或者它是集中登記檔，歸類的人回到〈熱點的分類與處理方式〉的表重新判斷。歸出的幾類裡有一類是「登記一筆」（同一個檔裡同時有處理邏輯與一張對照表）時，先把那張表拆成自己的檔，依集中登記檔處理，其餘幾類照候選模組判斷。</p>
<p>團隊決定候選模組是否拆開時，看這些修改的代價有沒有出現：有沒有人在這個檔案上等待、有沒有一個需求改壞了另一類在乎的行為，出現任一種就有拆的理由。各類在統計範圍裡只被改過一兩次、而且時間上一個接一個、沒有兩個需求同時在改時，代價還沒有出現，記下這次的歸類，下次重跑統計時再判斷。</p>
<h2 id="拆分時與語言無關的做法特徵測試搬移與修改分開提交單向依賴">拆分時與語言無關的做法：特徵測試、搬移與修改分開提交、單向依賴</h2>
<p>團隊決定切法之後，動手拆分的人要把 <code>guard.py</code> 依這三類拆成 <code>command_parse.py</code>、<code>state.py</code>、<code>messages.py</code>，同時維持對外行為不變。做到這一點有三件事與語言無關，各語言的寫法放在各語言模組；要拆的邏輯在其他檔案另有副本時，還有一件事排在這三件之前。</p>
<p><strong>拆分前先收斂散在多個檔案的重複實作。</strong> 同一段邏輯在好幾個檔案各寫了一份時（例如讀取同一份設定的函式，在共用模組與幾支腳本裡各有一份），拆分的人先把它收斂成一份、其他檔案改為呼叫它，收斂合併之後再拆各個熱點檔。不先收斂有兩個代價：各份副本跟著需求各自被修改，同一個修正常常只落在其中一份，其餘幾份留著舊的行為；這段邏輯每改一次都要同時改好幾個檔（<a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">散彈式修改</a>），這幾個檔都被推進熱點排行，各自拆完之後，每個新模組裡仍帶著一份副本，修改照樣要同時改好幾個新檔。所以收斂是這幾個拆分共同的前置：它自成一個需求、先合併，之後各熱點檔再以收斂後的版本為基準拆分，拆出的模組依賴那一份共用實作。收斂只把同一份知識合成一份；各份已經分歧時，先判斷是某一份漏修，還是兩類需求本來就要不同的行為，後者照 <a href="/blog/software-design/design-principles/knowledge-cards/dry-principle/" data-link-title="DRY（Don&#39;t Repeat Yourself）" data-link-desc="看到兩段長得一樣的程式碼想合併、或同一條規則改了一處而另一處忘了改時：DRY 管的是知識的重複，判斷兩段程式碼該不該合併要看它們是不是因為同一個理由改變">DRY</a> 卡片的判斷各留一份。前者在收斂時會改到漏修那幾份的行為，這個行為改動與合併副本分開提交，理由見下文〈搬移與修改分開提交〉。</p>
<p><strong>拆分前用特徵測試鎖住行為。</strong> 拆分前後跑同一組測試、結果相同，才證明拆分沒有改到對外行為。這種測試叫特徵測試（<a href="/blog/testing/knowledge-cards/characterization-test/" data-link-title="Characterization Test" data-link-desc="斷言「行為不變」而非「行為正確」的測試形態；與正確性測試的語意分界，決定紅燈能不能歸因">characterization test</a>）：預期值取自拆分前程式當下的實際輸出，不是規格上應該如何。既有的測試已經涵蓋要鎖住的輸入與輸出時，直接用它們；沒有時才補特徵測試，拆完、正確性測試覆蓋同一段行為之後，照那張卡片〈設計責任〉的說明移除它。它抓得到拆分在測到的輸入與斷言的輸出上造成的任何差異，包括原本就存在的錯誤被「順手修好」；沒有斷言的輸出抓不到。測試表要涵蓋應該被擋下或應該回報錯誤的反例：只驗證正常情況會放行的測試表，在拆分改壞了擋下的判斷時依然全綠。</p>
<p><strong>搬移與修改分開提交。</strong> 搬移和修改混在同一個 commit 裡，測試一紅，就分不出是搬錯了還是改錯了，所以純搬移的 commit 不同時修改行為，行為的修改放在之後的 commit。<code>git diff --color-moved</code> 會把搬移過的行與新寫的行用不同顏色標出，用來確認一個 commit 裡只有搬移。拆分要把原檔換到新路徑時（例如 Python 改成同名套件），改名也單獨提交一個只改名的 commit：git 依內容相似度判定改名，改名與大量搬移混在同一個 commit 時，留下來的那份檔案只剩原檔的一小部分，git 認不出它是改名而來，<code>git log --follow</code> 就追不到改名之前的歷史。只改名的 commit 保住的是留下原名的那一份；其他新檔是新建的，<code>--follow</code> 追不到它們的內容在拆分前的修改，日後想看搬走的程式之前怎麼改的人，用 <code>git blame -C -C &lt;新檔&gt;</code>：<code>-C</code> 給兩次時，git 也會在建立這個檔的 commit 裡找從其他檔複製過來的行，標出它們在原檔的來源 commit。</p>
<p><strong>拆出來的模組依賴只朝一個方向。</strong> 依賴單向時，改動下層的模組可能影響上層，改動上層不會影響下層，一次修改會波及哪些模組可以預測；兩個模組互相依賴時，改任何一邊都可能波及另一邊。判斷上下層的依據是誰需要知道誰：被較多拆出模組使用、而且自己不需要知道其他拆出模組的那一個在下層，兩個條件都要成立。這個例子裡 <code>command_parse.py</code> 只把指令字串解析成結構，在最下層；<code>state.py</code> 與 <code>messages.py</code> 使用解析結果；組合三者的那一層在最上面。兩個新模組互相呼叫時，拆分的人看它們過去的修改：幾乎每次都一起發生，代表它們是同一類職責、歸類時切錯了，併回同一個模組（判斷見 <a href="/blog/software-design/design-principles/knowledge-cards/coupling/" data-link-title="Coupling（耦合）" data-link-desc="改一個模組總是連帶要改另一個、或要判斷兩個模組之間的依賴可不可以接受時：耦合是模組之間互相依賴的程度，依兩個模組共享什麼分成由強到弱的幾種">Coupling</a> 卡片的〈設計責任〉）；修改各自發生，先確認兩個方向的呼叫是否都必要，有時拿掉其中一邊就夠了；兩邊都必要，多半代表中間有一塊共用的定義沒有抽出來，例如兩邊都用到的資料型別，把它往下抽一層，讓兩者都依賴它。往下抽的前提是那塊定義只因一個理由改變；兩類需求各自會改它時（Martin 的 <code>regularHours</code> 例子，見<a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則</a>卡片），抽成共用正是那張卡片警告的情形，各留一份（判斷同一份知識與碰巧長得一樣，見 <a href="/blog/software-design/design-principles/knowledge-cards/dry-principle/" data-link-title="DRY（Don&#39;t Repeat Yourself）" data-link-desc="看到兩段長得一樣的程式碼想合併、或同一條規則改了一處而另一處忘了改時：DRY 管的是知識的重複，判斷兩段程式碼該不該合併要看它們是不是因為同一個理由改變">DRY</a> 卡片）。熱點檔拆成同一個 package 裡的幾個檔案時（Go 的常見做法），編譯器不檢查同一個 package 裡檔案之間的依賴方向，這一條作用在之後是否再拆成不同 package 的判斷上。</p>
<h2 id="拆分的時機與等待中的需求">拆分的時機與等待中的需求</h2>
<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">拆分前：命令解析、狀態判斷、訊息產生各有一個需求在等，三個都要改 guard.py → 一個接一個
</span></span><span class="line"><span class="ln">2</span><span class="cl">拆分後：處理命令解析需求的人改 command_parse.py，狀態判斷的改 state.py，訊息產生的改 messages.py → 可以同時進行</span></span></code></pre></div><p>原本要一個接一個做的三個需求，拆分之後可以平行處理。拆分若在某個需求開工之後才合併，那個需求的分支改的是舊的 <code>guard.py</code>，rebase 到拆分之後的版本時，git 的改名偵測（內容相似度預設 50% 以上就判定為改名，下文的「過半」就是指這個相似度）決定衝突的形態。原檔留在原路徑、只剩組合三者的那一層時（Go 常把 <code>guard.go</code> 留著當組合層），沒有改名，衝突以一般的內容衝突出現在原檔，處置同下；原檔被換到新路徑或刪除時，有兩種形態：</p>
<ul>
<li><strong>沒有一份新檔保留原檔過半的內容</strong>：git 判定 <code>guard.py</code> 被刪除，出現 modify/delete 衝突。</li>
<li><strong>有一份新檔保留了原檔過半的內容</strong>：git 把它當成 <code>guard.py</code> 改名而來，衝突以一般的內容衝突出現在那份新檔，衝突區塊裡是已經搬到其他模組的舊函式。解衝突的人照一般衝突處理接受這一段，會把搬走的函式重新塞回那份新檔，分支的改動落在那份新檔裡的舊函式副本上。工具是否擋下這份副本，依語言與檔案配置而定：Go 把幾類拆成同一個 package 裡的檔案、Dart 用 <code>part</code> 把一個 library 拆成好幾個檔時，重複宣告在編譯時就被擋下；Python 這類每個檔案各自是一個命名空間的語言不報錯（Dart 不用 <code>part</code> 時也是，編譯與 <code>dart analyze</code> 都不擋），又分兩種情形——那份新檔沒有匯入同名函式時，副本沒有人呼叫，分支的改動等於消失；那份新檔先匯入了同名函式（例如 <code>from .command_parse import parse_command</code>）、塞回的 <code>def</code> 排在 import 之後時，副本蓋掉匯入的名字，被呼叫的反而是帶著分支改動的副本，而真正的新家留著舊版。後一種情形 ruff 或 flake8 的 F811 規則（pyflakes 的 redefinition of unused 檢查）抓得到。</li>
</ul>
<p>兩種形態的處置相同：解衝突的人把這個分支的改動移到那一職責所在的新檔，不接受衝突區塊裡整段的舊程式碼。rebase 沒有衝突時，分支的負責人也要檢查一次：分支在被當成改名目標的那份新檔裡新增的函式會乾淨合併，即使它屬於另一類職責。用 <code>git diff main --stat</code> 與 <code>git diff main</code> 看分支新增的每個函式落在哪個檔（<code>main</code> 換成拆分合併進去的那條分支）。在檔案各自是命名空間的語言裡，拆分前就有的測試在上面這幾種情形下（modify/delete、內容衝突，以及 Python 的兩種副本）仍然全綠；分支為自己的改動補的測試可能紅，也可能因為副本蓋掉了匯入的名字而全綠，所以上面那個 <code>git diff</code> 的檢查不論落在哪一種情形都有效。這也是拆分要排在等待中的需求開工之前合併的原因。</p>
<p>長期分支很多、團隊找不到「沒有需求在進行中」的時間點時，有三種退路：先把進行中的分支合完再拆，排一個短暫的凍結窗口；一次只抽出一類，原檔保留其餘內容，衝突只落在動過那一類的分支、位置也預測得到；rebase 之後跑會抓重複定義的工具（Go 的編譯、TypeScript 的 tsc、Python 的 ruff F811），再加上面那個 <code>git diff</code> 檢查。分支都很短的團隊（例如 trunk-based development）少有這個情形。</p>
<h2 id="拆分之後重跑熱點統計驗證切法與新檔再成熱點的判讀">拆分之後重跑熱點統計：驗證切法與新檔再成熱點的判讀</h2>
<p>團隊用拆分之後的修改紀錄驗證拆分是否切對。驗證的對象是拆分之後的修改，所以過了幾輪需求之後重跑熱點統計，<code>report()</code> 的引數設成拆分合併的那一天；拆分本身的需求編號加進 <code>EXCLUDE_ISSUES</code>，否則拆分那個 commit 會讓每個新檔各多算一個需求。重跑統計的人比較拆出來的各個新檔被幾個需求修改，並看每個需求是否只改了一個新檔：多數需求同時改到兩個新檔，代表當初把同一個改變的理由切成了兩類。</p>
<p>某個新檔很快又成為熱點時，讀排行的人把它的修改再歸類一次：</p>
<ul>
<li><strong>修改落在拆分時已有的幾類而混在一起</strong>：當初的職責分類切錯了位置。</li>
<li><strong>出現拆分時沒有的一類</strong>：多了一種新的職責。</li>
<li><strong>每次修改都是登記一筆</strong>：那個新檔本身是集中登記檔。</li>
<li><strong>修改全部落在它自己那一類</strong>：在這一類裡照〈從修改紀錄找出職責邊界〉再歸類一次。依需求要求的改動種類再分一次：這些需求要求的改動屬於兩種以上的種類（例如有的改解析規則、有的改輸出格式），代表這一類粒度太粗，可以再拆；全部屬於同一種，代表這一類就是程式最常變動的那一處，拆分已經把變動集中在那裡，不必再拆。</li>
</ul>
<p>拆分時若另抽了一個讓幾個新檔共用的定義模組（例如幾個模組都用到的資料型別），它被每個需求都改到時，常見的原因是共用定義抽得太大，裡面混了只有某一個模組用的型別，開發者把那些型別搬回使用它的模組。</p>
<p>這次驗證之後，團隊照原本的時間範圍定期重跑熱點統計：讀排行的人從〈熱點的分類與處理方式〉重新判讀新出現在排行前段的檔案。</p>
<hr>
<p>拆分的語言實作：<a href="/blog/programming/python/basic/07-refactoring/python-module-split/" data-link-title="拆分 Python 模組：pytest 的特徵測試、同名套件與重新匯出、自動登記的分派表、避免循環匯入" data-link-desc="職責邊界已經從修改紀錄歸類出來之後，用 Python 拆分一個模組而不改變對外行為：用 pytest 寫特徵測試與 monkeypatch 的替換目標、改成同名套件並重新匯出、把巨型分支改成自動登記的分派表，以及拆出模組之間循環匯入在 Python 的症狀與解法">拆分 Python 模組</a>（同名套件與重新匯出、monkeypatch 的替換目標、自動登記的分派表、拆出模組之間的循環匯入）、<a href="/blog/programming/go/basic/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">7.5 以 domain 重新整理 package</a>（type alias 過渡、每次只搬一個邊界、測試保護搬移）、<a href="/blog/programming/go/basic/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">1.5 從單檔到多檔案</a>（Go 的多檔案、多 package 與 import cycle）。文字文件的對應做法：<a href="/blog/record/document-split-by-responsibility/" data-link-title="依職責拆分文字文件：修改紀錄的訊號、切法與拆完之後的引用" data-link-desc="規則文件、操作手冊與範本的拆分：從版本歷史讀出一份文件承擔幾種職責，依職責而非長度決定切法，原檔保留導覽表、同一條規則只定義一次，以及拆完之後更新引用與檢查新檔之間的指引缺口">依職責拆分文字文件</a>。</p>
]]></content:encoded></item><item><title>軟體設計原則</title><link>https://tarrragon.github.io/blog/software-design/design-principles/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/</guid><description>&lt;p>本模組承擔語言無關的軟體設計原則：模組的邊界怎麼畫、哪些程式碼該放在一起、違反時在程式碼與修改紀錄上留下什麼痕跡。這些原則在每種語言的寫法不同（Python 的套件與 re-export、Go 的 package、Dart 的 library），所以本模組放定義、判斷標準、只讀版本歷史的量測方法，以及拆分時與語言無關的做法；各語言的寫法放在各語言模組。&lt;/p>
&lt;h2 id="與其他教材的分工">與其他教材的分工&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>教材&lt;/th>
 &lt;th>承擔什麼&lt;/th>
 &lt;th>本模組的關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/software-design/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判斷標準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD&lt;/a>&lt;/td>
 &lt;td>領域模型的理論：entity、不變式、聚合等&lt;/td>
 &lt;td>DDD 依單一職責切 domain 與 event；本模組給原則的定義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/" data-link-title="Python 維護工程師實戰指南" data-link-desc="以 Hook 系統為範例的 Python 開發教學">Python&lt;/a>&lt;/td>
 &lt;td>Python 的語言與工程實踐&lt;/td>
 &lt;td>本模組原則的 Python 實作（拆模組、re-export、測試）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單&lt;/a>&lt;/td>
 &lt;td>壞味道的偵測規則與修正範例&lt;/td>
 &lt;td>本模組給壞味道的定義與它違反的原則&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/record/solid-writing-methodology/" data-link-title="SOLID 寫作方法論：程式結構原則在文章體系的映射" data-link-desc="文章、模組、系列的結構決策（該不該拆篇、擴充點放哪裡、引用往哪個方向、讀者怎麼分流）缺乏統一判斷標準時，用 SOLID 原則的寫作映射做結構檢查；含五原則的寫作定義、實證案例、違反訊號與類比邊界">SOLID 寫作方法論&lt;/a>&lt;/td>
 &lt;td>SOLID 映射到文章體系&lt;/td>
 &lt;td>同一組原則用在文件上的版本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="測試全過而實機全壞、或程式碼由 agent 產出而無法逐行讀時，用來決定驗證該掛在哪幾層、每一層各自守不住什麼">Testing&lt;/a>&lt;/td>
 &lt;td>驗證掛在哪幾層、每層保證不了什麼&lt;/td>
 &lt;td>可測試性是設計屬性；測試接縫與依賴注入的卡在 DDD 模組&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design&lt;/a>&lt;/td>
 &lt;td>畫面狀態、導航與互動回饋&lt;/td>
 &lt;td>無直接依賴；共用的只有「設計」這個字&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>路由方向是單向的：本模組的定義不依賴任何語言的實作；語言模組引用本模組的卡片建立概念。&lt;/p>
&lt;h2 id="章節與卡片">章節與卡片&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界&lt;/a>：用 git 歷史量出被多個不同需求修改的檔案、分類熱點、逐個 commit 把需求歸到職責、拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴）、拆分合併的時機與拆完之後的驗證。範例是一支 Python 寫成的 commit 守衛，量測與歸類不依賴語言；語言的寫法在各語言模組（目前有 &lt;a href="https://tarrragon.github.io/blog/programming/python/basic/07-refactoring/python-module-split/" data-link-title="拆分 Python 模組：pytest 的特徵測試、同名套件與重新匯出、自動登記的分派表、避免循環匯入" data-link-desc="職責邊界已經從修改紀錄歸類出來之後，用 Python 拆分一個模組而不改變對外行為：用 pytest 寫特徵測試與 monkeypatch 的替換目標、改成同名套件並重新匯出、把巨型分支改成自動登記的分派表，以及拆出模組之間循環匯入在 Python 的症狀與解法">Python&lt;/a>，Go 的拆分基礎在 &lt;a href="https://tarrragon.github.io/blog/programming/go/basic/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">1.5 從單檔到多檔案&lt;/a>）。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/" data-link-title="Knowledge Cards" data-link-desc="軟體設計原則的術語卡片：模組邊界怎麼畫、哪些程式碼該放在一起">Knowledge Cards&lt;/a>：單一職責、開放封閉、DRY、內聚、耦合與兩種對應的壞味道。&lt;/li>
&lt;/ul>
&lt;h2 id="backlog">Backlog&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>前置條件&lt;/th>
 &lt;th>規模&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>〈從修改紀錄畫模組邊界〉補第二個語言的範例：目前範例只有 Python 寫成的 commit 守衛；Go 已有 go/07〈7.5 以 domain 重新整理 package〉承接 type alias 過渡與測試保護搬移，缺的是以修改紀錄歸類為起點的 Go 範例&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>有第二個語言的實例&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>聚焦入口候選：rebase 撞到別人拆檔留下的衝突（兩種形態與改名目標蓋掉匯入名字的情形），目前埋在〈從修改紀錄畫模組邊界〉的〈拆分的時機與等待中的需求〉&lt;/td>
 &lt;td>案例&lt;/td>
 &lt;td>有實際事件再寫成 work-log&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Principle of Least Astonishment（最小意外原則）知識卡：python/00〈認知負擔〉的〈最小意外原則〉一節是本模組範圍內的原則，抽出定義與來源，Python 例子留原處&lt;/td>
 &lt;td>知識卡&lt;/td>
 &lt;td>先查證來源&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="範圍決定">範圍決定&lt;/h3>
&lt;p>2026-10-19 評估過把 DDD、UX Design、Testing 移進本模組，三者都維持獨立、與本模組並列：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DDD&lt;/strong>：理解的對象是領域模型，自有詞彙與案例庫；它引用本模組的單一職責與開放封閉原則，關係是引用而不是隸屬。&lt;/li>
&lt;li>&lt;strong>UX Design&lt;/strong>：理解的對象是使用者與畫面，與程式碼結構沒有交集。&lt;/li>
&lt;li>&lt;strong>Testing&lt;/strong>：理解的對象是驗證怎麼分層，涵蓋可觀測性、UI 自動化與 agent 產出程式碼的驗證，大半與程式碼結構無關。&lt;/li>
&lt;/ul>
&lt;p>本模組的內容改由抽取補齊：語言模組裡講語言無關原則的段落，把定義與判斷標準抽成本模組的卡，語言寫法留在原處並連過來。DDD 的 Dependency Injection 與 Test Seam 卡評估後留在 DDD：兩張的引用全部在 DDD 內部，內容以 port、adapter、composition root 定義，搬出會切斷那組卡的互連。&lt;/p>
&lt;p>重新評估的條件：DDD 的章節開始處理與領域模型無關的模組設計，或讀者路線固定成先讀本模組再讀 DDD 的一條階梯。&lt;/p></description><content:encoded><![CDATA[<p>本模組承擔語言無關的軟體設計原則：模組的邊界怎麼畫、哪些程式碼該放在一起、違反時在程式碼與修改紀錄上留下什麼痕跡。這些原則在每種語言的寫法不同（Python 的套件與 re-export、Go 的 package、Dart 的 library），所以本模組放定義、判斷標準、只讀版本歷史的量測方法，以及拆分時與語言無關的做法；各語言的寫法放在各語言模組。</p>
<h2 id="與其他教材的分工">與其他教材的分工</h2>
<table>
  <thead>
      <tr>
          <th>教材</th>
          <th>承擔什麼</th>
          <th>本模組的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/software-design/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判斷標準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD</a></td>
          <td>領域模型的理論：entity、不變式、聚合等</td>
          <td>DDD 依單一職責切 domain 與 event；本模組給原則的定義</td>
      </tr>
      <tr>
          <td><a href="/blog/programming/python/basic/" data-link-title="Python 維護工程師實戰指南" data-link-desc="以 Hook 系統為範例的 Python 開發教學">Python</a></td>
          <td>Python 的語言與工程實踐</td>
          <td>本模組原則的 Python 實作（拆模組、re-export、測試）</td>
      </tr>
      <tr>
          <td><a href="/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單</a></td>
          <td>壞味道的偵測規則與修正範例</td>
          <td>本模組給壞味道的定義與它違反的原則</td>
      </tr>
      <tr>
          <td><a href="/blog/record/solid-writing-methodology/" data-link-title="SOLID 寫作方法論：程式結構原則在文章體系的映射" data-link-desc="文章、模組、系列的結構決策（該不該拆篇、擴充點放哪裡、引用往哪個方向、讀者怎麼分流）缺乏統一判斷標準時，用 SOLID 原則的寫作映射做結構檢查；含五原則的寫作定義、實證案例、違反訊號與類比邊界">SOLID 寫作方法論</a></td>
          <td>SOLID 映射到文章體系</td>
          <td>同一組原則用在文件上的版本</td>
      </tr>
      <tr>
          <td><a href="/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="測試全過而實機全壞、或程式碼由 agent 產出而無法逐行讀時，用來決定驗證該掛在哪幾層、每一層各自守不住什麼">Testing</a></td>
          <td>驗證掛在哪幾層、每層保證不了什麼</td>
          <td>可測試性是設計屬性；測試接縫與依賴注入的卡在 DDD 模組</td>
      </tr>
      <tr>
          <td><a href="/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design</a></td>
          <td>畫面狀態、導航與互動回饋</td>
          <td>無直接依賴；共用的只有「設計」這個字</td>
      </tr>
  </tbody>
</table>
<p>路由方向是單向的：本模組的定義不依賴任何語言的實作；語言模組引用本模組的卡片建立概念。</p>
<h2 id="章節與卡片">章節與卡片</h2>
<ul>
<li><a href="/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界</a>：用 git 歷史量出被多個不同需求修改的檔案、分類熱點、逐個 commit 把需求歸到職責、拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴）、拆分合併的時機與拆完之後的驗證。範例是一支 Python 寫成的 commit 守衛，量測與歸類不依賴語言；語言的寫法在各語言模組（目前有 <a href="/blog/programming/python/basic/07-refactoring/python-module-split/" data-link-title="拆分 Python 模組：pytest 的特徵測試、同名套件與重新匯出、自動登記的分派表、避免循環匯入" data-link-desc="職責邊界已經從修改紀錄歸類出來之後，用 Python 拆分一個模組而不改變對外行為：用 pytest 寫特徵測試與 monkeypatch 的替換目標、改成同名套件並重新匯出、把巨型分支改成自動登記的分派表，以及拆出模組之間循環匯入在 Python 的症狀與解法">Python</a>，Go 的拆分基礎在 <a href="/blog/programming/go/basic/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">1.5 從單檔到多檔案</a>）。</li>
<li><a href="/blog/software-design/design-principles/knowledge-cards/" data-link-title="Knowledge Cards" data-link-desc="軟體設計原則的術語卡片：模組邊界怎麼畫、哪些程式碼該放在一起">Knowledge Cards</a>：單一職責、開放封閉、DRY、內聚、耦合與兩種對應的壞味道。</li>
</ul>
<h2 id="backlog">Backlog</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>類型</th>
          <th>前置條件</th>
          <th>規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>〈從修改紀錄畫模組邊界〉補第二個語言的範例：目前範例只有 Python 寫成的 commit 守衛；Go 已有 go/07〈7.5 以 domain 重新整理 package〉承接 type alias 過渡與測試保護搬移，缺的是以修改紀錄歸類為起點的 Go 範例</td>
          <td>主章</td>
          <td>有第二個語言的實例</td>
          <td>中</td>
      </tr>
      <tr>
          <td>聚焦入口候選：rebase 撞到別人拆檔留下的衝突（兩種形態與改名目標蓋掉匯入名字的情形），目前埋在〈從修改紀錄畫模組邊界〉的〈拆分的時機與等待中的需求〉</td>
          <td>案例</td>
          <td>有實際事件再寫成 work-log</td>
          <td>小</td>
      </tr>
      <tr>
          <td>Principle of Least Astonishment（最小意外原則）知識卡：python/00〈認知負擔〉的〈最小意外原則〉一節是本模組範圍內的原則，抽出定義與來源，Python 例子留原處</td>
          <td>知識卡</td>
          <td>先查證來源</td>
          <td>小</td>
      </tr>
  </tbody>
</table>
<h3 id="範圍決定">範圍決定</h3>
<p>2026-10-19 評估過把 DDD、UX Design、Testing 移進本模組，三者都維持獨立、與本模組並列：</p>
<ul>
<li><strong>DDD</strong>：理解的對象是領域模型，自有詞彙與案例庫；它引用本模組的單一職責與開放封閉原則，關係是引用而不是隸屬。</li>
<li><strong>UX Design</strong>：理解的對象是使用者與畫面，與程式碼結構沒有交集。</li>
<li><strong>Testing</strong>：理解的對象是驗證怎麼分層，涵蓋可觀測性、UI 自動化與 agent 產出程式碼的驗證，大半與程式碼結構無關。</li>
</ul>
<p>本模組的內容改由抽取補齊：語言模組裡講語言無關原則的段落，把定義與判斷標準抽成本模組的卡，語言寫法留在原處並連過來。DDD 的 Dependency Injection 與 Test Seam 卡評估後留在 DDD：兩張的引用全部在 DDD 內部，內容以 port、adapter、composition root 定義，搬出會切斷那組卡的互連。</p>
<p>重新評估的條件：DDD 的章節開始處理與領域模型無關的模組設計，或讀者路線固定成先讀本模組再讀 DDD 的一條階梯。</p>
]]></content:encoded></item><item><title>Divergent Change（發散式變更）</title><link>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/</guid><description>&lt;p>Divergent Change（發散式變更，其他中文資料常寫作「發散式變化」）是 Martin Fowler 在《Refactoring》列出的程式碼壞味道：一個模組因為不同的理由、以不同的方式反覆被修改。它是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）&lt;/a> 被違反時在修改紀錄上留下的形態——模組有幾個改變的理由，就會被幾種不相干的需求輪流修改。與它方向相反的壞味道是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Divergent Change 與 Shotgun Surgery 描述的是同一組對應關係的兩種失衡：改變的理由與模組之間應該一對一。同一個理由的好幾個需求都改同一個模組是正常的&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">內聚&lt;/a>；屬於不同理由的需求改同一個模組是 Divergent Change，同一個理由的一個需求要改好幾個模組是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery&lt;/a>。&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則&lt;/a>的兩句指示各自對應一種：「把因不同理由改變的程式碼分開」修的是 Divergent Change，「把因同一個理由改變的程式碼放在一起」修的是 Shotgun Surgery。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>Fowler 給的辨識方式是：修改某個模組時，心裡想的是「加一種資料庫要改這三個函式，加一種金融商品要改那四個函式」——兩種變更各自集中在模組裡的幾個函式，卻共用同一個模組。可以觀察到的訊號有三種：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>修改紀錄&lt;/strong>：同一個檔案在一段時間內被好幾個不相干的需求修改。以需求編號統計每個檔案被幾個不同需求改過，再逐個 commit 歸類，做法在&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界&lt;/a>。&lt;/li>
&lt;li>&lt;strong>並行開發的等待&lt;/strong>：處理不相干需求的人反覆在同一個檔案上解合併衝突、或等對方合併之後重跑測試。&lt;/li>
&lt;li>&lt;strong>檔案內的分群&lt;/strong>：模組裡的函式可以分成幾群，每一群只被某一類需求修改，群與群之間很少一起改。&lt;/li>
&lt;/ul>
&lt;p>站內偵測規則（以方法分組數判斷）與 Extract Class 的操作範例在 &lt;a href="https://tarrragon.github.io/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>修正 Divergent Change 的方向是依改變的理由把模組切開：先找出每一類需求各自會改的那一群函式與資料，再用 Extract Class（拆出類別）或把模組改成套件、每一群移到一個子模組。切開之前先用測試鎖住現有行為，切開之後確認同一類需求的變更只需要改一個新模組；若同一類需求要同時改好幾個新模組，代表切過頭，產生了 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Divergent Change（發散式變更，其他中文資料常寫作「發散式變化」）是 Martin Fowler 在《Refactoring》列出的程式碼壞味道：一個模組因為不同的理由、以不同的方式反覆被修改。它是 <a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）</a> 被違反時在修改紀錄上留下的形態——模組有幾個改變的理由，就會被幾種不相干的需求輪流修改。與它方向相反的壞味道是 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>Divergent Change 與 Shotgun Surgery 描述的是同一組對應關係的兩種失衡：改變的理由與模組之間應該一對一。同一個理由的好幾個需求都改同一個模組是正常的<a href="/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">內聚</a>；屬於不同理由的需求改同一個模組是 Divergent Change，同一個理由的一個需求要改好幾個模組是 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery</a>。<a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則</a>的兩句指示各自對應一種：「把因不同理由改變的程式碼分開」修的是 Divergent Change，「把因同一個理由改變的程式碼放在一起」修的是 Shotgun Surgery。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>Fowler 給的辨識方式是：修改某個模組時，心裡想的是「加一種資料庫要改這三個函式，加一種金融商品要改那四個函式」——兩種變更各自集中在模組裡的幾個函式，卻共用同一個模組。可以觀察到的訊號有三種：</p>
<ul>
<li><strong>修改紀錄</strong>：同一個檔案在一段時間內被好幾個不相干的需求修改。以需求編號統計每個檔案被幾個不同需求改過，再逐個 commit 歸類，做法在<a href="/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界</a>。</li>
<li><strong>並行開發的等待</strong>：處理不相干需求的人反覆在同一個檔案上解合併衝突、或等對方合併之後重跑測試。</li>
<li><strong>檔案內的分群</strong>：模組裡的函式可以分成幾群，每一群只被某一類需求修改，群與群之間很少一起改。</li>
</ul>
<p>站內偵測規則（以方法分組數判斷）與 Extract Class 的操作範例在 <a href="/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>修正 Divergent Change 的方向是依改變的理由把模組切開：先找出每一類需求各自會改的那一群函式與資料，再用 Extract Class（拆出類別）或把模組改成套件、每一群移到一個子模組。切開之前先用測試鎖住現有行為，切開之後確認同一類需求的變更只需要改一個新模組；若同一類需求要同時改好幾個新模組，代表切過頭，產生了 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery</a>。</p>
]]></content:encoded></item><item><title>Shotgun Surgery（散彈式修改）</title><link>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/</guid><description>&lt;p>Shotgun Surgery（散彈式修改，其他中文資料常寫作「霰彈式修改」或「散彈槍手術」）是 Martin Fowler 在《Refactoring》列出的程式碼壞味道：每做一種變更，都要在很多個模組裡各做一點小修改。它是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）&lt;/a> 的反方向——那一種是多個不相干的需求改同一個模組，這一種是一個需求要改多個模組。依 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）&lt;/a> 的說法，這是把因同一個理由改變的程式碼拆散了。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則&lt;/a>有兩句方向相反的指示：把因不同理由改變的程式碼分開，把因同一個理由改變的程式碼放在一起。只照前一句切，最常出現的結果就是 Shotgun Surgery——切的人看到「做了好幾件事」就切開，沒有檢查那幾件事是不是總是一起改。它與 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change&lt;/a> 都是改變的理由與模組沒有一對一對應的情形，修正方向相反，所以拆檔之後要兩個方向都檢查。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>Fowler 指出這種壞味道的主要風險是漏改：變更散在很多地方時，很難全部找到，漏掉一處就留下不一致。常見的形態有三種：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>依行數拆檔&lt;/strong>：把一個 900 行、只屬於同一個需求連續演進的解析器切成三份，切的時候不看修改落在哪裡，之後同一個主題的修改常常要同時改好幾個檔。行數判斷與不同需求數的判斷在這種檔案上給出相反的結論，對照在&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界&lt;/a>的〈行數與不同需求數的量測對象〉。&lt;/li>
&lt;li>&lt;strong>新增一個欄位要改每一層&lt;/strong>：欄位定義、資料存取、序列化、畫面各在一個檔案，加一個欄位就要依序改過每一層。這種情形有時是分層架構刻意付的成本，判斷時看這幾層是不是總是一起改、而且由同一個理由驅動。&lt;/li>
&lt;li>&lt;strong>同一段規則複製在好幾處&lt;/strong>：規則改了，每一份複本都要找出來改。這與 DRY 處理的是同一件事，Python 的寫法在&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則&lt;/a>。&lt;/li>
&lt;/ul>
&lt;p>站內偵測規則與修正範例（Extract Interface、Introduce Facade）在 &lt;a href="https://tarrragon.github.io/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>修正 Shotgun Surgery 的方向是提高&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">內聚&lt;/a>，把總是一起改的函式與資料收回同一個模組：用 Move Function、Move Field 把散落的函式與資料移到一處，散開的那幾個模組若只剩空殼就用 Inline Class 併回去。判斷問句是：最近幾次同一類變更，各改了哪幾個檔案；每次都改同一組檔案，那一組就是應該合併的候選。合併之後回頭檢查另一個方向：新的模組是否因此開始被不相干的需求修改，是的話就併過頭了，變成 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Shotgun Surgery（散彈式修改，其他中文資料常寫作「霰彈式修改」或「散彈槍手術」）是 Martin Fowler 在《Refactoring》列出的程式碼壞味道：每做一種變更，都要在很多個模組裡各做一點小修改。它是 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）</a> 的反方向——那一種是多個不相干的需求改同一個模組，這一種是一個需求要改多個模組。依 <a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）</a> 的說法，這是把因同一個理由改變的程式碼拆散了。</p>
<h2 id="概念位置">概念位置</h2>
<p><a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則</a>有兩句方向相反的指示：把因不同理由改變的程式碼分開，把因同一個理由改變的程式碼放在一起。只照前一句切，最常出現的結果就是 Shotgun Surgery——切的人看到「做了好幾件事」就切開，沒有檢查那幾件事是不是總是一起改。它與 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change</a> 都是改變的理由與模組沒有一對一對應的情形，修正方向相反，所以拆檔之後要兩個方向都檢查。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>Fowler 指出這種壞味道的主要風險是漏改：變更散在很多地方時，很難全部找到，漏掉一處就留下不一致。常見的形態有三種：</p>
<ul>
<li><strong>依行數拆檔</strong>：把一個 900 行、只屬於同一個需求連續演進的解析器切成三份，切的時候不看修改落在哪裡，之後同一個主題的修改常常要同時改好幾個檔。行數判斷與不同需求數的判斷在這種檔案上給出相反的結論，對照在<a href="/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界</a>的〈行數與不同需求數的量測對象〉。</li>
<li><strong>新增一個欄位要改每一層</strong>：欄位定義、資料存取、序列化、畫面各在一個檔案，加一個欄位就要依序改過每一層。這種情形有時是分層架構刻意付的成本，判斷時看這幾層是不是總是一起改、而且由同一個理由驅動。</li>
<li><strong>同一段規則複製在好幾處</strong>：規則改了，每一份複本都要找出來改。這與 DRY 處理的是同一件事，Python 的寫法在<a href="/blog/programming/python/basic/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則</a>。</li>
</ul>
<p>站內偵測規則與修正範例（Extract Interface、Introduce Facade）在 <a href="/blog/record/code_smell/" data-link-title="Code Smell 檢查清單" data-link-desc="在這裡列出常見的 Code Smell 清單，提供 設計和派工做 review 的參考及早察覺問題">Code Smell 檢查清單</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>修正 Shotgun Surgery 的方向是提高<a href="/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">內聚</a>，把總是一起改的函式與資料收回同一個模組：用 Move Function、Move Field 把散落的函式與資料移到一處，散開的那幾個模組若只剩空殼就用 Inline Class 併回去。判斷問句是：最近幾次同一類變更，各改了哪幾個檔案；每次都改同一組檔案，那一組就是應該合併的候選。合併之後回頭檢查另一個方向：新的模組是否因此開始被不相干的需求修改，是的話就併過頭了，變成 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change</a>。</p>
]]></content:encoded></item><item><title>Open-Closed Principle（開放封閉原則）</title><link>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/open-closed-principle/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/open-closed-principle/</guid><description>&lt;p>開放封閉原則（Open-Closed Principle，OCP）的定義是：軟體實體（類別、模組、函式）應該對擴充開放、對修改封閉——需求改變時，靠新增程式碼擴充模組的行為，不改動已經能運作的程式碼。封閉有範圍：模組只對設計者選定的那幾類變更封閉，下一節說明為什麼不可能對所有變更封閉。這條原則由 Bertrand Meyer 在 1988 年的《Object-Oriented Software Construction》提出。它與 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）&lt;/a> 常一起使用：單一職責原則決定模組的邊界畫在哪裡，開放封閉原則決定其中哪一類變更要做成擴充點。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>對擴充開放與對修改封閉看起來互相矛盾：要讓模組有新的行為，通常就得改它。Robert C. Martin 在 1996 年的〈The Open-Closed Principle〉裡給的解法是「抽象是關鍵」：使用端只依賴一個固定的抽象（例如一個介面或抽象類別），新的行為由新的實作提供。使用端依賴的抽象不變，所以使用端不必修改；實作可以一直增加，所以行為可以擴充。用單一職責原則的語言說，單一職責原則畫出模組的邊界，抽象是這條邊界上對外公開的部分，各個實作留在邊界內，其他模組不需要知道。&lt;/p>
&lt;p>同一篇文章另有一段〈Strategic Closure〉，是這條原則的使用條件：沒有一個有規模的程式能百分之百封閉，不論模組多「封閉」，總有某一類變更是它擋不住的。Martin 的例子是一個依序畫出所有圖形的函式，它對「新增一種圖形」封閉，對「先畫完所有圓形再畫方形」這種順序規則的變更就不封閉。所以封閉要有選擇：設計者要挑出要封閉的那幾類變更，Martin 給的依據是對使用者與產業的了解，判斷哪幾類變更最可能發生。&lt;/p>
&lt;p>「封閉要有選擇」這個條件讓開放封閉原則與 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）&lt;/a> 接在一起：修改紀錄是「哪幾類變更最可能發生」最直接的證據。一類變更在紀錄裡已經反覆發生、每次都要改同一個模組，就是 Divergent Change 的形態，也是值得封閉的候選。替紀錄裡從沒出現過的那一類變更預先做擴充點，現在就要多寫並維護一層抽象，換到的是一個未必發生的變更變便宜；這是 YAGNI 處理的那種取捨，它在什麼條件下成立見 &lt;a href="https://tarrragon.github.io/blog/record/yagni-boundary-three-axes/" data-link-title="YAGNI 的真實適用條件：成本對稱性、可逆性、領域先驗劃出邊界" data-link-desc="YAGNI 不是「永遠選最受限選項」、是「不為未來投入額外成本」的原則。用成本對稱性、可逆性、領域先驗三軸框架釐清「該選通用 default」與「該避免過度設計」的邊界、並補上 review checklist、架構規範、領域先驗清單三層制度補強。">YAGNI 的真實適用條件&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要開放封閉原則的訊號，是同一類變更反覆要求修改同一段程式碼：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>依類型分支的函式&lt;/strong>：一個 &lt;code>if&lt;/code> 或 &lt;code>switch&lt;/code> 依格式（PDF、Excel、HTML）或類型分派，每新增一種格式就要在這段程式碼裡多加一個分支，並重新確認沒有改壞其他分支。改成每種格式各一個實作、使用端只依賴共同的抽象之後，新增格式變成新增一個實作。&lt;/li>
&lt;li>&lt;strong>集中登記檔&lt;/strong>：新增一種實作之後，還要回到一份集中的清單登記它。這份清單仍然對「新增實作」不封閉，並行的需求也會在它的尾端同時追加而衝突（處置見&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界&lt;/a>〈熱點的分類與處理方式〉）；用自動登記（依命名規則或目錄掃描）可以讓這份清單也不必再改，代價是登記關係不再寫在一個看得到的地方。&lt;/li>
&lt;li>&lt;strong>共用的全域狀態&lt;/strong>：Martin 在同一篇文章裡指出，依賴全域變數的模組，對任何可能改寫那個變數的模組都無法封閉；依賴那個全域變數的模組很少、或那個全域變數不可能被不一致地使用時，損害才小。&lt;/li>
&lt;/ul>
&lt;p>各語言的寫法不同：Python 用抽象基類或 Protocol，Go 用 interface，Dart 用 abstract class。Python 的例子在&lt;a href="https://tarrragon.github.io/blog/programming/python/basic/00-philosophy/open-closed-principle/" data-link-title="開放封閉原則與認知負擔" data-link-desc="從認知負擔的視角重新理解 SOLID 原則">開放封閉原則與認知負擔&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>套用開放封閉原則的設計責任是選對要封閉的變更。判斷問句是：這個擴充點要擋的那一類變更，在過去的修改紀錄裡發生過幾次，三種結果各有處置：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>已經反覆發生&lt;/strong>（紀錄裡有兩次以上、每次都改同一處）：做擴充點。&lt;/li>
&lt;li>&lt;strong>只發生過一次&lt;/strong>：看對使用者與產業的了解判斷它會不會再發生——例如報表格式由客戶要求、而客戶還會再要新格式——判斷會再發生才做；判斷不出來就先照原樣改，等它第二次發生。&lt;/li>
&lt;li>&lt;strong>從沒發生過&lt;/strong>：先不做。&lt;/li>
&lt;/ul>
&lt;p>擴充點做出來之後，抽象本身成了很多實作共同依賴的對象，修改抽象的成本隨實作數增加，所以抽象只放那一類變更裡每個實作都需要的操作，不放眼前某一個實作才有的細節。&lt;/p></description><content:encoded><![CDATA[<p>開放封閉原則（Open-Closed Principle，OCP）的定義是：軟體實體（類別、模組、函式）應該對擴充開放、對修改封閉——需求改變時，靠新增程式碼擴充模組的行為，不改動已經能運作的程式碼。封閉有範圍：模組只對設計者選定的那幾類變更封閉，下一節說明為什麼不可能對所有變更封閉。這條原則由 Bertrand Meyer 在 1988 年的《Object-Oriented Software Construction》提出。它與 <a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）</a> 常一起使用：單一職責原則決定模組的邊界畫在哪裡，開放封閉原則決定其中哪一類變更要做成擴充點。</p>
<h2 id="概念位置">概念位置</h2>
<p>對擴充開放與對修改封閉看起來互相矛盾：要讓模組有新的行為，通常就得改它。Robert C. Martin 在 1996 年的〈The Open-Closed Principle〉裡給的解法是「抽象是關鍵」：使用端只依賴一個固定的抽象（例如一個介面或抽象類別），新的行為由新的實作提供。使用端依賴的抽象不變，所以使用端不必修改；實作可以一直增加，所以行為可以擴充。用單一職責原則的語言說，單一職責原則畫出模組的邊界，抽象是這條邊界上對外公開的部分，各個實作留在邊界內，其他模組不需要知道。</p>
<p>同一篇文章另有一段〈Strategic Closure〉，是這條原則的使用條件：沒有一個有規模的程式能百分之百封閉，不論模組多「封閉」，總有某一類變更是它擋不住的。Martin 的例子是一個依序畫出所有圖形的函式，它對「新增一種圖形」封閉，對「先畫完所有圓形再畫方形」這種順序規則的變更就不封閉。所以封閉要有選擇：設計者要挑出要封閉的那幾類變更，Martin 給的依據是對使用者與產業的了解，判斷哪幾類變更最可能發生。</p>
<p>「封閉要有選擇」這個條件讓開放封閉原則與 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）</a> 接在一起：修改紀錄是「哪幾類變更最可能發生」最直接的證據。一類變更在紀錄裡已經反覆發生、每次都要改同一個模組，就是 Divergent Change 的形態，也是值得封閉的候選。替紀錄裡從沒出現過的那一類變更預先做擴充點，現在就要多寫並維護一層抽象，換到的是一個未必發生的變更變便宜；這是 YAGNI 處理的那種取捨，它在什麼條件下成立見 <a href="/blog/record/yagni-boundary-three-axes/" data-link-title="YAGNI 的真實適用條件：成本對稱性、可逆性、領域先驗劃出邊界" data-link-desc="YAGNI 不是「永遠選最受限選項」、是「不為未來投入額外成本」的原則。用成本對稱性、可逆性、領域先驗三軸框架釐清「該選通用 default」與「該避免過度設計」的邊界、並補上 review checklist、架構規範、領域先驗清單三層制度補強。">YAGNI 的真實適用條件</a>。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要開放封閉原則的訊號，是同一類變更反覆要求修改同一段程式碼：</p>
<ul>
<li><strong>依類型分支的函式</strong>：一個 <code>if</code> 或 <code>switch</code> 依格式（PDF、Excel、HTML）或類型分派，每新增一種格式就要在這段程式碼裡多加一個分支，並重新確認沒有改壞其他分支。改成每種格式各一個實作、使用端只依賴共同的抽象之後，新增格式變成新增一個實作。</li>
<li><strong>集中登記檔</strong>：新增一種實作之後，還要回到一份集中的清單登記它。這份清單仍然對「新增實作」不封閉，並行的需求也會在它的尾端同時追加而衝突（處置見<a href="/blog/software-design/design-principles/module-boundary-from-change-history/" data-link-title="從修改紀錄畫模組邊界：修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證" data-link-desc="用 git 歷史統計每個檔案被幾個不同的需求修改、把結果當成單一職責的間接量測，修改熱點的分類與處理，逐個 commit 把需求歸到職責以決定切法，拆分時與語言無關的做法（特徵測試、搬移與修改分開提交、單向依賴），以及拆分合併的時機與拆完之後的驗證；量測只讀版本歷史，與程式語言無關">從修改紀錄畫模組邊界</a>〈熱點的分類與處理方式〉）；用自動登記（依命名規則或目錄掃描）可以讓這份清單也不必再改，代價是登記關係不再寫在一個看得到的地方。</li>
<li><strong>共用的全域狀態</strong>：Martin 在同一篇文章裡指出，依賴全域變數的模組，對任何可能改寫那個變數的模組都無法封閉；依賴那個全域變數的模組很少、或那個全域變數不可能被不一致地使用時，損害才小。</li>
</ul>
<p>各語言的寫法不同：Python 用抽象基類或 Protocol，Go 用 interface，Dart 用 abstract class。Python 的例子在<a href="/blog/programming/python/basic/00-philosophy/open-closed-principle/" data-link-title="開放封閉原則與認知負擔" data-link-desc="從認知負擔的視角重新理解 SOLID 原則">開放封閉原則與認知負擔</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>套用開放封閉原則的設計責任是選對要封閉的變更。判斷問句是：這個擴充點要擋的那一類變更，在過去的修改紀錄裡發生過幾次，三種結果各有處置：</p>
<ul>
<li><strong>已經反覆發生</strong>（紀錄裡有兩次以上、每次都改同一處）：做擴充點。</li>
<li><strong>只發生過一次</strong>：看對使用者與產業的了解判斷它會不會再發生——例如報表格式由客戶要求、而客戶還會再要新格式——判斷會再發生才做；判斷不出來就先照原樣改，等它第二次發生。</li>
<li><strong>從沒發生過</strong>：先不做。</li>
</ul>
<p>擴充點做出來之後，抽象本身成了很多實作共同依賴的對象，修改抽象的成本隨實作數增加，所以抽象只放那一類變更裡每個實作都需要的操作，不放眼前某一個實作才有的細節。</p>
]]></content:encoded></item><item><title>DRY（Don't Repeat Yourself）</title><link>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/dry-principle/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/dry-principle/</guid><description>&lt;p>DRY（Don&amp;rsquo;t Repeat Yourself）出自 Andy Hunt 與 Dave Thomas 1999 年的《The Pragmatic Programmer》：「Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.」系統裡的每一份知識——一條業務規則、一個資料格式、一個設定值——只能有一個權威的表示。違反 DRY 的後果是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）&lt;/a>：同一份知識的每一份複本都要找出來改，漏掉一處就不一致。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>DRY 管的對象是知識，不是程式碼的字面。2019 年的第二版特別說明，很多讀者把 DRY 讀成「不要複製貼上程式碼」，而 Hunt 與 Thomas 指的是知識與意圖的重複：同一件事寫在兩個地方，即使兩處的寫法完全不同（一段程式碼與描述它的文件、一份資料庫綱要與對應的資料結構），也是重複。&lt;/p>
&lt;p>反方向也成立：兩段字面相同的程式碼，如果因為不同的理由改變，就不是同一份知識。&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）&lt;/a>的判斷在這裡接手：兩段程式碼要求的改動屬於不同種類（一段跟著某個職能的規則變、另一段跟著另一個職能的規則變），現在相同只是碰巧，合併成一個共用函式之後，為其中一個需求做的修改會改到另一個需求依賴的行為，那個共用函式就成了 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）&lt;/a>。Martin 在《Clean Architecture》的單一職責那一章把這種字面相同、理由不同的情形稱為 accidental duplication（意外的重複）。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要套用 DRY 的訊號，是同一份知識的變更要同時改好幾處：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>同一條規則複製在好幾個檔案&lt;/strong>：例如四個腳本各自寫了一份相同的 git 指令包裝函式，要改一個 git 指令選項就得改四次。四份都因同一個理由改變（呼叫 git 的方式），是同一份知識，所以抽成一個共用函式、四處改成呼叫它。Python 專案的做法與遷移順序在 &lt;a href="https://tarrragon.github.io/blog/programming/python/basic/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫&lt;/a>。&lt;/li>
&lt;li>&lt;strong>同一個常數寫在好幾處&lt;/strong>：例如逾時秒數同時寫在程式碼、設定檔與文件裡。保留一個權威來源（通常是程式執行時實際讀取的那一處，例如設定檔），程式碼讀它、文件連到它。&lt;/li>
&lt;li>&lt;strong>程式碼與文件描述同一件事&lt;/strong>：文件抄了一份參數清單，程式碼改了而文件沒改。讓文件從程式碼產生，或文件只寫程式碼表達不了的部分。&lt;/li>
&lt;/ul>
&lt;p>判斷兩段相似的程式碼要不要合併時，Martin Fowler 在《Refactoring》引述 Don Roberts 的 Rule of Three：第一次照寫，第二次寫出相似的程式碼時先容忍重複，第三次才抽出共用的部分。Rule of Three 決定的是什麼時候判斷，要不要合併仍由下一節的判斷問句決定：等到有三份相似的程式碼，才累積得出足夠的修改紀錄，看得出它們是不是一起改。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>套用 DRY 的設計責任是讓每一份知識只有一個權威來源，而合併之前先確認那幾段程式碼是同一份知識。判斷問句是：這幾處過去的修改，是否每次都一起改、而且由同一個需求驅動；兩個條件都成立才是同一份知識，合併；任一個不成立，字面相同也分開保留。合併之後，函式裡開始需要一個參數來分支成兩套行為，代表那兩套行為各自由不同的理由改變，是合併錯了的訊號。&lt;/p></description><content:encoded><![CDATA[<p>DRY（Don&rsquo;t Repeat Yourself）出自 Andy Hunt 與 Dave Thomas 1999 年的《The Pragmatic Programmer》：「Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.」系統裡的每一份知識——一條業務規則、一個資料格式、一個設定值——只能有一個權威的表示。違反 DRY 的後果是 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）</a>：同一份知識的每一份複本都要找出來改，漏掉一處就不一致。</p>
<h2 id="概念位置">概念位置</h2>
<p>DRY 管的對象是知識，不是程式碼的字面。2019 年的第二版特別說明，很多讀者把 DRY 讀成「不要複製貼上程式碼」，而 Hunt 與 Thomas 指的是知識與意圖的重複：同一件事寫在兩個地方，即使兩處的寫法完全不同（一段程式碼與描述它的文件、一份資料庫綱要與對應的資料結構），也是重複。</p>
<p>反方向也成立：兩段字面相同的程式碼，如果因為不同的理由改變，就不是同一份知識。<a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）</a>的判斷在這裡接手：兩段程式碼要求的改動屬於不同種類（一段跟著某個職能的規則變、另一段跟著另一個職能的規則變），現在相同只是碰巧，合併成一個共用函式之後，為其中一個需求做的修改會改到另一個需求依賴的行為，那個共用函式就成了 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）</a>。Martin 在《Clean Architecture》的單一職責那一章把這種字面相同、理由不同的情形稱為 accidental duplication（意外的重複）。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要套用 DRY 的訊號，是同一份知識的變更要同時改好幾處：</p>
<ul>
<li><strong>同一條規則複製在好幾個檔案</strong>：例如四個腳本各自寫了一份相同的 git 指令包裝函式，要改一個 git 指令選項就得改四次。四份都因同一個理由改變（呼叫 git 的方式），是同一份知識，所以抽成一個共用函式、四處改成呼叫它。Python 專案的做法與遷移順序在 <a href="/blog/programming/python/basic/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫</a>。</li>
<li><strong>同一個常數寫在好幾處</strong>：例如逾時秒數同時寫在程式碼、設定檔與文件裡。保留一個權威來源（通常是程式執行時實際讀取的那一處，例如設定檔），程式碼讀它、文件連到它。</li>
<li><strong>程式碼與文件描述同一件事</strong>：文件抄了一份參數清單，程式碼改了而文件沒改。讓文件從程式碼產生，或文件只寫程式碼表達不了的部分。</li>
</ul>
<p>判斷兩段相似的程式碼要不要合併時，Martin Fowler 在《Refactoring》引述 Don Roberts 的 Rule of Three：第一次照寫，第二次寫出相似的程式碼時先容忍重複，第三次才抽出共用的部分。Rule of Three 決定的是什麼時候判斷，要不要合併仍由下一節的判斷問句決定：等到有三份相似的程式碼，才累積得出足夠的修改紀錄，看得出它們是不是一起改。</p>
<h2 id="設計責任">設計責任</h2>
<p>套用 DRY 的設計責任是讓每一份知識只有一個權威來源，而合併之前先確認那幾段程式碼是同一份知識。判斷問句是：這幾處過去的修改，是否每次都一起改、而且由同一個需求驅動；兩個條件都成立才是同一份知識，合併；任一個不成立，字面相同也分開保留。合併之後，函式裡開始需要一個參數來分支成兩套行為，代表那兩套行為各自由不同的理由改變，是合併錯了的訊號。</p>
]]></content:encoded></item><item><title>Cohesion（內聚）</title><link>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/cohesion/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/cohesion/</guid><description>&lt;p>Cohesion（內聚）是一個模組內部的元素彼此相關、應該放在一起的程度。這裡的模組泛指任何有對外介面的程式單位：函式、類別、檔案或套件。這個詞由 Larry Constantine 在 1960 年代後期的結構化設計（structured design）裡提出，1974 年 Stevens、Myers 與 Constantine 在 IBM Systems Journal 的〈Structured Design〉一文把它與 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/coupling/" data-link-title="Coupling（耦合）" data-link-desc="改一個模組總是連帶要改另一個、或要判斷兩個模組之間的依賴可不可以接受時：耦合是模組之間互相依賴的程度，依兩個模組共享什麼分成由強到弱的幾種">Coupling（耦合）&lt;/a> 一起寫成模組設計的兩個衡量標準：內聚量模組內部，耦合量模組之間。內聚與 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）&lt;/a> 衡量的是同一件事——哪些元素該放在一起——而判斷依據不同：內聚的傳統定義看元素在功能上是否相關，單一職責原則看元素是否因為同一個理由改變。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>內聚依「這些元素為什麼放在一起」分成幾種。由弱到強的排序依據是放在一起的理由與模組那件工作的關係有多緊：偶然內聚的元素之間完全無關，功能內聚的每個元素都屬於同一件工作。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>偶然內聚（coincidental）&lt;/strong>：放在一起沒有任何理由，例如一個什麼都放的 &lt;code>utils&lt;/code> 模組。&lt;/li>
&lt;li>&lt;strong>邏輯內聚（logical）&lt;/strong>：元素屬於同一個類別，例如所有輸入處理都放在一起，而每一種輸入的處理彼此無關。&lt;/li>
&lt;li>&lt;strong>時間內聚（temporal）&lt;/strong>：元素在同一個時間點執行，例如程式啟動時要做的所有初始化。&lt;/li>
&lt;li>&lt;strong>程序內聚（procedural）&lt;/strong>：元素總是依某個順序執行。&lt;/li>
&lt;li>&lt;strong>通訊內聚（communicational）&lt;/strong>：元素操作同一份資料。&lt;/li>
&lt;li>&lt;strong>循序內聚（sequential）&lt;/strong>：一個元素的輸出是下一個元素的輸入。&lt;/li>
&lt;li>&lt;strong>功能內聚（functional）&lt;/strong>：所有元素一起完成一件定義明確的工作。&lt;/li>
&lt;/ul>
&lt;p>Robert C. Martin 在《Agile Software Development》的單一職責那一章寫明，這條原則在 Tom DeMarco 與 Meilir Page-Jones 的著作裡已有描述，「They called it cohesion」，他們把內聚定義成模組元素在功能上的相關程度；Martin 在那一章把內聚的意思稍微移動，改成和「讓模組改變的力量」相關。兩種判斷用到的資訊不同：功能相關看程式碼在做什麼，&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則&lt;/a>看誰會要求它改變。兩種判斷可能給出不同結果：一個模組完成一件定義明確的工作，屬於功能內聚；如果那件工作的規則由兩個業務職能各管一半，它仍然有兩個改變的理由。決定要不要拆時以改變的理由為準，內聚的層級用來描述元素放在一起的理由屬於哪一種。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>低內聚的模組有幾個可以觀察到的訊號：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>名字說不出它做什麼&lt;/strong>：&lt;code>utils&lt;/code>、&lt;code>helpers&lt;/code>、&lt;code>common&lt;/code>、&lt;code>misc&lt;/code> 這類名字通常代表偶然內聚，裡面的函式彼此沒有關係，只是沒有更好的去處。&lt;/li>
&lt;li>&lt;strong>改一處要讀整個模組，而讀到的大部分與這次修改無關&lt;/strong>：模組裡混了幾群互不相關的元素。&lt;/li>
&lt;li>&lt;strong>修改紀錄裡同一個模組被不相干的需求輪流修改&lt;/strong>：這是內聚不足在版本歷史上的樣子，也就是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）&lt;/a>。&lt;/li>
&lt;/ul>
&lt;p>反方向的錯誤是把同一件工作拆到好幾個模組，每個模組單獨看都很小，而那件工作的每一次修改都要同時改它們，這是 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>提高內聚的設計責任是讓模組裡的每個元素都因為同一個理由放在這裡，理由指的是改變的理由：它會因為哪一種改動而改變。判斷問句是：逐一寫出模組裡每個元素會被哪一種改動要求修改（那一類改動由哪一個職能決定）；同一種改動的那一群留下，屬於別種改動的、或寫得出的原因只有「沒有別的地方放」的，移到它所屬的模組。移完之後回頭看 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/coupling/" data-link-title="Coupling（耦合）" data-link-desc="改一個模組總是連帶要改另一個、或要判斷兩個模組之間的依賴可不可以接受時：耦合是模組之間互相依賴的程度，依兩個模組共享什麼分成由強到弱的幾種">Coupling（耦合）&lt;/a>：移出去的元素如果仍然大量讀寫原模組的內部資料、而且每次修改都要和原模組一起改，那個元素與原模組本來就屬於同一個模組，移出去的判斷錯了。&lt;/p></description><content:encoded><![CDATA[<p>Cohesion（內聚）是一個模組內部的元素彼此相關、應該放在一起的程度。這裡的模組泛指任何有對外介面的程式單位：函式、類別、檔案或套件。這個詞由 Larry Constantine 在 1960 年代後期的結構化設計（structured design）裡提出，1974 年 Stevens、Myers 與 Constantine 在 IBM Systems Journal 的〈Structured Design〉一文把它與 <a href="/blog/software-design/design-principles/knowledge-cards/coupling/" data-link-title="Coupling（耦合）" data-link-desc="改一個模組總是連帶要改另一個、或要判斷兩個模組之間的依賴可不可以接受時：耦合是模組之間互相依賴的程度，依兩個模組共享什麼分成由強到弱的幾種">Coupling（耦合）</a> 一起寫成模組設計的兩個衡量標準：內聚量模組內部，耦合量模組之間。內聚與 <a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">Single Responsibility Principle（單一職責原則）</a> 衡量的是同一件事——哪些元素該放在一起——而判斷依據不同：內聚的傳統定義看元素在功能上是否相關，單一職責原則看元素是否因為同一個理由改變。</p>
<h2 id="概念位置">概念位置</h2>
<p>內聚依「這些元素為什麼放在一起」分成幾種。由弱到強的排序依據是放在一起的理由與模組那件工作的關係有多緊：偶然內聚的元素之間完全無關，功能內聚的每個元素都屬於同一件工作。</p>
<ul>
<li><strong>偶然內聚（coincidental）</strong>：放在一起沒有任何理由，例如一個什麼都放的 <code>utils</code> 模組。</li>
<li><strong>邏輯內聚（logical）</strong>：元素屬於同一個類別，例如所有輸入處理都放在一起，而每一種輸入的處理彼此無關。</li>
<li><strong>時間內聚（temporal）</strong>：元素在同一個時間點執行，例如程式啟動時要做的所有初始化。</li>
<li><strong>程序內聚（procedural）</strong>：元素總是依某個順序執行。</li>
<li><strong>通訊內聚（communicational）</strong>：元素操作同一份資料。</li>
<li><strong>循序內聚（sequential）</strong>：一個元素的輸出是下一個元素的輸入。</li>
<li><strong>功能內聚（functional）</strong>：所有元素一起完成一件定義明確的工作。</li>
</ul>
<p>Robert C. Martin 在《Agile Software Development》的單一職責那一章寫明，這條原則在 Tom DeMarco 與 Meilir Page-Jones 的著作裡已有描述，「They called it cohesion」，他們把內聚定義成模組元素在功能上的相關程度；Martin 在那一章把內聚的意思稍微移動，改成和「讓模組改變的力量」相關。兩種判斷用到的資訊不同：功能相關看程式碼在做什麼，<a href="/blog/software-design/design-principles/knowledge-cards/single-responsibility-principle/" data-link-title="Single Responsibility Principle（單一職責原則）" data-link-desc="要判斷一個模組、類別或文件該不該拆，或讀到「一個類別只做一件事」這種說法時：單一職責的定義是改變的理由，理由以改動的種類計數，提出變更的人用來辨認種類">單一職責原則</a>看誰會要求它改變。兩種判斷可能給出不同結果：一個模組完成一件定義明確的工作，屬於功能內聚；如果那件工作的規則由兩個業務職能各管一半，它仍然有兩個改變的理由。決定要不要拆時以改變的理由為準，內聚的層級用來描述元素放在一起的理由屬於哪一種。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>低內聚的模組有幾個可以觀察到的訊號：</p>
<ul>
<li><strong>名字說不出它做什麼</strong>：<code>utils</code>、<code>helpers</code>、<code>common</code>、<code>misc</code> 這類名字通常代表偶然內聚，裡面的函式彼此沒有關係，只是沒有更好的去處。</li>
<li><strong>改一處要讀整個模組，而讀到的大部分與這次修改無關</strong>：模組裡混了幾群互不相關的元素。</li>
<li><strong>修改紀錄裡同一個模組被不相干的需求輪流修改</strong>：這是內聚不足在版本歷史上的樣子，也就是 <a href="/blog/software-design/design-principles/knowledge-cards/divergent-change/" data-link-title="Divergent Change（發散式變更）" data-link-desc="同一個模組總是因為不相干的需求被修改、或並行開發的人一直在同一個檔案上等待彼此時：這是違反單一職責原則在修改紀錄上的樣子">Divergent Change（發散式變更）</a>。</li>
</ul>
<p>反方向的錯誤是把同一件工作拆到好幾個模組，每個模組單獨看都很小，而那件工作的每一次修改都要同時改它們，這是 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>提高內聚的設計責任是讓模組裡的每個元素都因為同一個理由放在這裡，理由指的是改變的理由：它會因為哪一種改動而改變。判斷問句是：逐一寫出模組裡每個元素會被哪一種改動要求修改（那一類改動由哪一個職能決定）；同一種改動的那一群留下，屬於別種改動的、或寫得出的原因只有「沒有別的地方放」的，移到它所屬的模組。移完之後回頭看 <a href="/blog/software-design/design-principles/knowledge-cards/coupling/" data-link-title="Coupling（耦合）" data-link-desc="改一個模組總是連帶要改另一個、或要判斷兩個模組之間的依賴可不可以接受時：耦合是模組之間互相依賴的程度，依兩個模組共享什麼分成由強到弱的幾種">Coupling（耦合）</a>：移出去的元素如果仍然大量讀寫原模組的內部資料、而且每次修改都要和原模組一起改，那個元素與原模組本來就屬於同一個模組，移出去的判斷錯了。</p>
]]></content:encoded></item><item><title>Coupling（耦合）</title><link>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/coupling/</link><pubDate>Mon, 19 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/coupling/</guid><description>&lt;p>Coupling（耦合）是模組之間互相依賴的程度：一個模組改變時，另一個模組要跟著改的可能性與範圍。這裡的模組泛指任何有對外介面的程式單位：函式、類別、檔案或套件。這個詞與 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">Cohesion（內聚）&lt;/a> 一起由 Larry Constantine 在 1960 年代後期的結構化設計裡提出，1974 年 Stevens、Myers 與 Constantine 的〈Structured Design〉把它們寫成模組設計的兩個衡量標準：內聚量模組內部，耦合量模組之間。低耦合與高內聚常一起出現，原因是它們來自同一個切法：把會一起改的元素放進同一個模組，元素之間的讀寫就留在模組內部，模組之間要交換的資料與呼叫隨之變少。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>耦合依兩個模組共享的內容分成幾種，由強到弱是：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>內容耦合（content）&lt;/strong>：一個模組直接使用另一個模組內部的程式碼或資料，例如跳進對方的內部、改對方的私有欄位。&lt;/li>
&lt;li>&lt;strong>共用耦合（common）&lt;/strong>：好幾個模組存取同一份全域資料。&lt;/li>
&lt;li>&lt;strong>外部耦合（external）&lt;/strong>：模組共用一個外部規定的資料格式、通訊協定或裝置介面。&lt;/li>
&lt;li>&lt;strong>控制耦合（control）&lt;/strong>：一個模組傳一個旗標給另一個模組，由旗標決定接收的模組要走哪一條邏輯。&lt;/li>
&lt;li>&lt;strong>戳記耦合（stamp，其他中文資料也寫作「標記耦合」）&lt;/strong>：模組之間傳遞一整個複合資料結構，而接收的一方只用到其中一部分欄位。&lt;/li>
&lt;li>&lt;strong>資料耦合（data）&lt;/strong>：模組之間只透過參數傳遞需要的資料。&lt;/li>
&lt;/ul>
&lt;p>耦合越強，一個模組的修改越可能要求另一個模組跟著改。&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/open-closed-principle/" data-link-title="Open-Closed Principle（開放封閉原則）" data-link-desc="要在新增一種格式、一種類型或一種規則時不必改動既有程式碼，或要判斷一個擴充點值不值得先做時：模組對選定的那一類變更封閉，靠的是使用端只依賴一個固定的抽象">Open-Closed Principle（開放封閉原則）&lt;/a>用來降低耦合的手段是讓使用端只依賴一個固定的抽象：使用端與各個實作之間只剩下對那個抽象的依賴，實作的內部改變不會傳到使用端。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>耦合過強的訊號，是修改一個模組時發現要同時改另一個模組，而這次修改的需求只跟被修改的那個模組有關：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>改一個模組的內部欄位，另一個模組編譯失敗或行為改變&lt;/strong>：另一個模組直接讀寫了這些欄位，是內容耦合。&lt;/li>
&lt;li>&lt;strong>改一份全域設定，幾個看似無關的模組行為都變了&lt;/strong>：它們共用同一份全域資料，是共用耦合。Martin 對全域變數的說明在 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/open-closed-principle/" data-link-title="Open-Closed Principle（開放封閉原則）" data-link-desc="要在新增一種格式、一種類型或一種規則時不必改動既有程式碼，或要判斷一個擴充點值不值得先做時：模組對選定的那一類變更封閉，靠的是使用端只依賴一個固定的抽象">Open-Closed Principle&lt;/a> 卡片。&lt;/li>
&lt;li>&lt;strong>函式收一個 &lt;code>mode&lt;/code> 或 &lt;code>is_xxx&lt;/code> 參數，依它切換成完全不同的兩套行為&lt;/strong>：呼叫端在控制被呼叫端的流程，是控制耦合；這種函式通常也代表兩套行為各自有不同的改變理由，拆成兩個函式之後，呼叫端直接呼叫它要的那一個。&lt;/li>
&lt;li>&lt;strong>函式收一整個物件、只讀其中一兩個欄位&lt;/strong>：例如計算運費的函式收整個訂單物件，只讀了寄送地址。訂單物件的結構一改，運費函式就要重新確認，是戳記耦合；測試這個函式時也得先造出一整個訂單物件。改成只收寄送地址，就降成資料耦合。&lt;/li>
&lt;/ul>
&lt;p>兩個模組之間有耦合本身不是錯誤，模組要合作就一定要交換資料或呼叫對方。要確認的是交換的內容是否只有完成這次呼叫實際用到的資料。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>降低耦合的設計責任是讓模組之間只交換合作需要的資料，而且依賴的方向穩定。判斷問句是：改這個模組時，有哪些別的模組要跟著改，原因是什麼；原因是那些模組讀了這個模組的內部、共用了同一份可變資料、或用旗標控制這個模組的流程，就把那份依賴改成更弱的形式，依情形選一種：只傳它實際用到的資料（降成資料耦合），或改成依賴一個抽象（做法見開放封閉原則）。降低耦合之前先確認那兩個模組是否本來就該是同一個：兩者過去的修改幾乎每次都一起發生，或一方大量讀寫另一方的內部資料，拆成兩個模組只會讓每次修改都要改兩處，形成 &lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）&lt;/a>。這時把兩者合併成一個模組比降低它們之間的耦合更合適，合併之後它們放在一起的理由相同，內聚也隨之提高。&lt;/p></description><content:encoded><![CDATA[<p>Coupling（耦合）是模組之間互相依賴的程度：一個模組改變時，另一個模組要跟著改的可能性與範圍。這裡的模組泛指任何有對外介面的程式單位：函式、類別、檔案或套件。這個詞與 <a href="/blog/software-design/design-principles/knowledge-cards/cohesion/" data-link-title="Cohesion（內聚）" data-link-desc="要判斷一個模組裡的元素放在一起的理由夠不夠強、或讀到「高內聚、低耦合」想知道內聚量的是什麼時：內聚是模組內部元素彼此相關的程度，依放在一起的理由分成由弱到強的幾種">Cohesion（內聚）</a> 一起由 Larry Constantine 在 1960 年代後期的結構化設計裡提出，1974 年 Stevens、Myers 與 Constantine 的〈Structured Design〉把它們寫成模組設計的兩個衡量標準：內聚量模組內部，耦合量模組之間。低耦合與高內聚常一起出現，原因是它們來自同一個切法：把會一起改的元素放進同一個模組，元素之間的讀寫就留在模組內部，模組之間要交換的資料與呼叫隨之變少。</p>
<h2 id="概念位置">概念位置</h2>
<p>耦合依兩個模組共享的內容分成幾種，由強到弱是：</p>
<ul>
<li><strong>內容耦合（content）</strong>：一個模組直接使用另一個模組內部的程式碼或資料，例如跳進對方的內部、改對方的私有欄位。</li>
<li><strong>共用耦合（common）</strong>：好幾個模組存取同一份全域資料。</li>
<li><strong>外部耦合（external）</strong>：模組共用一個外部規定的資料格式、通訊協定或裝置介面。</li>
<li><strong>控制耦合（control）</strong>：一個模組傳一個旗標給另一個模組，由旗標決定接收的模組要走哪一條邏輯。</li>
<li><strong>戳記耦合（stamp，其他中文資料也寫作「標記耦合」）</strong>：模組之間傳遞一整個複合資料結構，而接收的一方只用到其中一部分欄位。</li>
<li><strong>資料耦合（data）</strong>：模組之間只透過參數傳遞需要的資料。</li>
</ul>
<p>耦合越強，一個模組的修改越可能要求另一個模組跟著改。<a href="/blog/software-design/design-principles/knowledge-cards/open-closed-principle/" data-link-title="Open-Closed Principle（開放封閉原則）" data-link-desc="要在新增一種格式、一種類型或一種規則時不必改動既有程式碼，或要判斷一個擴充點值不值得先做時：模組對選定的那一類變更封閉，靠的是使用端只依賴一個固定的抽象">Open-Closed Principle（開放封閉原則）</a>用來降低耦合的手段是讓使用端只依賴一個固定的抽象：使用端與各個實作之間只剩下對那個抽象的依賴，實作的內部改變不會傳到使用端。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>耦合過強的訊號，是修改一個模組時發現要同時改另一個模組，而這次修改的需求只跟被修改的那個模組有關：</p>
<ul>
<li><strong>改一個模組的內部欄位，另一個模組編譯失敗或行為改變</strong>：另一個模組直接讀寫了這些欄位，是內容耦合。</li>
<li><strong>改一份全域設定，幾個看似無關的模組行為都變了</strong>：它們共用同一份全域資料，是共用耦合。Martin 對全域變數的說明在 <a href="/blog/software-design/design-principles/knowledge-cards/open-closed-principle/" data-link-title="Open-Closed Principle（開放封閉原則）" data-link-desc="要在新增一種格式、一種類型或一種規則時不必改動既有程式碼，或要判斷一個擴充點值不值得先做時：模組對選定的那一類變更封閉，靠的是使用端只依賴一個固定的抽象">Open-Closed Principle</a> 卡片。</li>
<li><strong>函式收一個 <code>mode</code> 或 <code>is_xxx</code> 參數，依它切換成完全不同的兩套行為</strong>：呼叫端在控制被呼叫端的流程，是控制耦合；這種函式通常也代表兩套行為各自有不同的改變理由，拆成兩個函式之後，呼叫端直接呼叫它要的那一個。</li>
<li><strong>函式收一整個物件、只讀其中一兩個欄位</strong>：例如計算運費的函式收整個訂單物件，只讀了寄送地址。訂單物件的結構一改，運費函式就要重新確認，是戳記耦合；測試這個函式時也得先造出一整個訂單物件。改成只收寄送地址，就降成資料耦合。</li>
</ul>
<p>兩個模組之間有耦合本身不是錯誤，模組要合作就一定要交換資料或呼叫對方。要確認的是交換的內容是否只有完成這次呼叫實際用到的資料。</p>
<h2 id="設計責任">設計責任</h2>
<p>降低耦合的設計責任是讓模組之間只交換合作需要的資料，而且依賴的方向穩定。判斷問句是：改這個模組時，有哪些別的模組要跟著改，原因是什麼；原因是那些模組讀了這個模組的內部、共用了同一份可變資料、或用旗標控制這個模組的流程，就把那份依賴改成更弱的形式，依情形選一種：只傳它實際用到的資料（降成資料耦合），或改成依賴一個抽象（做法見開放封閉原則）。降低耦合之前先確認那兩個模組是否本來就該是同一個：兩者過去的修改幾乎每次都一起發生，或一方大量讀寫另一方的內部資料，拆成兩個模組只會讓每次修改都要改兩處，形成 <a href="/blog/software-design/design-principles/knowledge-cards/shotgun-surgery/" data-link-title="Shotgun Surgery（散彈式修改）" data-link-desc="每做一個小變更都要同時改好幾個檔案、漏改一處就出錯，或依行數拆完檔之後同一個主題的修改散到好幾份時：這是同一個改變理由被拆散到多個模組的壞味道">Shotgun Surgery（散彈式修改）</a>。這時把兩者合併成一個模組比降低它們之間的耦合更合適，合併之後它們放在一起的理由相同，內聚也隨之提高。</p>
]]></content:encoded></item><item><title>軟體設計：設計原則與 DDD 領域驅動設計</title><link>https://tarrragon.github.io/blog/software-design/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/software-design/</guid><description>&lt;p>本分類收錄語言無關的軟體設計教學系列：設計原則與它們對應的壞味道（&lt;code>design-principles/&lt;/code>），以及領域模型的理論與判斷標準（&lt;code>ddd/&lt;/code>）。2026-11-03 這兩個系列從首頁的頂層分類搬進本分類，舊網址由各頁 frontmatter 的 &lt;code>aliases&lt;/code> 導向新位置。&lt;/p>
&lt;h2 id="子分類與排序">子分類與排序&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>weight&lt;/th>
 &lt;th>子分類&lt;/th>
 &lt;th>收錄&lt;/th>
 &lt;th>排在這個位置的理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/software-design/design-principles/" data-link-title="軟體設計原則" data-link-desc="語言無關的軟體設計原則：單一職責、模組邊界與它們對應的程式碼壞味道。各語言模組的實作與例子引用這裡的定義。">軟體設計原則&lt;/a>&lt;/td>
 &lt;td>單一職責、開放封閉、DRY、內聚與耦合，以及從修改紀錄畫模組邊界的量測方法&lt;/td>
 &lt;td>DDD 依單一職責切 domain 與 event，用到這裡的原則定義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/software-design/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判斷標準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>&lt;/td>
 &lt;td>資料袋與領域模型、entity 與 value object、不變式的強制層次、讀側與事件&lt;/td>
 &lt;td>建立在設計原則之上，範圍限定在領域模型&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="分類的範圍與相鄰分類">分類的範圍與相鄰分類&lt;/h2>
&lt;ul>
&lt;li>本分類的兩個系列都不依賴任何語言的實作；各語言的寫法放 &lt;a href="https://tarrragon.github.io/blog/programming/" data-link-title="程式語言：Python、Go、PHP、Flutter" data-link-desc="要學某一門程式語言的語言精神、標準函式庫、並發與工程實踐，或某個語言在伺服器與行動端的執行方式時，從這裡進入 Python、Go、PHP 與 Flutter 各自的教學系列">程式語言&lt;/a>，那裡的系列引用本分類的定義，路由是單向的。&lt;/li>
&lt;li>畫面狀態與互動設計放 &lt;a href="https://tarrragon.github.io/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design&lt;/a>，跟本分類共用的只有「設計」這個字。&lt;/li>
&lt;/ul>
&lt;h2 id="backlog">Backlog&lt;/h2>
&lt;p>目前沒有待辦項。&lt;/p></description><content:encoded><![CDATA[<p>本分類收錄語言無關的軟體設計教學系列：設計原則與它們對應的壞味道（<code>design-principles/</code>），以及領域模型的理論與判斷標準（<code>ddd/</code>）。2026-11-03 這兩個系列從首頁的頂層分類搬進本分類，舊網址由各頁 frontmatter 的 <code>aliases</code> 導向新位置。</p>
<h2 id="子分類與排序">子分類與排序</h2>
<table>
  <thead>
      <tr>
          <th>weight</th>
          <th>子分類</th>
          <th>收錄</th>
          <th>排在這個位置的理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><a href="/blog/software-design/design-principles/" data-link-title="軟體設計原則" data-link-desc="語言無關的軟體設計原則：單一職責、模組邊界與它們對應的程式碼壞味道。各語言模組的實作與例子引用這裡的定義。">軟體設計原則</a></td>
          <td>單一職責、開放封閉、DRY、內聚與耦合，以及從修改紀錄畫模組邊界的量測方法</td>
          <td>DDD 依單一職責切 domain 與 event，用到這裡的原則定義</td>
      </tr>
      <tr>
          <td>2</td>
          <td><a href="/blog/software-design/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判斷標準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a></td>
          <td>資料袋與領域模型、entity 與 value object、不變式的強制層次、讀側與事件</td>
          <td>建立在設計原則之上，範圍限定在領域模型</td>
      </tr>
  </tbody>
</table>
<h2 id="分類的範圍與相鄰分類">分類的範圍與相鄰分類</h2>
<ul>
<li>本分類的兩個系列都不依賴任何語言的實作；各語言的寫法放 <a href="/blog/programming/" data-link-title="程式語言：Python、Go、PHP、Flutter" data-link-desc="要學某一門程式語言的語言精神、標準函式庫、並發與工程實踐，或某個語言在伺服器與行動端的執行方式時，從這裡進入 Python、Go、PHP 與 Flutter 各自的教學系列">程式語言</a>，那裡的系列引用本分類的定義，路由是單向的。</li>
<li>畫面狀態與互動設計放 <a href="/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design</a>，跟本分類共用的只有「設計」這個字。</li>
</ul>
<h2 id="backlog">Backlog</h2>
<p>目前沒有待辦項。</p>
]]></content:encoded></item></channel></rss>