<?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>模組十一：API 設計與對外契約 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/11-api-design/</link><description>Recent content in 模組十一：API 設計與對外契約 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 03 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/11-api-design/index.xml" rel="self" type="application/rss+xml"/><item><title>11.1 API 作為服務邊界的責任</title><link>https://tarrragon.github.io/blog/backend/11-api-design/api-boundary-responsibility/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/api-boundary-responsibility/</guid><description>&lt;p>API 設計的核心責任是管理一組對外承諾的成本結構。服務內部的實作可以隨時重構、成本收在自己團隊；對外語意一旦被消費者依賴、每次變更都要付出跨組織的遷移協調成本。這個不對稱決定了 API 設計的所有下游議題：版本策略在決定承諾怎麼分期、相容紀律在決定承諾怎麼守、錯誤模型在決定失敗時承諾什麼。本章建立這個責任框架；框架本身是從 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">案例庫&lt;/a> 跨案例合成的推導、非單一案例原文。&lt;/p>
&lt;h2 id="承諾的範圍比-schema-大">承諾的範圍比 schema 大&lt;/h2>
&lt;p>對外承諾的範圍涵蓋所有消費者觀察得到、且會寫進程式碼依賴的介面性質 — 欄位與型別只是最顯眼的一層。錯誤碼、HTTP status 的使用慣例、欄位預設值、回應時序、分頁行為、ID 字串的長度與格式、甚至欄位在 JSON 裡的順序、都可能被某個消費者拿去依賴。這個現象有個常被引用的名字：Hyrum&amp;rsquo;s Law — 使用者夠多時、介面的每個可觀察行為都會被某人依賴、無論服務端是否承諾過。&lt;/p>
&lt;p>契約邊界的有效管理方式是把「消費者不可依賴的性質」明文寫出來、而非留給雙方猜。Stripe 的 upgrade 文件明列了一份相容變更清單、劃出服務端保留變更自由的軸（清單逐項與它在變更審查裡的用法、主寫在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6&lt;/a>；案例見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-stripe-named-major-releases/" data-link-title="11.C11 Stripe 現行方案：具名 major release 與相容變更清單" data-link-desc="同一家公司版本策略隨規模演進的第二個時間切片、附「什麼算 backwards-compatible」的明文清單">11.C11&lt;/a>）。這份清單的作用是雙向的：服務端保留這些軸上的變更自由、消費者拿到「寫 client 時什麼可以依賴」的明確指引。缺少這種明文劃界時、每個未宣告的性質都處於灰色地帶 — 變更的權責只能事後協商。&lt;/p>
&lt;h2 id="變更成本的兩種分配設計">變更成本的兩種分配設計&lt;/h2>
&lt;p>承諾確立之後、變更成本的分配有兩種本質設計：由服務端吸收、或攤給所有消費者。這是 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5 版本策略&lt;/a> 背後的經濟結構、在本章先建立判讀框架。&lt;/p>
&lt;p>服務端吸收的極端案例是 Stripe：服務端的轉換層一次吸收了約 100 個 backwards-incompatible 升級、同時維持與 2011 年以來每一版相容（轉換層機制的展開見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5&lt;/a>；案例見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-stripe-rolling-date-versions/" data-link-title="11.C10 Stripe：日期滾動版本與 version change module" data-link-desc="把相容性從路由層搬進轉換層、breaking change 成本由服務端一次吸收；date-based versioning 的原型案例">11.C10&lt;/a>）。這個設計把變更成本收在服務端的基礎設施投資裡、換來的是消費者幾乎永遠不被迫遷移。&lt;/p>
&lt;p>攤給消費者的設計則以「版本 + 遷移窗口」的形式出現：服務端宣告新版、給一段支援期、到期消費者必須完成遷移。成本較低、但把協調負擔外部化 — 適合消費者數量有限、或平台對生態有強制力的情境。兩種設計的選擇判準是消費者的數量、異質性、跟服務端對消費者的控制力：內部服務間的 API 可以直接協調升級、公開平台的十萬個 integration 只能靠承諾與窗口。&lt;/p>
&lt;h2 id="承諾違約的模式">承諾違約的模式&lt;/h2>
&lt;p>違約的傷害大小跟違約的模式有關、明確的失敗優於靜默的行為改變。Facebook Graph API v1.0 退場時、到期的 v1.0 請求被靜默改以 v2.0 語意處理、而非回傳明確錯誤 — v2.0 移除了 friends 資料等大範圍權限、未遷移的 app 不會炸在認證層、而是拿到形狀不同的資料默默壞掉（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-facebook-graph-v1-forced-upgrade/" data-link-title="11.C17 Facebook Graph API v1.0 退場：靜默語意切換（反例）" data-link-desc="反例：到期後把 v1.0 請求靜默當 v2.0 處理而非回明確錯誤、長尾 app 默默壞掉">11.C17&lt;/a>、反例）。對照組是明確錯誤：消費者立刻知道、監控立刻報警、修復路徑清楚。設計退場行為時的判準是「消費者發現問題的延遲」— 靜默切換把發現延遲拉到不可控、明確錯誤把它壓到第一個請求。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>消費者回報「你們沒改版但行為變了」&lt;/td>
 &lt;td>有未明文的介面性質被依賴、契約劃界不完整&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>團隊內對「這個改動要不要發版本」反覆爭論&lt;/td>
 &lt;td>缺「什麼算 breaking」的明文清單、先補清單再談流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>改一個欄位要跨三個團隊開會&lt;/td>
 &lt;td>變更成本已攤給消費者、評估是否值得投資服務端吸收層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費者停在舊版不動、新功能推不出去&lt;/td>
 &lt;td>遷移壓力設計缺位、看 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5&lt;/a> 的 deprecation 執行工具&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些訊號的共同根因是承諾範圍或成本分配沒有被當成明確的設計對象。修法從明文化開始：先有相容變更清單、再有版本與退場政策、最後才是工具。&lt;/p>
&lt;h2 id="邊界">邊界&lt;/h2>
&lt;p>本章的框架適用於「有外部消費者」的介面 — 外部指組織邊界或部署邊界之外、修正代價無法用一次 refactor 收掉的情境。同一個 repo 內、同一次部署一起上線的模組間介面、變更成本結構完全不同、用內部重構紀律處理即可、套用本章框架是過度設計。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>選風格與建模：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-style-selection/" data-link-title="11.2 風格選型總覽" data-link-desc="REST 式 HTTP&amp;#43;JSON、GraphQL、gRPC、tRPC、JSON-RPC、event 之間選哪個 — 用消費者形狀、演進成本、操作可及性三軸判讀">11.2 風格選型總覽&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3 資源建模與操作語意&lt;/a>&lt;/li>
&lt;li>承諾怎麼分期、什麼算 breaking：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5 版本策略與 deprecation&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律&lt;/a>&lt;/li>
&lt;li>契約的驗證手段（consumer-driven contract test）：&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/contract-testing/" data-link-title="6.10 Contract Testing 與 Schema 演進" data-link-desc="把跨服務 / API / event schema 的隱性期待變成可驗證契約，控制演進相容性">6.10 契約測試&lt;/a>&lt;/li>
&lt;li>名詞層：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/api-contract/" data-link-title="API Contract" data-link-desc="說明 request / response 邊界如何維持相容與可驗證">API Contract&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/contract/" data-link-title="Boundary Contract" data-link-desc="說明跨邊界約定如何維持相容與可驗證">Contract&lt;/a> 知識卡&lt;/li>
&lt;li>案例原文：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>API 設計的核心責任是管理一組對外承諾的成本結構。服務內部的實作可以隨時重構、成本收在自己團隊；對外語意一旦被消費者依賴、每次變更都要付出跨組織的遷移協調成本。這個不對稱決定了 API 設計的所有下游議題：版本策略在決定承諾怎麼分期、相容紀律在決定承諾怎麼守、錯誤模型在決定失敗時承諾什麼。本章建立這個責任框架；框架本身是從 <a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">案例庫</a> 跨案例合成的推導、非單一案例原文。</p>
<h2 id="承諾的範圍比-schema-大">承諾的範圍比 schema 大</h2>
<p>對外承諾的範圍涵蓋所有消費者觀察得到、且會寫進程式碼依賴的介面性質 — 欄位與型別只是最顯眼的一層。錯誤碼、HTTP status 的使用慣例、欄位預設值、回應時序、分頁行為、ID 字串的長度與格式、甚至欄位在 JSON 裡的順序、都可能被某個消費者拿去依賴。這個現象有個常被引用的名字：Hyrum&rsquo;s Law — 使用者夠多時、介面的每個可觀察行為都會被某人依賴、無論服務端是否承諾過。</p>
<p>契約邊界的有效管理方式是把「消費者不可依賴的性質」明文寫出來、而非留給雙方猜。Stripe 的 upgrade 文件明列了一份相容變更清單、劃出服務端保留變更自由的軸（清單逐項與它在變更審查裡的用法、主寫在 <a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6</a>；案例見 <a href="/blog/backend/11-api-design/cases/versioning-stripe-named-major-releases/" data-link-title="11.C11 Stripe 現行方案：具名 major release 與相容變更清單" data-link-desc="同一家公司版本策略隨規模演進的第二個時間切片、附「什麼算 backwards-compatible」的明文清單">11.C11</a>）。這份清單的作用是雙向的：服務端保留這些軸上的變更自由、消費者拿到「寫 client 時什麼可以依賴」的明確指引。缺少這種明文劃界時、每個未宣告的性質都處於灰色地帶 — 變更的權責只能事後協商。</p>
<h2 id="變更成本的兩種分配設計">變更成本的兩種分配設計</h2>
<p>承諾確立之後、變更成本的分配有兩種本質設計：由服務端吸收、或攤給所有消費者。這是 <a href="/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5 版本策略</a> 背後的經濟結構、在本章先建立判讀框架。</p>
<p>服務端吸收的極端案例是 Stripe：服務端的轉換層一次吸收了約 100 個 backwards-incompatible 升級、同時維持與 2011 年以來每一版相容（轉換層機制的展開見 <a href="/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5</a>；案例見 <a href="/blog/backend/11-api-design/cases/versioning-stripe-rolling-date-versions/" data-link-title="11.C10 Stripe：日期滾動版本與 version change module" data-link-desc="把相容性從路由層搬進轉換層、breaking change 成本由服務端一次吸收；date-based versioning 的原型案例">11.C10</a>）。這個設計把變更成本收在服務端的基礎設施投資裡、換來的是消費者幾乎永遠不被迫遷移。</p>
<p>攤給消費者的設計則以「版本 + 遷移窗口」的形式出現：服務端宣告新版、給一段支援期、到期消費者必須完成遷移。成本較低、但把協調負擔外部化 — 適合消費者數量有限、或平台對生態有強制力的情境。兩種設計的選擇判準是消費者的數量、異質性、跟服務端對消費者的控制力：內部服務間的 API 可以直接協調升級、公開平台的十萬個 integration 只能靠承諾與窗口。</p>
<h2 id="承諾違約的模式">承諾違約的模式</h2>
<p>違約的傷害大小跟違約的模式有關、明確的失敗優於靜默的行為改變。Facebook Graph API v1.0 退場時、到期的 v1.0 請求被靜默改以 v2.0 語意處理、而非回傳明確錯誤 — v2.0 移除了 friends 資料等大範圍權限、未遷移的 app 不會炸在認證層、而是拿到形狀不同的資料默默壞掉（見 <a href="/blog/backend/11-api-design/cases/versioning-facebook-graph-v1-forced-upgrade/" data-link-title="11.C17 Facebook Graph API v1.0 退場：靜默語意切換（反例）" data-link-desc="反例：到期後把 v1.0 請求靜默當 v2.0 處理而非回明確錯誤、長尾 app 默默壞掉">11.C17</a>、反例）。對照組是明確錯誤：消費者立刻知道、監控立刻報警、修復路徑清楚。設計退場行為時的判準是「消費者發現問題的延遲」— 靜默切換把發現延遲拉到不可控、明確錯誤把它壓到第一個請求。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>消費者回報「你們沒改版但行為變了」</td>
          <td>有未明文的介面性質被依賴、契約劃界不完整</td>
      </tr>
      <tr>
          <td>團隊內對「這個改動要不要發版本」反覆爭論</td>
          <td>缺「什麼算 breaking」的明文清單、先補清單再談流程</td>
      </tr>
      <tr>
          <td>改一個欄位要跨三個團隊開會</td>
          <td>變更成本已攤給消費者、評估是否值得投資服務端吸收層</td>
      </tr>
      <tr>
          <td>消費者停在舊版不動、新功能推不出去</td>
          <td>遷移壓力設計缺位、看 <a href="/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5</a> 的 deprecation 執行工具</td>
      </tr>
  </tbody>
</table>
<p>這些訊號的共同根因是承諾範圍或成本分配沒有被當成明確的設計對象。修法從明文化開始：先有相容變更清單、再有版本與退場政策、最後才是工具。</p>
<h2 id="邊界">邊界</h2>
<p>本章的框架適用於「有外部消費者」的介面 — 外部指組織邊界或部署邊界之外、修正代價無法用一次 refactor 收掉的情境。同一個 repo 內、同一次部署一起上線的模組間介面、變更成本結構完全不同、用內部重構紀律處理即可、套用本章框架是過度設計。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>選風格與建模：<a href="/blog/backend/11-api-design/api-style-selection/" data-link-title="11.2 風格選型總覽" data-link-desc="REST 式 HTTP&#43;JSON、GraphQL、gRPC、tRPC、JSON-RPC、event 之間選哪個 — 用消費者形狀、演進成本、操作可及性三軸判讀">11.2 風格選型總覽</a>、<a href="/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3 資源建模與操作語意</a></li>
<li>承諾怎麼分期、什麼算 breaking：<a href="/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5 版本策略與 deprecation</a>、<a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律</a></li>
<li>契約的驗證手段（consumer-driven contract test）：<a href="/blog/backend/06-reliability/contract-testing/" data-link-title="6.10 Contract Testing 與 Schema 演進" data-link-desc="把跨服務 / API / event schema 的隱性期待變成可驗證契約，控制演進相容性">6.10 契約測試</a></li>
<li>名詞層：<a href="/blog/backend/knowledge-cards/api-contract/" data-link-title="API Contract" data-link-desc="說明 request / response 邊界如何維持相容與可驗證">API Contract</a>、<a href="/blog/backend/knowledge-cards/contract/" data-link-title="Boundary Contract" data-link-desc="說明跨邊界約定如何維持相容與可驗證">Contract</a> 知識卡</li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.2 風格選型總覽</title><link>https://tarrragon.github.io/blog/backend/11-api-design/api-style-selection/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/api-style-selection/</guid><description>&lt;p>風格選型的判準是介面的使用情境、而非風格本身的技術優劣。同一個團隊裡可能同時存在三個正確答案：對外公開 API 用 HTTP+JSON、內部服務間用 gRPC、前後端同倉的產品用 tRPC — 三個介面的消費者形狀不同、答案就不同。本章建立三條判準軸；各風格內部的深度論證（流派自己怎麼說、失敗案例、適用邊界）收在 &lt;code>styles/&lt;/code> 流派層、本章結尾的爭論地圖列出路由。本章的判準軸是從 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">案例庫&lt;/a> 跨案例合成的、標明為推導。&lt;/p>
&lt;h2 id="判準軸一消費者形狀">判準軸一：消費者形狀&lt;/h2>
&lt;p>消費者形狀指誰在呼叫、服務端對呼叫方有多少控制力、呼叫方跟服務端的部署與語言關係。這條軸是三條裡權重最高的、因為它決定其他軸的成本怎麼放大。&lt;/p>
&lt;p>流派自己劃出的邊界最有說服力。tRPC 官方 FAQ 明言前提：脫離 monorepo 就失去 client 與 server 一起運作的保證、替代方案是把 backend 型別發成 private npm package — 語言鎖定 TypeScript、部署形態鎖定同倉或私包（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/rpc-trpc-design-philosophy/" data-link-title="11.C33 tRPC 設計哲學：無 schema 無 codegen 的型別共享" data-link-desc="把 API 契約從 IDL 檔搬進型別系統的極端點、官方自述的前提與代價（TS-only、同倉共置）">11.C33&lt;/a>）。公開撤退立場的六年 GraphQL 實踐者也給出同構的判準句、並建議控制得了 client 的團隊改用 OpenAPI REST（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/graphql-bessey-retreat/" data-link-title="11.C22 Matt Bessey：六年 GraphQL 老手的撤退清單（反例）" data-link-desc="反例：授權下推到 field、成本不可預測、解析層攻擊面的執行期代價清單、附撤退判準">11.C22&lt;/a>、反例、作者判讀層）。兩個相反方向的來源指向同一條判讀：&lt;strong>選型的第一步是確認消費者是誰、再比對風格能力&lt;/strong>。&lt;/p>
&lt;p>消費者形狀的常見分型與傾向：&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>匿名第三方開發者（公開平台）&lt;/td>
 &lt;td>HTTP+JSON（REST 式）&lt;/td>
 &lt;td>工具鏈普及、curl 可及、文件生態成熟&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部服務、跨語言、多團隊&lt;/td>
 &lt;td>gRPC / protobuf&lt;/td>
 &lt;td>schema-first 跨語言、契約可 CI 檢查&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>前後端同倉、全 TypeScript&lt;/td>
 &lt;td>tRPC&lt;/td>
 &lt;td>型別即契約、零 codegen；前提與代價見上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>多形狀 client 拼裝巢狀資料&lt;/td>
 &lt;td>GraphQL&lt;/td>
 &lt;td>client 聲明取數；執行成本與安全代價見爭論地圖&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本地 process 間、雙向、低頻&lt;/td>
 &lt;td>JSON-RPC&lt;/td>
 &lt;td>最小夠用訊息層；LSP 與 MCP 的採用是實證（見下）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>下游要事件不是查詢&lt;/td>
 &lt;td>event / queue&lt;/td>
 &lt;td>交接語意不同、路由到 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格是索引、每列的成立條件在真實情境裡要重新判讀。以 JSON-RPC 列為例：它在 web API 世代被 REST 式做法取代、卻在 LSP 與 MCP 兩份現代 spec 裡被選為訊息層 — 兩份 spec 都在 JSON-RPC 2.0 上加約束、而非發明新協議（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/rpc-jsonrpc-lsp-mcp-revival/" data-link-title="11.C34 JSON-RPC 重生：LSP 與 MCP 都選它當訊息層" data-link-desc="死在 web API、活在編輯器與 agent 協議：最小夠用訊息層的選型條件組合">11.C34&lt;/a>；spec 只陳述採用、選型理由是本模組的判讀）。共同的條件組合是本地、雙向、需要 notification 語意、生態工具要求零 codegen 可自省 — 這組條件下 gRPC 的 HTTP/2 與 codegen 成本全是負資產。判讀重點是條件組合、而非「JSON-RPC 回來了」這種風格敘事。其餘各列的成立條件同樣散在後文 — gRPC 與 tRPC 的前提在判準軸二、三展開、GraphQL 的代價在爭論地圖路由的流派層、表格只負責索引。&lt;/p>
&lt;h2 id="判準軸二演進成本">判準軸二：演進成本&lt;/h2>
&lt;p>每種風格都要回答「介面上線後怎麼改」、機制差異很大。兩個代表性答案：GraphQL 的 versionless 路線把演進成本轉嫁到 schema 層的紀律、protobuf 把相容性直接做成編碼格式的性質 — 兩者的具體紀律與條款、主寫在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 變更紀律&lt;/a> 的格式層段（案例 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/graphql-versionless-evolution/" data-link-title="11.C26 GraphQL 官方：versionless API 與 nullable-by-default" data-link-desc="no-versioning 的成本轉嫁鏈：只加不改、deprecation、nullable 預設三個紀律換掉版本號">11.C26&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/grpc-protobuf-field-number-discipline/" data-link-title="11.C28 protobuf 官方規範：field number 紀律" data-link-desc="編號不可改、刪除必 reserve、重用導致資料損毀；契約相容性是編碼格式的性質、不是 review 慣例">11.C28&lt;/a>）。HTTP+JSON 的演進紀律則多半靠約定與流程、缺格式層的強制 — 這是它自由度最高也最容易踩線的原因。&lt;/p></description><content:encoded><![CDATA[<p>風格選型的判準是介面的使用情境、而非風格本身的技術優劣。同一個團隊裡可能同時存在三個正確答案：對外公開 API 用 HTTP+JSON、內部服務間用 gRPC、前後端同倉的產品用 tRPC — 三個介面的消費者形狀不同、答案就不同。本章建立三條判準軸；各風格內部的深度論證（流派自己怎麼說、失敗案例、適用邊界）收在 <code>styles/</code> 流派層、本章結尾的爭論地圖列出路由。本章的判準軸是從 <a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">案例庫</a> 跨案例合成的、標明為推導。</p>
<h2 id="判準軸一消費者形狀">判準軸一：消費者形狀</h2>
<p>消費者形狀指誰在呼叫、服務端對呼叫方有多少控制力、呼叫方跟服務端的部署與語言關係。這條軸是三條裡權重最高的、因為它決定其他軸的成本怎麼放大。</p>
<p>流派自己劃出的邊界最有說服力。tRPC 官方 FAQ 明言前提：脫離 monorepo 就失去 client 與 server 一起運作的保證、替代方案是把 backend 型別發成 private npm package — 語言鎖定 TypeScript、部署形態鎖定同倉或私包（見 <a href="/blog/backend/11-api-design/cases/rpc-trpc-design-philosophy/" data-link-title="11.C33 tRPC 設計哲學：無 schema 無 codegen 的型別共享" data-link-desc="把 API 契約從 IDL 檔搬進型別系統的極端點、官方自述的前提與代價（TS-only、同倉共置）">11.C33</a>）。公開撤退立場的六年 GraphQL 實踐者也給出同構的判準句、並建議控制得了 client 的團隊改用 OpenAPI REST（見 <a href="/blog/backend/11-api-design/cases/graphql-bessey-retreat/" data-link-title="11.C22 Matt Bessey：六年 GraphQL 老手的撤退清單（反例）" data-link-desc="反例：授權下推到 field、成本不可預測、解析層攻擊面的執行期代價清單、附撤退判準">11.C22</a>、反例、作者判讀層）。兩個相反方向的來源指向同一條判讀：<strong>選型的第一步是確認消費者是誰、再比對風格能力</strong>。</p>
<p>消費者形狀的常見分型與傾向：</p>
<table>
  <thead>
      <tr>
          <th>消費者形狀</th>
          <th>傾向風格</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>匿名第三方開發者（公開平台）</td>
          <td>HTTP+JSON（REST 式）</td>
          <td>工具鏈普及、curl 可及、文件生態成熟</td>
      </tr>
      <tr>
          <td>內部服務、跨語言、多團隊</td>
          <td>gRPC / protobuf</td>
          <td>schema-first 跨語言、契約可 CI 檢查</td>
      </tr>
      <tr>
          <td>前後端同倉、全 TypeScript</td>
          <td>tRPC</td>
          <td>型別即契約、零 codegen；前提與代價見上</td>
      </tr>
      <tr>
          <td>多形狀 client 拼裝巢狀資料</td>
          <td>GraphQL</td>
          <td>client 聲明取數；執行成本與安全代價見爭論地圖</td>
      </tr>
      <tr>
          <td>本地 process 間、雙向、低頻</td>
          <td>JSON-RPC</td>
          <td>最小夠用訊息層；LSP 與 MCP 的採用是實證（見下）</td>
      </tr>
      <tr>
          <td>下游要事件不是查詢</td>
          <td>event / queue</td>
          <td>交接語意不同、路由到 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a></td>
      </tr>
  </tbody>
</table>
<p>表格是索引、每列的成立條件在真實情境裡要重新判讀。以 JSON-RPC 列為例：它在 web API 世代被 REST 式做法取代、卻在 LSP 與 MCP 兩份現代 spec 裡被選為訊息層 — 兩份 spec 都在 JSON-RPC 2.0 上加約束、而非發明新協議（見 <a href="/blog/backend/11-api-design/cases/rpc-jsonrpc-lsp-mcp-revival/" data-link-title="11.C34 JSON-RPC 重生：LSP 與 MCP 都選它當訊息層" data-link-desc="死在 web API、活在編輯器與 agent 協議：最小夠用訊息層的選型條件組合">11.C34</a>；spec 只陳述採用、選型理由是本模組的判讀）。共同的條件組合是本地、雙向、需要 notification 語意、生態工具要求零 codegen 可自省 — 這組條件下 gRPC 的 HTTP/2 與 codegen 成本全是負資產。判讀重點是條件組合、而非「JSON-RPC 回來了」這種風格敘事。其餘各列的成立條件同樣散在後文 — gRPC 與 tRPC 的前提在判準軸二、三展開、GraphQL 的代價在爭論地圖路由的流派層、表格只負責索引。</p>
<h2 id="判準軸二演進成本">判準軸二：演進成本</h2>
<p>每種風格都要回答「介面上線後怎麼改」、機制差異很大。兩個代表性答案：GraphQL 的 versionless 路線把演進成本轉嫁到 schema 層的紀律、protobuf 把相容性直接做成編碼格式的性質 — 兩者的具體紀律與條款、主寫在 <a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 變更紀律</a> 的格式層段（案例 <a href="/blog/backend/11-api-design/cases/graphql-versionless-evolution/" data-link-title="11.C26 GraphQL 官方：versionless API 與 nullable-by-default" data-link-desc="no-versioning 的成本轉嫁鏈：只加不改、deprecation、nullable 預設三個紀律換掉版本號">11.C26</a>、<a href="/blog/backend/11-api-design/cases/grpc-protobuf-field-number-discipline/" data-link-title="11.C28 protobuf 官方規範：field number 紀律" data-link-desc="編號不可改、刪除必 reserve、重用導致資料損毀；契約相容性是編碼格式的性質、不是 review 慣例">11.C28</a>）。HTTP+JSON 的演進紀律則多半靠約定與流程、缺格式層的強制 — 這是它自由度最高也最容易踩線的原因。</p>
<p>選型時的判讀問題是團隊承擔得起哪種紀律：格式層強制（protobuf）適合跨團隊多語言、因為紀律不依賴人的自覺；約定層紀律（JSON、GraphQL SDL）需要配套的變更審查與工具、組織面見 <a href="/blog/backend/11-api-design/api-governance/" data-link-title="11.10 API 規範治理" data-link-desc="設計規範怎麼讓幾十個團隊持續遵守 — 提案制、Guild 制、分軌制的治理模式比較、linting 進 CI、規範失敗的成因">11.10 規範治理</a>。</p>
<h2 id="判準軸三操作與-debug-可及性">判準軸三：操作與 debug 可及性</h2>
<p>介面會被 on-call 的人徒手戳、會過 LB 與 proxy、會被防火牆規則篩 — 這些操作面的成本在能力比較表上通常缺席。gRPC 在這條軸上的代價有完整的一手批評：協議要求端到端 HTTP/2 加 trailers、瀏覽器支援需要翻譯 proxy、debug 時 <code>curl | jq</code> 不可行（見 <a href="/blog/backend/11-api-design/cases/grpc-buf-connect-critique/" data-link-title="11.C30 Buf Connect 發布文：對 grpc-go 的系統性批評" data-link-desc="gRPC 部署邊界（trailers、瀏覽器、proxy）最完整的一手批評；發布方是競品、立場需標明">11.C30</a>、發布方是競品 vendor、批評點與 <a href="/blog/backend/11-api-design/cases/grpc-kmcd-bad-parts/" data-link-title="11.C32 gRPC: The Bad Parts：cURL 測試不過的 API（反例）" data-link-desc="反例：獨立實踐者的 gRPC 批評清單、debug 可及性判準、含生態已修補的平衡敘述">11.C32</a> 的獨立實踐者批評互證後採用）。C32 提出的「傳一個 cURL 範例給朋友」測試是這條軸的可操作判準。</p>
<p>這條軸的權重跟組織的 infra 成熟度成反比：有能力在框架層集中處理 proxy、觀測、部署的組織（如 Dropbox 的 Courier、見 <a href="/blog/backend/11-api-design/cases/grpc-dropbox-courier/" data-link-title="11.C31 Dropbox Courier：百萬 RPS 規模的 gRPC 遷移" data-link-desc="gRPC 當框架層集中可靠性的載體、遷移成本與 TLS 握手踩雷的規模判讀訊號">11.C31</a>）、操作成本被平台吸收；小團隊每個介面都要自己扛操作面、可及性差的風格會在 on-call 時收利息。</p>
<h2 id="共存是常態取代是例外">共存是常態、取代是例外</h2>
<p>大平台的長期實證支持「多風格共存」而非「新風格取代舊風格」。GitHub 2016 年採用 GraphQL、多年後的官方立場是 REST 與 GraphQL 並行、依情境選用、且明文說明功能覆蓋不對等（見 <a href="/blog/backend/11-api-design/cases/graphql-github-rest-parallel/" data-link-title="11.C20 GitHub：REST 與 GraphQL 雙軌並行的十年穩態" data-link-desc="2016 採用者的長期終點是共存而非取代、功能覆蓋不對等被官方明文承認">11.C20</a>）。反方向的 Shopify 宣告 GraphQL 為唯一 API — 但它動用了「新功能只上 GraphQL、新 app 強制」的平台強制力、還配套 rate limit 加倍與查詢成本降 75%（見 <a href="/blog/backend/11-api-design/cases/graphql-shopify-all-in/" data-link-title="11.C21 Shopify：宣告 GraphQL 為唯一 API、REST 轉 legacy" data-link-desc="跟共存路線相反的策略極端：用新功能只上 GraphQL 製造遷移壓力、配套降成本加倍配額">11.C21</a>）。判讀：沒有平台強制力的組織、務實的預期是多風格長期共存、選型的真正產出是「每個介面用對風格」、而非全公司統一答案。</p>
<h2 id="爭論地圖">爭論地圖</h2>
<p>本章只給判準軸；各風格的深度交鋒在 <code>styles/</code> 流派層：REST 語意學之爭與 hypermedia 復興（<a href="/blog/backend/11-api-design/styles/rest/" data-link-title="REST 流派：語意學之爭與 hypermedia 復興" data-link-desc="REST 這個詞的定義權爭奪、hypermedia 路線的復興論證與業界拒絕的理由、成熟度模型的實用讀法">已完成</a>）、GraphQL 的執行成本與進退（<a href="/blog/backend/11-api-design/styles/graphql/" data-link-title="GraphQL 流派：schema 演進、執行成本與公開 API 進退" data-link-desc="versionless 演進的紀律代價、resolver 執行模型的成本與攻擊面、大平台採用與撤退的情境差異">已完成</a>）、proto 演進紀律與部署邊界（styles/grpc、backlog）、tRPC 與 JSON-RPC 的復興條件（styles/rpc-revival、backlog）、格式標準化的反覆嘗試（styles/standards、backlog）、server 推 client 的承諾差異（styles/realtime、案例待採集）。完整 backlog 見 <a href="/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁</a> 章節規劃。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>承諾成本結構的框架：<a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1 API 作為服務邊界的責任</a></li>
<li>選了風格之後的資源建模：<a href="/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3 資源建模與操作語意</a></li>
<li>事件式交接的能力邊界：<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a>、<a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">Webhook 知識卡</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.3 資源建模與操作語意</title><link>https://tarrragon.github.io/blog/backend/11-api-design/resource-modeling-operation-semantics/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/resource-modeling-operation-semantics/</guid><description>&lt;p>資源建模的核心決策是把業務操作對映成介面詞彙的方式。同一個「取消訂單」、可以建模成資源狀態的變更（&lt;code>PATCH /orders/{id}&lt;/code> 帶 &lt;code>status: cancelled&lt;/code>）、子資源的建立（&lt;code>POST /orders/{id}/cancellation&lt;/code>）、或動作端點（&lt;code>POST /orders/{id}/cancel&lt;/code>）— 三種寫法都在真實 API 裡大量存在、差異在演進空間、快取語意、跟消費者的心智模型。本章給建模決策的判準；本章的來源偏論證型（定義與判別法）、企業建模實作的公開案例在案例庫標為缺口、操作層展開以通用工程知識補充。&lt;/p>
&lt;h2 id="資源與表徵是兩層">資源與表徵是兩層&lt;/h2>
&lt;p>資源建模的第一個概念區分是資源（resource）與表徵（representation）：資源是被命名的概念實體、表徵是它在某次回應裡的具體格式。這個區分出自 REST 論文的 uniform interface 子約束 manipulation through representations — client 透過表徵操作資源（約束清單見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/rest-fielding-dissertation-ch5/" data-link-title="11.C1 Fielding 論文第 5 章：REST 是約束推導的架構風格" data-link-desc="REST 由六個約束從 null style 推導而來、uniform interface 以效率換一般性；所有 REST 論爭的定義基準">11.C1&lt;/a>、定義展開依論文原文）。工程意義：同一個資源可以有多種表徵（完整版、列表精簡版、不同版本的形狀）、表徵的形狀可以演進而資源身分不變 — URL 命名的是資源、欄位設計的是表徵、兩層的變更紀律不同。把這兩層混在一起的常見症狀是「加一個欄位要開一個新 endpoint」。&lt;/p>
&lt;h2 id="資源導向與動作導向的取捨">資源導向與動作導向的取捨&lt;/h2>
&lt;p>兩種建模方向各有成立情境、判準是操作的語意複雜度與演進預期。&lt;/p>
&lt;p>資源導向把業務操作收斂成「對某個名詞的狀態操作」：建立、讀取、更新、刪除、加上狀態欄位變更。收益是介面詞彙統一 — 消費者學會一個資源的操作方式、就會操作所有資源；快取、權限、審計都能按資源粒度統一處理。成本是有些操作硬塞進名詞會失真：「重算報表」「合併帳號」「試算價格」這類操作沒有自然的資源對應。&lt;/p>
&lt;p>動作導向（RPC 式端點）直接命名操作。收益是語意直白、參數自由；成本是介面詞彙發散 — 每個動作都是新詞彙、消費者要逐個學、橫切能力（快取、重試語意、權限）要逐個處理。&lt;/p>
&lt;p>務實的判準是混用、但混用要有規則：預設資源導向、動作導向保留給「狀態機轉換有業務儀式」的操作（下單、退款、發布）、且動作端點的回應仍回到資源表徵（回訂單、不回裸 ack）。這條規則讓橫切能力至少在回應側保持統一。跨資源的操作（轉帳、合併）建議建模成獨立資源（transfer、merge job）— 操作本身有生命週期、有查詢需求、有失敗狀態時、它就值得一個名詞。&lt;/p>
&lt;h2 id="http-method-與-status-是承諾不只是慣例">HTTP method 與 status 是承諾、不只是慣例&lt;/h2>
&lt;p>method 與 status 的選用向中介層與消費者承諾了行為性質、選錯的代價由基礎設施收取。GET 承諾安全（無副作用）— proxy 與瀏覽器會據此重試、預取、快取；PUT 承諾冪等 — client 可以直接重送、無需判斷前次結果；POST 兩者都沒承諾、所以需要冪等鍵機制補強（&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計&lt;/a> 主寫）。status 同理：2xx 對監控承諾成功、4xx 承諾「錯在請求、重試無用」（限流的 429 是明確例外、可重試但要等、見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9&lt;/a>）、5xx 承諾「錯在服務、可以重試」— 把業務失敗包在 200 裡回傳、等於對整條觀測與重試鏈說謊、錯誤率圖表從此失真。錯誤語意的完整設計是 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/error-model-design/" data-link-title="11.4 錯誤模型設計" data-link-desc="錯誤該分幾類、格式怎麼定才有演化空間、機器判讀跟人類訊息怎麼分工 — 錯誤作為契約一級公民的設計判準">11.4 錯誤模型設計&lt;/a> 的主題、本章只立「status 是給機器的承諾」這條判準。&lt;/p>
&lt;h2 id="available-actions-由誰計算">Available actions 由誰計算&lt;/h2>
&lt;p>資源當下可做什麼操作、有兩種回答方式 — 這是 hypermedia 爭論落到建模層的具體形式。htmx 的 HATEOAS essay 用透支帳戶做對照：HTML 表徵在透支時只回 deposit 連結、業務狀態直接編碼在可用操作裡、client 零業務知識；JSON 表徵回 &lt;code>status: &amp;quot;overdrawn&amp;quot;&lt;/code> 欄位、client 靠文件理解語意跟下一步（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/rest-htmx-hateoas-html-necessity/" data-link-title="11.C5 htmx HATEOAS essay：透支帳戶的兩種表徵對照" data-link-desc="同一個 domain 狀態的 HTML 與 JSON 表徵耦合差異、HATEOAS 有無的操作型判別法">11.C5&lt;/a>）。由此得到的操作型判別法：&lt;strong>available actions 由 server 算完放進 response、還是 client 讀狀態欄位自己算&lt;/strong> — 前者是 hypermedia 路線、後者是業界主流的 JSON API 路線。&lt;/p>
&lt;p>判準層的建議：machine-to-machine 的 JSON API 走 client 自算是務實預設（消費者是程式、本來就要讀文件寫死邏輯）；但「狀態欄位 + 文件」的組合要把狀態機明文化 — 狀態列舉、每個狀態下的合法操作、非法操作回什麼錯誤。hypermedia 路線的完整論證與反方立場、收在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/rest/hypermedia-hateoas-revival/" data-link-title="Hypermedia 與 HATEOAS 復興" data-link-desc="復興派的論證本體：uniform client 前提、語意漂移史、格式標準化的失敗現實、反方的收益假設拆解 — 與 hypermedia 的適用邊界">Hypermedia 與 HATEOAS 復興&lt;/a>。&lt;/p>
&lt;p>Richardson 成熟度模型可以當這個決策的定位工具：Level 1（資源化）、Level 2（method 與 status 語意正確）、Level 3（hypermedia controls）。一手來源自己標注它是理解工具、非 REST 認證（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/rest-fowler-richardson-maturity-model/" data-link-title="11.C3 Richardson 成熟度模型：分級階梯與它的自我聲明" data-link-desc="RMM 四級是理解 REST 元素的思考工具、一手來源自己警告它不是 REST 分級定義；業界停在 Level 2 的參照系">11.C3&lt;/a>）。用它描述「我們的 API 在哪、要不要往上」是合法用法、拿它當合規檢查表是誤用 — 完整的實用讀法與誤用邊界、主寫在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/rest/richardson-maturity-practical-reading/" data-link-title="Richardson 成熟度的實用讀法" data-link-desc="RMM 四級當定位與溝通工具的用法、每一級的工程意義、以及把它當合規認證或升級路線圖的誤用邊界">Richardson 成熟度的實用讀法&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>資源建模的核心決策是把業務操作對映成介面詞彙的方式。同一個「取消訂單」、可以建模成資源狀態的變更（<code>PATCH /orders/{id}</code> 帶 <code>status: cancelled</code>）、子資源的建立（<code>POST /orders/{id}/cancellation</code>）、或動作端點（<code>POST /orders/{id}/cancel</code>）— 三種寫法都在真實 API 裡大量存在、差異在演進空間、快取語意、跟消費者的心智模型。本章給建模決策的判準；本章的來源偏論證型（定義與判別法）、企業建模實作的公開案例在案例庫標為缺口、操作層展開以通用工程知識補充。</p>
<h2 id="資源與表徵是兩層">資源與表徵是兩層</h2>
<p>資源建模的第一個概念區分是資源（resource）與表徵（representation）：資源是被命名的概念實體、表徵是它在某次回應裡的具體格式。這個區分出自 REST 論文的 uniform interface 子約束 manipulation through representations — client 透過表徵操作資源（約束清單見 <a href="/blog/backend/11-api-design/cases/rest-fielding-dissertation-ch5/" data-link-title="11.C1 Fielding 論文第 5 章：REST 是約束推導的架構風格" data-link-desc="REST 由六個約束從 null style 推導而來、uniform interface 以效率換一般性；所有 REST 論爭的定義基準">11.C1</a>、定義展開依論文原文）。工程意義：同一個資源可以有多種表徵（完整版、列表精簡版、不同版本的形狀）、表徵的形狀可以演進而資源身分不變 — URL 命名的是資源、欄位設計的是表徵、兩層的變更紀律不同。把這兩層混在一起的常見症狀是「加一個欄位要開一個新 endpoint」。</p>
<h2 id="資源導向與動作導向的取捨">資源導向與動作導向的取捨</h2>
<p>兩種建模方向各有成立情境、判準是操作的語意複雜度與演進預期。</p>
<p>資源導向把業務操作收斂成「對某個名詞的狀態操作」：建立、讀取、更新、刪除、加上狀態欄位變更。收益是介面詞彙統一 — 消費者學會一個資源的操作方式、就會操作所有資源；快取、權限、審計都能按資源粒度統一處理。成本是有些操作硬塞進名詞會失真：「重算報表」「合併帳號」「試算價格」這類操作沒有自然的資源對應。</p>
<p>動作導向（RPC 式端點）直接命名操作。收益是語意直白、參數自由；成本是介面詞彙發散 — 每個動作都是新詞彙、消費者要逐個學、橫切能力（快取、重試語意、權限）要逐個處理。</p>
<p>務實的判準是混用、但混用要有規則：預設資源導向、動作導向保留給「狀態機轉換有業務儀式」的操作（下單、退款、發布）、且動作端點的回應仍回到資源表徵（回訂單、不回裸 ack）。這條規則讓橫切能力至少在回應側保持統一。跨資源的操作（轉帳、合併）建議建模成獨立資源（transfer、merge job）— 操作本身有生命週期、有查詢需求、有失敗狀態時、它就值得一個名詞。</p>
<h2 id="http-method-與-status-是承諾不只是慣例">HTTP method 與 status 是承諾、不只是慣例</h2>
<p>method 與 status 的選用向中介層與消費者承諾了行為性質、選錯的代價由基礎設施收取。GET 承諾安全（無副作用）— proxy 與瀏覽器會據此重試、預取、快取；PUT 承諾冪等 — client 可以直接重送、無需判斷前次結果；POST 兩者都沒承諾、所以需要冪等鍵機制補強（<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計</a> 主寫）。status 同理：2xx 對監控承諾成功、4xx 承諾「錯在請求、重試無用」（限流的 429 是明確例外、可重試但要等、見 <a href="/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9</a>）、5xx 承諾「錯在服務、可以重試」— 把業務失敗包在 200 裡回傳、等於對整條觀測與重試鏈說謊、錯誤率圖表從此失真。錯誤語意的完整設計是 <a href="/blog/backend/11-api-design/error-model-design/" data-link-title="11.4 錯誤模型設計" data-link-desc="錯誤該分幾類、格式怎麼定才有演化空間、機器判讀跟人類訊息怎麼分工 — 錯誤作為契約一級公民的設計判準">11.4 錯誤模型設計</a> 的主題、本章只立「status 是給機器的承諾」這條判準。</p>
<h2 id="available-actions-由誰計算">Available actions 由誰計算</h2>
<p>資源當下可做什麼操作、有兩種回答方式 — 這是 hypermedia 爭論落到建模層的具體形式。htmx 的 HATEOAS essay 用透支帳戶做對照：HTML 表徵在透支時只回 deposit 連結、業務狀態直接編碼在可用操作裡、client 零業務知識；JSON 表徵回 <code>status: &quot;overdrawn&quot;</code> 欄位、client 靠文件理解語意跟下一步（見 <a href="/blog/backend/11-api-design/cases/rest-htmx-hateoas-html-necessity/" data-link-title="11.C5 htmx HATEOAS essay：透支帳戶的兩種表徵對照" data-link-desc="同一個 domain 狀態的 HTML 與 JSON 表徵耦合差異、HATEOAS 有無的操作型判別法">11.C5</a>）。由此得到的操作型判別法：<strong>available actions 由 server 算完放進 response、還是 client 讀狀態欄位自己算</strong> — 前者是 hypermedia 路線、後者是業界主流的 JSON API 路線。</p>
<p>判準層的建議：machine-to-machine 的 JSON API 走 client 自算是務實預設（消費者是程式、本來就要讀文件寫死邏輯）；但「狀態欄位 + 文件」的組合要把狀態機明文化 — 狀態列舉、每個狀態下的合法操作、非法操作回什麼錯誤。hypermedia 路線的完整論證與反方立場、收在 <a href="/blog/backend/11-api-design/styles/rest/hypermedia-hateoas-revival/" data-link-title="Hypermedia 與 HATEOAS 復興" data-link-desc="復興派的論證本體：uniform client 前提、語意漂移史、格式標準化的失敗現實、反方的收益假設拆解 — 與 hypermedia 的適用邊界">Hypermedia 與 HATEOAS 復興</a>。</p>
<p>Richardson 成熟度模型可以當這個決策的定位工具：Level 1（資源化）、Level 2（method 與 status 語意正確）、Level 3（hypermedia controls）。一手來源自己標注它是理解工具、非 REST 認證（見 <a href="/blog/backend/11-api-design/cases/rest-fowler-richardson-maturity-model/" data-link-title="11.C3 Richardson 成熟度模型：分級階梯與它的自我聲明" data-link-desc="RMM 四級是理解 REST 元素的思考工具、一手來源自己警告它不是 REST 分級定義；業界停在 Level 2 的參照系">11.C3</a>）。用它描述「我們的 API 在哪、要不要往上」是合法用法、拿它當合規檢查表是誤用 — 完整的實用讀法與誤用邊界、主寫在 <a href="/blog/backend/11-api-design/styles/rest/richardson-maturity-practical-reading/" data-link-title="Richardson 成熟度的實用讀法" data-link-desc="RMM 四級當定位與溝通工具的用法、每一級的工程意義、以及把它當合規認證或升級路線圖的誤用邊界">Richardson 成熟度的實用讀法</a>。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新功能反覆長出 <code>/doSomething</code> 端點</td>
          <td>缺混用規則、動作導向變成預設、橫切能力開始發散</td>
      </tr>
      <tr>
          <td>業務失敗回 200 + <code>success: false</code></td>
          <td>status 承諾失效、觀測與重試鏈失真、往錯誤模型章修</td>
      </tr>
      <tr>
          <td>消費者問「這個狀態下能不能呼叫 X」</td>
          <td>狀態機沒明文化、補狀態 × 操作對照表、比加欄位優先</td>
      </tr>
      <tr>
          <td>GET 端點被發現有副作用</td>
          <td>method 承諾違約、中介層重試會放大傷害、最高優先修</td>
      </tr>
  </tbody>
</table>
<p>四個訊號的損害半徑不同、排查順序從半徑最大的開始：GET 副作用會被中介層自動放大、屬立即修；200 包業務失敗污染整條觀測鏈、次之；端點增生與狀態機未明文是設計債、按迭代節奏收。</p>
<h2 id="邊界">邊界</h2>
<p>本章的建模判準以 HTTP+JSON 風格為主要語境。gRPC 與 GraphQL 的建模單位（service/method、type/field）有各自的紀律、收在對應流派層；事件式介面的建模（event schema）屬 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 訊息佇列</a> 的範圍。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>資料層的結構設計：<a href="/blog/backend/01-database/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">1.2 Schema 設計</a>（資源表徵與資料表是兩層、交接處看這篇）</li>
<li>風格還沒定、先回：<a href="/blog/backend/11-api-design/api-style-selection/" data-link-title="11.2 風格選型總覽" data-link-desc="REST 式 HTTP&#43;JSON、GraphQL、gRPC、tRPC、JSON-RPC、event 之間選哪個 — 用消費者形狀、演進成本、操作可及性三軸判讀">11.2 風格選型總覽</a></li>
<li>承諾框架：<a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1 API 作為服務邊界的責任</a></li>
<li>名詞層：<a href="/blog/backend/knowledge-cards/request-response-protocol/" data-link-title="Request/Response Protocol" data-link-desc="說明同步請求如何在 client 與 service 之間對齊互動規則">Request-Response Protocol</a>、<a href="/blog/backend/knowledge-cards/api-contract/" data-link-title="API Contract" data-link-desc="說明 request / response 邊界如何維持相容與可驗證">API Contract</a> 知識卡</li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.4 錯誤模型設計</title><link>https://tarrragon.github.io/blog/backend/11-api-design/error-model-design/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/error-model-design/</guid><description>&lt;p>錯誤模型是契約的一級公民：消費者的重試邏輯、監控的告警規則、前端的使用者訊息、全部建立在錯誤回應的結構上。錯誤格式一旦被依賴、變更成本跟正常回應完全相同（承諾成本結構見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1&lt;/a>）、常見的失衡是設計精力集中在成功路徑、錯誤格式在第一個 handler 裡即興決定 — 之後每個新錯誤都在複製那次即興。&lt;/p>
&lt;h2 id="第一刀可重試與終態">第一刀：可重試與終態&lt;/h2>
&lt;p>錯誤分類的第一個維度是對消費者行為的指示：這個錯誤重試有沒有用。可重試（服務暫時失效、限流、鎖衝突）指示消費者退避後重送；終態（參數錯誤、權限拒絕、業務規則拒絕）指示消費者停止重試、走修正或人工路徑。這一刀切錯的代價是雙向的：終態錯誤被標成可重試、消費者的 retry 迴圈空轉壓垮服務；可重試被標成終態、暫時性故障變成使用者眼中的永久失敗。&lt;/p>
&lt;p>HTTP status 承擔這一刀的粗分類（4xx 終態、5xx 與 429 可重試、見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3&lt;/a> 的 status 承諾段）、錯誤 body 承擔細分類。兩層要一致 — body 說可重試、status 給 400、中介層跟 SDK 只看 status、消費者的兩層邏輯就互相矛盾。&lt;/p>
&lt;h2 id="格式設計標準與自訂並存的現實">格式設計：標準與自訂並存的現實&lt;/h2>
&lt;p>錯誤格式有現行標準、也有大廠自成一格的成熟先例、兩者的設計目標一致：機器可判讀、人類可理解、格式可演化。&lt;/p>
&lt;p>RFC 9457 定義 &lt;code>application/problem+json&lt;/code>：&lt;code>type&lt;/code>（URI）、&lt;code>title&lt;/code>、&lt;code>status&lt;/code>、&lt;code>detail&lt;/code>、&lt;code>instance&lt;/code> 五個核心成員、允許 extension members 且要求 client 忽略不認識的欄位（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/error-rfc9457-problem-details/" data-link-title="11.C35 RFC 9457：problem&amp;#43;json 標準化錯誤格式" data-link-desc="type 用 URI 外部化錯誤命名空間、client 必須忽略未知欄位的演化條款、IANA registry 補 7807 碎片化">11.C35&lt;/a>）。兩個設計值得單獨理解：&lt;code>type&lt;/code> 用 URI 而非字串 enum、把錯誤種類的命名空間外部化、跨團隊不撞名；「client MUST ignore unknown extensions」是格式的演化條款 — 服務端可以加欄位而不破壞既有消費者、等同錯誤模型的開放封閉原則。&lt;/p>
&lt;p>Stripe 的錯誤物件早於這個標準自成一格、分層思路可以直接借用：&lt;code>type&lt;/code> 承擔路由層（哪類錯誤、走哪條處理分支）、&lt;code>code&lt;/code> 承擔分支層（細粒度機器碼）、&lt;code>param&lt;/code> 加 &lt;code>message&lt;/code> 承擔 UI 層（哪個欄位錯、給人看什麼）、三個正交欄位讓消費者各層各取所需（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/error-stripe-error-object/" data-link-title="11.C36 Stripe 錯誤物件：type / code / param 三層分離" data-link-desc="路由層、分支層、UI 層做成正交欄位；冪等衝突列 first-class 錯誤型別；標準前自成一格的對照組">11.C36&lt;/a>）。這個模型還藏著一個結構訊號：&lt;code>idempotency_error&lt;/code> 是四個 type 之一 — 冪等衝突在支付 API 是預期常態、錯誤模型要為它保留一級位置（冪等語意主寫在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8&lt;/a>）。&lt;/p>
&lt;p>選標準還是自訂的判準：新 API 從 RFC 9457 起手、拿到現成的演化條款與工具生態；既有 API 有自訂格式且被大量依賴、遷移本身就是 breaking change、務實做法是把 9457 的兩個設計（type 命名空間化、未知欄位忽略條款）補進自訂格式、而非換格式。&lt;/p>
&lt;h2 id="錯誤狀態下的系統行為">錯誤狀態下的系統行為&lt;/h2>
&lt;p>錯誤模型的最後一段責任是「錯誤發生時、系統還敢做什麼」。Twilio 2013 年計費事故的教訓落在這：關鍵狀態讀不到、自動扣款卻繼續跑、演變成重複扣款（事故時序與冪等閘門的抽象、主寫在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 的反例段&lt;/a>）。落到錯誤模型的通用判準：關鍵狀態讀寫失敗的錯誤處理、預設要往「拒絕服務」收斂、而非「帶著壞狀態繼續」— 錯誤分類表裡要有一類「狀態不可信、停止副作用」、它的處理路徑跟一般 5xx 不同。&lt;/p>
&lt;h2 id="常見設計錯誤">常見設計錯誤&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>業務失敗包 200&lt;/strong>：觀測與重試鏈失真、修法見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3 的 status 承諾段&lt;/a>。&lt;/li>
&lt;li>&lt;strong>錯誤碼用連續數字&lt;/strong>：&lt;code>code: 1047&lt;/code> 無命名空間、跨服務撞號、grep 不到語意 — 用可讀字串或 URI。&lt;/li>
&lt;li>&lt;strong>message 當機器介面&lt;/strong>：消費者 parse 錯誤訊息文字做分支、訊息改字就是 breaking change — 機器分支一律走 type / code。&lt;/li>
&lt;li>&lt;strong>錯誤格式沒有演化條款&lt;/strong>：第一版沒宣告「未知欄位忽略」、之後每次加欄位都無法確認安全性 — 條款從第一版就寫進文件。&lt;/li>
&lt;/ul>
&lt;h2 id="爭論地圖與下一步">爭論地圖與下一步&lt;/h2>
&lt;p>本章的分類與格式判準、以 HTTP transport 承載 status 語意為前提。錯誤格式的跨風格交鋒（RFC 9457、envelope 包裝、GraphQL 的 200-with-errors 慣例）是掛在本章的爭論深度文章 backlog（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁&lt;/a> 章節規劃）— GraphQL 把 transport 層 status 跟業務錯誤解耦的做法、在該文攤開、本章不展開。&lt;/p></description><content:encoded><![CDATA[<p>錯誤模型是契約的一級公民：消費者的重試邏輯、監控的告警規則、前端的使用者訊息、全部建立在錯誤回應的結構上。錯誤格式一旦被依賴、變更成本跟正常回應完全相同（承諾成本結構見 <a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1</a>）、常見的失衡是設計精力集中在成功路徑、錯誤格式在第一個 handler 裡即興決定 — 之後每個新錯誤都在複製那次即興。</p>
<h2 id="第一刀可重試與終態">第一刀：可重試與終態</h2>
<p>錯誤分類的第一個維度是對消費者行為的指示：這個錯誤重試有沒有用。可重試（服務暫時失效、限流、鎖衝突）指示消費者退避後重送；終態（參數錯誤、權限拒絕、業務規則拒絕）指示消費者停止重試、走修正或人工路徑。這一刀切錯的代價是雙向的：終態錯誤被標成可重試、消費者的 retry 迴圈空轉壓垮服務；可重試被標成終態、暫時性故障變成使用者眼中的永久失敗。</p>
<p>HTTP status 承擔這一刀的粗分類（4xx 終態、5xx 與 429 可重試、見 <a href="/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3</a> 的 status 承諾段）、錯誤 body 承擔細分類。兩層要一致 — body 說可重試、status 給 400、中介層跟 SDK 只看 status、消費者的兩層邏輯就互相矛盾。</p>
<h2 id="格式設計標準與自訂並存的現實">格式設計：標準與自訂並存的現實</h2>
<p>錯誤格式有現行標準、也有大廠自成一格的成熟先例、兩者的設計目標一致：機器可判讀、人類可理解、格式可演化。</p>
<p>RFC 9457 定義 <code>application/problem+json</code>：<code>type</code>（URI）、<code>title</code>、<code>status</code>、<code>detail</code>、<code>instance</code> 五個核心成員、允許 extension members 且要求 client 忽略不認識的欄位（見 <a href="/blog/backend/11-api-design/cases/error-rfc9457-problem-details/" data-link-title="11.C35 RFC 9457：problem&#43;json 標準化錯誤格式" data-link-desc="type 用 URI 外部化錯誤命名空間、client 必須忽略未知欄位的演化條款、IANA registry 補 7807 碎片化">11.C35</a>）。兩個設計值得單獨理解：<code>type</code> 用 URI 而非字串 enum、把錯誤種類的命名空間外部化、跨團隊不撞名；「client MUST ignore unknown extensions」是格式的演化條款 — 服務端可以加欄位而不破壞既有消費者、等同錯誤模型的開放封閉原則。</p>
<p>Stripe 的錯誤物件早於這個標準自成一格、分層思路可以直接借用：<code>type</code> 承擔路由層（哪類錯誤、走哪條處理分支）、<code>code</code> 承擔分支層（細粒度機器碼）、<code>param</code> 加 <code>message</code> 承擔 UI 層（哪個欄位錯、給人看什麼）、三個正交欄位讓消費者各層各取所需（見 <a href="/blog/backend/11-api-design/cases/error-stripe-error-object/" data-link-title="11.C36 Stripe 錯誤物件：type / code / param 三層分離" data-link-desc="路由層、分支層、UI 層做成正交欄位；冪等衝突列 first-class 錯誤型別；標準前自成一格的對照組">11.C36</a>）。這個模型還藏著一個結構訊號：<code>idempotency_error</code> 是四個 type 之一 — 冪等衝突在支付 API 是預期常態、錯誤模型要為它保留一級位置（冪等語意主寫在 <a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8</a>）。</p>
<p>選標準還是自訂的判準：新 API 從 RFC 9457 起手、拿到現成的演化條款與工具生態；既有 API 有自訂格式且被大量依賴、遷移本身就是 breaking change、務實做法是把 9457 的兩個設計（type 命名空間化、未知欄位忽略條款）補進自訂格式、而非換格式。</p>
<h2 id="錯誤狀態下的系統行為">錯誤狀態下的系統行為</h2>
<p>錯誤模型的最後一段責任是「錯誤發生時、系統還敢做什麼」。Twilio 2013 年計費事故的教訓落在這：關鍵狀態讀不到、自動扣款卻繼續跑、演變成重複扣款（事故時序與冪等閘門的抽象、主寫在 <a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 的反例段</a>）。落到錯誤模型的通用判準：關鍵狀態讀寫失敗的錯誤處理、預設要往「拒絕服務」收斂、而非「帶著壞狀態繼續」— 錯誤分類表裡要有一類「狀態不可信、停止副作用」、它的處理路徑跟一般 5xx 不同。</p>
<h2 id="常見設計錯誤">常見設計錯誤</h2>
<ul>
<li><strong>業務失敗包 200</strong>：觀測與重試鏈失真、修法見 <a href="/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3 的 status 承諾段</a>。</li>
<li><strong>錯誤碼用連續數字</strong>：<code>code: 1047</code> 無命名空間、跨服務撞號、grep 不到語意 — 用可讀字串或 URI。</li>
<li><strong>message 當機器介面</strong>：消費者 parse 錯誤訊息文字做分支、訊息改字就是 breaking change — 機器分支一律走 type / code。</li>
<li><strong>錯誤格式沒有演化條款</strong>：第一版沒宣告「未知欄位忽略」、之後每次加欄位都無法確認安全性 — 條款從第一版就寫進文件。</li>
</ul>
<h2 id="爭論地圖與下一步">爭論地圖與下一步</h2>
<p>本章的分類與格式判準、以 HTTP transport 承載 status 語意為前提。錯誤格式的跨風格交鋒（RFC 9457、envelope 包裝、GraphQL 的 200-with-errors 慣例）是掛在本章的爭論深度文章 backlog（見 <a href="/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁</a> 章節規劃）— GraphQL 把 transport 層 status 跟業務錯誤解耦的做法、在該文攤開、本章不展開。</p>
<ul>
<li>可重試錯誤的消費端行為設計：<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 冪等與重放驗證</a></li>
<li>限流錯誤（429）的完整語意：<a href="/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9 對外流量語意</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.5 版本策略與 deprecation</title><link>https://tarrragon.github.io/blog/backend/11-api-design/versioning-and-deprecation/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/versioning-and-deprecation/</guid><description>&lt;p>版本策略是承諾的分期方式：介面要演進、消費者要穩定、版本機制決定兩者的張力由誰、在什麼時點、付出什麼成本來吸收。同一家公司的答案會隨規模演進 — Stripe 2011 年起用日期滾動版本、2017 年公開轉換層設計、現行方案又演進成具名 major release 加月度相容 release（&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-stripe-rolling-date-versions/" data-link-title="11.C10 Stripe：日期滾動版本與 version change module" data-link-desc="把相容性從路由層搬進轉換層、breaking change 成本由服務端一次吸收；date-based versioning 的原型案例">11.C10&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-stripe-named-major-releases/" data-link-title="11.C11 Stripe 現行方案：具名 major release 與相容變更清單" data-link-desc="同一家公司版本策略隨規模演進的第二個時間切片、附「什麼算 backwards-compatible」的明文清單">11.C11&lt;/a> 是同一策略的兩個時間切片）— 版本策略是活的設計、不是上線前勾一次的選項。&lt;/p>
&lt;h2 id="版本擺哪裡uriheader日期">版本擺哪裡：URI、header、日期&lt;/h2>
&lt;p>版本識別的三個主流位置、差異在「版本被當成什麼」：&lt;/p>
&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>URI 版本&lt;/td>
 &lt;td>&lt;code>/v1/orders&lt;/code>&lt;/td>
 &lt;td>版本是資源身分的一部分&lt;/td>
 &lt;td>業界大量存在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>header 版本&lt;/td>
 &lt;td>&lt;code>X-GitHub-Api-Version: 2022-11-28&lt;/code>&lt;/td>
 &lt;td>版本是內容協商&lt;/td>
 &lt;td>GitHub（C12）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>date-based pin&lt;/td>
 &lt;td>帳號 pin 首次呼叫日、header 可覆寫&lt;/td>
 &lt;td>版本是消費者的屬性&lt;/td>
 &lt;td>Stripe（C10）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>URI 版本的優勢是可見性：版本寫在每個請求上、curl 就能切版、快取與路由基礎設施天然按版本分流。代價是「v2」的粒度太粗 — 整個 API 一起翻版、消費者面對的是大遷移、服務端面對的是雙版本長期並行的維護。&lt;/p>
&lt;p>header 與 date-based 把版本移出資源身分、版本粒度可以細到單一 breaking change。GitHub 2022 年為 REST API 引入 calendar versioning 時同步給了承諾結構：新版釋出後舊版至少支援 24 個月（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-github-calendar-versioning/" data-link-title="11.C12 GitHub：REST API calendar versioning 與 24 個月支援承諾" data-link-desc="date-based versioning 成為大平台收斂方向的證據點、最低支援窗口把 deprecation 成本變成明文契約">11.C12&lt;/a>）— 支援窗口從隱性期待變成 SLA 式的明文契約、消費者可以據此排遷移計畫。Stripe 的 date-based pin 更進一步：帳號自動 pin 住首次呼叫時的版本、服務端把每個 breaking change 封裝成一個 version change module、response 依時間反向流過模組鏈、轉換成該帳號 pin 住版本的回應 schema — 截至 2017 年累積約 100 個 backwards-incompatible 升級、維持與 2011 年以來每一版相容（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-stripe-rolling-date-versions/" data-link-title="11.C10 Stripe：日期滾動版本與 version change module" data-link-desc="把相容性從路由層搬進轉換層、breaking change 成本由服務端一次吸收；date-based versioning 的原型案例">11.C10&lt;/a>；這種「服務端吸收」的成本分配框架見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1&lt;/a>）。&lt;/p>
&lt;p>選型判準回到 11.1 的成本分配：消費者多而異質、值得投資 header / date-based 加服務端吸收層；消費者少而可協調、URI 版本的簡單性划算。另一派主張版本化本身是錯的 — Fielding 的立場是「DON&amp;rsquo;T」、用 hypermedia 的執行期演化取代版本號（InfoQ 訪談、2014、見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-fielding-no-versioning/" data-link-title="11.C14 Fielding：對 API 版本化的建議是「別做」" data-link-desc="no-versioning 流派的理論錨點：hypermedia 演化取代版本號、與運營現實路線分歧的根源">11.C14&lt;/a>）；GraphQL 的 versionless 路線是這個方向的工程化實例（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/graphql-versionless-evolution/" data-link-title="11.C26 GraphQL 官方：versionless API 與 nullable-by-default" data-link-desc="no-versioning 的成本轉嫁鏈：只加不改、deprecation、nullable 預設三個紀律換掉版本號">11.C26&lt;/a>、紀律代價的深化見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/graphql/graphql-schema-evolution/" data-link-title="GraphQL Schema 演進：versionless 的紀律代價" data-link-desc="只加不改、deprecation 標注、nullable 預設怎麼共同取代版本號 — 以及每個紀律各自的隱藏帳單">Schema 演進&lt;/a>）。兩者的完整交鋒收在掛本章的「版本策略流派之爭」爭論文章 backlog（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁&lt;/a>）。&lt;/p>
&lt;h2 id="deprecation-的執行工具箱">Deprecation 的執行工具箱&lt;/h2>
&lt;p>宣告 deprecation 容易、讓長尾消費者實際完成遷移才是工程問題。公開案例累積出一組執行工具、各自解決通訊鏈的不同斷點：&lt;/p>
&lt;p>&lt;strong>分階段日期&lt;/strong>。Slack 收斂四族舊 API 到 Conversations API 時用三個日期各擋一種風險：宣告日起算、五個月後新建 app 拿不到舊方法（掐斷新增量）、十三個月後全面停用（處理存量）（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-slack-conversations-api-sunset/" data-link-title="11.C16 Slack：四族 API 分階段收斂到 Conversations API" data-link-desc="分階段 deprecation 執行：先掐斷新增量、再處理存量、最後硬停、過渡期用 in-band warning">11.C16&lt;/a>）。先斷增量再清存量的順序讓債務停止成長、清理才有終點。&lt;/p></description><content:encoded><![CDATA[<p>版本策略是承諾的分期方式：介面要演進、消費者要穩定、版本機制決定兩者的張力由誰、在什麼時點、付出什麼成本來吸收。同一家公司的答案會隨規模演進 — Stripe 2011 年起用日期滾動版本、2017 年公開轉換層設計、現行方案又演進成具名 major release 加月度相容 release（<a href="/blog/backend/11-api-design/cases/versioning-stripe-rolling-date-versions/" data-link-title="11.C10 Stripe：日期滾動版本與 version change module" data-link-desc="把相容性從路由層搬進轉換層、breaking change 成本由服務端一次吸收；date-based versioning 的原型案例">11.C10</a> 與 <a href="/blog/backend/11-api-design/cases/versioning-stripe-named-major-releases/" data-link-title="11.C11 Stripe 現行方案：具名 major release 與相容變更清單" data-link-desc="同一家公司版本策略隨規模演進的第二個時間切片、附「什麼算 backwards-compatible」的明文清單">11.C11</a> 是同一策略的兩個時間切片）— 版本策略是活的設計、不是上線前勾一次的選項。</p>
<h2 id="版本擺哪裡uriheader日期">版本擺哪裡：URI、header、日期</h2>
<p>版本識別的三個主流位置、差異在「版本被當成什麼」：</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>形式</th>
          <th>版本的語意</th>
          <th>代表</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>URI 版本</td>
          <td><code>/v1/orders</code></td>
          <td>版本是資源身分的一部分</td>
          <td>業界大量存在</td>
      </tr>
      <tr>
          <td>header 版本</td>
          <td><code>X-GitHub-Api-Version: 2022-11-28</code></td>
          <td>版本是內容協商</td>
          <td>GitHub（C12）</td>
      </tr>
      <tr>
          <td>date-based pin</td>
          <td>帳號 pin 首次呼叫日、header 可覆寫</td>
          <td>版本是消費者的屬性</td>
          <td>Stripe（C10）</td>
      </tr>
  </tbody>
</table>
<p>URI 版本的優勢是可見性：版本寫在每個請求上、curl 就能切版、快取與路由基礎設施天然按版本分流。代價是「v2」的粒度太粗 — 整個 API 一起翻版、消費者面對的是大遷移、服務端面對的是雙版本長期並行的維護。</p>
<p>header 與 date-based 把版本移出資源身分、版本粒度可以細到單一 breaking change。GitHub 2022 年為 REST API 引入 calendar versioning 時同步給了承諾結構：新版釋出後舊版至少支援 24 個月（見 <a href="/blog/backend/11-api-design/cases/versioning-github-calendar-versioning/" data-link-title="11.C12 GitHub：REST API calendar versioning 與 24 個月支援承諾" data-link-desc="date-based versioning 成為大平台收斂方向的證據點、最低支援窗口把 deprecation 成本變成明文契約">11.C12</a>）— 支援窗口從隱性期待變成 SLA 式的明文契約、消費者可以據此排遷移計畫。Stripe 的 date-based pin 更進一步：帳號自動 pin 住首次呼叫時的版本、服務端把每個 breaking change 封裝成一個 version change module、response 依時間反向流過模組鏈、轉換成該帳號 pin 住版本的回應 schema — 截至 2017 年累積約 100 個 backwards-incompatible 升級、維持與 2011 年以來每一版相容（見 <a href="/blog/backend/11-api-design/cases/versioning-stripe-rolling-date-versions/" data-link-title="11.C10 Stripe：日期滾動版本與 version change module" data-link-desc="把相容性從路由層搬進轉換層、breaking change 成本由服務端一次吸收；date-based versioning 的原型案例">11.C10</a>；這種「服務端吸收」的成本分配框架見 <a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1</a>）。</p>
<p>選型判準回到 11.1 的成本分配：消費者多而異質、值得投資 header / date-based 加服務端吸收層；消費者少而可協調、URI 版本的簡單性划算。另一派主張版本化本身是錯的 — Fielding 的立場是「DON&rsquo;T」、用 hypermedia 的執行期演化取代版本號（InfoQ 訪談、2014、見 <a href="/blog/backend/11-api-design/cases/versioning-fielding-no-versioning/" data-link-title="11.C14 Fielding：對 API 版本化的建議是「別做」" data-link-desc="no-versioning 流派的理論錨點：hypermedia 演化取代版本號、與運營現實路線分歧的根源">11.C14</a>）；GraphQL 的 versionless 路線是這個方向的工程化實例（見 <a href="/blog/backend/11-api-design/cases/graphql-versionless-evolution/" data-link-title="11.C26 GraphQL 官方：versionless API 與 nullable-by-default" data-link-desc="no-versioning 的成本轉嫁鏈：只加不改、deprecation、nullable 預設三個紀律換掉版本號">11.C26</a>、紀律代價的深化見 <a href="/blog/backend/11-api-design/styles/graphql/graphql-schema-evolution/" data-link-title="GraphQL Schema 演進：versionless 的紀律代價" data-link-desc="只加不改、deprecation 標注、nullable 預設怎麼共同取代版本號 — 以及每個紀律各自的隱藏帳單">Schema 演進</a>）。兩者的完整交鋒收在掛本章的「版本策略流派之爭」爭論文章 backlog（見 <a href="/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁</a>）。</p>
<h2 id="deprecation-的執行工具箱">Deprecation 的執行工具箱</h2>
<p>宣告 deprecation 容易、讓長尾消費者實際完成遷移才是工程問題。公開案例累積出一組執行工具、各自解決通訊鏈的不同斷點：</p>
<p><strong>分階段日期</strong>。Slack 收斂四族舊 API 到 Conversations API 時用三個日期各擋一種風險：宣告日起算、五個月後新建 app 拿不到舊方法（掐斷新增量）、十三個月後全面停用（處理存量）（見 <a href="/blog/backend/11-api-design/cases/versioning-slack-conversations-api-sunset/" data-link-title="11.C16 Slack：四族 API 分階段收斂到 Conversations API" data-link-desc="分階段 deprecation 執行：先掐斷新增量、再處理存量、最後硬停、過渡期用 in-band warning">11.C16</a>）。先斷增量再清存量的順序讓債務停止成長、清理才有終點。</p>
<p><strong>In-band warning</strong>。同一案例的過渡期、呼叫舊方法會在 response 收到 <code>method_deprecated</code> warning 加退場日期 — 訊號出現在開發者一定會看的地方（自己的 response）、觸及率高於任何公告渠道。</p>
<p><strong>Brownout</strong>。GitHub 廢止 Git 操作密碼認證前、在兩個預告時窗暫時停用再恢復（見 <a href="/blog/backend/11-api-design/cases/versioning-github-password-auth-brownout/" data-link-title="11.C13 GitHub：密碼認證廢止的 brownout 執行" data-link-desc="deprecation 執行機制案例：公告觸及不到的長尾 client、用排程 brownout 的短暫真實故障叫醒">11.C13</a>）— 沒讀公告的長尾消費者、只有短暫的真實故障能觸達、且是在低風險時窗先遭遇明確失敗、不是在強制日全面斷線。</p>
<p><strong>Sunset header</strong>。RFC 8594 定義用 HTTP header 宣告退場時點的機器可讀層（見 <a href="/blog/backend/11-api-design/cases/versioning-sunset-header-rfc8594/" data-link-title="11.C15 RFC 8594 Sunset header：退場宣告的機器可讀層" data-link-desc="用 HTTP header 宣告資源退場時點的標準化嘗試、Informational 地位與實務採用有限">11.C15</a>）— Informational 地位、實務採用有限、Slack 與 GitHub 都沒等它。引用價值是概念完整性：通訊鏈該有一層給程式讀、具體形式各家自選。</p>
<p>工具的組合邏輯：公告觸及會讀公告的人、in-band warning 觸及在開發的人、brownout 觸及所有人。退場計畫的完整度檢查是「三類人各被哪個工具覆蓋」。</p>
<h2 id="退場的量測與到期行為">退場的量測與到期行為</h2>
<p>退場決策要有數據：舊版的呼叫量、呼叫方分布、衰減曲線。量測的基礎設施跟 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 觀測</a> 共用、版本維度要進 metrics label。到期行為的設計原則承接 <a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1 的違約模式分析</a>：到期回明確錯誤、而非靜默改變語意 — Facebook Graph v1.0 的靜默語意切換反例在該段完整展開。</p>
<p>版本策略運作不良的訊號可以從三個地方讀：舊版呼叫量長期不衰減（deprecation 工具箱沒有形成遷移壓力）、多數帳號 pin 死在首版（新能力的價值不足以驅動升級、或遷移成本被低估）、每次退場都演變成客訴事故（通訊鏈有一類消費者始終沒被覆蓋）。三個訊號指向的修法分別是執行工具、版本內容、通訊覆蓋 — 對症下藥、而非一律延長支援期。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>什麼算 breaking、變更怎麼審：<a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律</a></li>
<li>承諾成本結構的上游框架：<a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1 API 作為服務邊界的責任</a></li>
<li>退場量測的觀測基礎：<a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 可觀測性平台</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.6 向後相容的變更紀律</title><link>https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/</guid><description>&lt;p>向後相容的變更紀律回答一個高頻的日常問題：這個 diff 能不能直接上。版本策略（&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5&lt;/a>）處理「決定要 breaking 之後怎麼辦」、本章處理更前面的一層 — 怎麼在每次變更時判定它 break 不 break、以及這個判定由人還是由工具把關。&lt;/p>
&lt;h2 id="breaking-的定義要明文且比直覺寬">Breaking 的定義要明文、且比直覺寬&lt;/h2>
&lt;p>變更紀律的地基是一份「什麼算 breaking」的明文清單、而且清單的範圍比直覺預期的寬。直覺抓得到的：刪欄位、改型別、改必填。直覺常漏的：改欄位預設值（消費者依賴舊預設）、改錯誤碼（消費者的分支邏輯建在上面）、改回應時序（輪詢邏輯依賴）、收緊驗證規則（昨天合法的請求今天 400）。反向的參照是 Stripe 明文的相容變更清單 — 新增資源、新增 optional 參數、新增 response property、property 順序改變、opaque ID 的長度格式改變、新增 event type（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-stripe-named-major-releases/" data-link-title="11.C11 Stripe 現行方案：具名 major release 與相容變更清單" data-link-desc="同一家公司版本策略隨規模演進的第二個時間切片、附「什麼算 backwards-compatible」的明文清單">11.C11&lt;/a>）：清單同時劃出「這些軸服務端保留自由」、消費者不可依賴。兩份清單（breaking 清單、相容清單）合起來才是完整的契約邊界、只有其中一份時灰色地帶照樣存在。&lt;/p>
&lt;h2 id="紀律的三個放置層格式工具流程">紀律的三個放置層：格式、工具、流程&lt;/h2>
&lt;p>相容紀律可以放在三個層、強度遞減、適用情境不同。&lt;/p>
&lt;p>&lt;strong>格式層&lt;/strong>：相容性做成編碼格式的性質、違規在技術上不可行或立即失效。protobuf 是代表 — field number 一旦投入使用即不可變更、刪除必須 reserve、重用會造成解碼歧義與資料損毀（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/grpc-protobuf-field-number-discipline/" data-link-title="11.C28 protobuf 官方規範：field number 紀律" data-link-desc="編號不可改、刪除必 reserve、重用導致資料損毀；契約相容性是編碼格式的性質、不是 review 慣例">11.C28&lt;/a>）；官方文件直接把 schema 變更分成 wire-safe、wire-unsafe 與 conditionally wire-compatible 三類 — 判定規則明文化之後、不依賴資深工程師在場。GraphQL 的 versionless 紀律同型、案例判讀把它歸納為三個支柱：只加不改、deprecation 標注、nullable 預設、由 schema 語言承載（C26 的判讀整理、觀察層見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/graphql-versionless-evolution/" data-link-title="11.C26 GraphQL 官方：versionless API 與 nullable-by-default" data-link-desc="no-versioning 的成本轉嫁鏈：只加不改、deprecation、nullable 預設三個紀律換掉版本號">11.C26&lt;/a>；GraphQL 內部機制的深化見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/graphql/graphql-schema-evolution/" data-link-title="GraphQL Schema 演進：versionless 的紀律代價" data-link-desc="只加不改、deprecation 標注、nullable 預設怎麼共同取代版本號 — 以及每個紀律各自的隱藏帳單">Schema 演進&lt;/a>）。&lt;/p>
&lt;p>&lt;strong>工具層&lt;/strong>：相容檢查做成 CI gate、在 merge 前擋下。Buf 的 breaking detection 對比歷史 schema、在 merge 前擋下破壞性變更、規則分四級（FILE、PACKAGE、WIRE_JSON、WIRE）、文件明言「Catching this before merge is the point」（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/grpc-buf-breaking-detection/" data-link-title="11.C29 Buf breaking detection：四級規則對應消費者依賴" data-link-desc="把 proto 相容紀律從人的自律升級成 CI gate、檢查粒度是產品決策不是工具預設">11.C29&lt;/a>）。從四級的分級設計可以抽出選級判準（C29 判讀）：選符合消費者實際依賴的等級 — 只走 wire 的消費者用 WIRE、有 generated code 依賴的要更嚴的級。這條主張可以推廣成本章的通用判準：&lt;strong>相容性檢查的粒度是產品決策、不是工具預設&lt;/strong> — 檢查太嚴、內部重構寸步難行；太鬆、消費者實際依賴的層沒被保護。HTTP+JSON 的對應工具是 OpenAPI diff 類檢查、把 spec 當 schema 跑同樣的 gate（工具治理見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-governance/" data-link-title="11.10 API 規範治理" data-link-desc="設計規範怎麼讓幾十個團隊持續遵守 — 提案制、Guild 制、分軌制的治理模式比較、linting 進 CI、規範失敗的成因">11.10 規範治理&lt;/a>）。&lt;/p>
&lt;p>&lt;strong>流程層&lt;/strong>：格式與工具都蓋不到的語意變更（預設值、時序、驗證規則）、由變更審查流程把關 — review checklist 上有「對照 breaking 清單」一項、重大變更走 API design review。流程層是三層裡唯一蓋得住全部變更類型的、也是唯一依賴人自覺的 — 所以務實的配置是三層疊加：格式層鎖結構、工具層鎖 spec、流程層鎖語意。&lt;/p>
&lt;h2 id="到期與豁免的邊界設計">到期與豁免的邊界設計&lt;/h2>
&lt;p>紀律需要兩個邊界條款才能長期運作。&lt;strong>到期行為&lt;/strong>：宣告過的 breaking 變更到期執行時、回明確錯誤而非靜默改語意 — 原則的完整推導與 Facebook Graph v1.0 的反例展開見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1 的違約模式段&lt;/a>；審查視角的增量是把到期行為當成變更提案的必填欄位、跟 brownout 這類預告機制（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/versioning-github-password-auth-brownout/" data-link-title="11.C13 GitHub：密碼認證廢止的 brownout 執行" data-link-desc="deprecation 執行機制案例：公告觸及不到的長尾 client、用排程 brownout 的短暫真實故障叫醒">11.C13&lt;/a>）一起在 review 時就定案、而非退場當天即興。&lt;strong>豁免宣告&lt;/strong>：每次變更公告要明列「誰不受影響」— GitHub 的密碼認證廢止同時明列 2FA 使用者、GHES、GitHub App 不受影響、讓多數消費者第一段就能停止閱讀、注意力留給真正要動的人。&lt;/p></description><content:encoded><![CDATA[<p>向後相容的變更紀律回答一個高頻的日常問題：這個 diff 能不能直接上。版本策略（<a href="/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5</a>）處理「決定要 breaking 之後怎麼辦」、本章處理更前面的一層 — 怎麼在每次變更時判定它 break 不 break、以及這個判定由人還是由工具把關。</p>
<h2 id="breaking-的定義要明文且比直覺寬">Breaking 的定義要明文、且比直覺寬</h2>
<p>變更紀律的地基是一份「什麼算 breaking」的明文清單、而且清單的範圍比直覺預期的寬。直覺抓得到的：刪欄位、改型別、改必填。直覺常漏的：改欄位預設值（消費者依賴舊預設）、改錯誤碼（消費者的分支邏輯建在上面）、改回應時序（輪詢邏輯依賴）、收緊驗證規則（昨天合法的請求今天 400）。反向的參照是 Stripe 明文的相容變更清單 — 新增資源、新增 optional 參數、新增 response property、property 順序改變、opaque ID 的長度格式改變、新增 event type（見 <a href="/blog/backend/11-api-design/cases/versioning-stripe-named-major-releases/" data-link-title="11.C11 Stripe 現行方案：具名 major release 與相容變更清單" data-link-desc="同一家公司版本策略隨規模演進的第二個時間切片、附「什麼算 backwards-compatible」的明文清單">11.C11</a>）：清單同時劃出「這些軸服務端保留自由」、消費者不可依賴。兩份清單（breaking 清單、相容清單）合起來才是完整的契約邊界、只有其中一份時灰色地帶照樣存在。</p>
<h2 id="紀律的三個放置層格式工具流程">紀律的三個放置層：格式、工具、流程</h2>
<p>相容紀律可以放在三個層、強度遞減、適用情境不同。</p>
<p><strong>格式層</strong>：相容性做成編碼格式的性質、違規在技術上不可行或立即失效。protobuf 是代表 — field number 一旦投入使用即不可變更、刪除必須 reserve、重用會造成解碼歧義與資料損毀（見 <a href="/blog/backend/11-api-design/cases/grpc-protobuf-field-number-discipline/" data-link-title="11.C28 protobuf 官方規範：field number 紀律" data-link-desc="編號不可改、刪除必 reserve、重用導致資料損毀；契約相容性是編碼格式的性質、不是 review 慣例">11.C28</a>）；官方文件直接把 schema 變更分成 wire-safe、wire-unsafe 與 conditionally wire-compatible 三類 — 判定規則明文化之後、不依賴資深工程師在場。GraphQL 的 versionless 紀律同型、案例判讀把它歸納為三個支柱：只加不改、deprecation 標注、nullable 預設、由 schema 語言承載（C26 的判讀整理、觀察層見 <a href="/blog/backend/11-api-design/cases/graphql-versionless-evolution/" data-link-title="11.C26 GraphQL 官方：versionless API 與 nullable-by-default" data-link-desc="no-versioning 的成本轉嫁鏈：只加不改、deprecation、nullable 預設三個紀律換掉版本號">11.C26</a>；GraphQL 內部機制的深化見 <a href="/blog/backend/11-api-design/styles/graphql/graphql-schema-evolution/" data-link-title="GraphQL Schema 演進：versionless 的紀律代價" data-link-desc="只加不改、deprecation 標注、nullable 預設怎麼共同取代版本號 — 以及每個紀律各自的隱藏帳單">Schema 演進</a>）。</p>
<p><strong>工具層</strong>：相容檢查做成 CI gate、在 merge 前擋下。Buf 的 breaking detection 對比歷史 schema、在 merge 前擋下破壞性變更、規則分四級（FILE、PACKAGE、WIRE_JSON、WIRE）、文件明言「Catching this before merge is the point」（見 <a href="/blog/backend/11-api-design/cases/grpc-buf-breaking-detection/" data-link-title="11.C29 Buf breaking detection：四級規則對應消費者依賴" data-link-desc="把 proto 相容紀律從人的自律升級成 CI gate、檢查粒度是產品決策不是工具預設">11.C29</a>）。從四級的分級設計可以抽出選級判準（C29 判讀）：選符合消費者實際依賴的等級 — 只走 wire 的消費者用 WIRE、有 generated code 依賴的要更嚴的級。這條主張可以推廣成本章的通用判準：<strong>相容性檢查的粒度是產品決策、不是工具預設</strong> — 檢查太嚴、內部重構寸步難行；太鬆、消費者實際依賴的層沒被保護。HTTP+JSON 的對應工具是 OpenAPI diff 類檢查、把 spec 當 schema 跑同樣的 gate（工具治理見 <a href="/blog/backend/11-api-design/api-governance/" data-link-title="11.10 API 規範治理" data-link-desc="設計規範怎麼讓幾十個團隊持續遵守 — 提案制、Guild 制、分軌制的治理模式比較、linting 進 CI、規範失敗的成因">11.10 規範治理</a>）。</p>
<p><strong>流程層</strong>：格式與工具都蓋不到的語意變更（預設值、時序、驗證規則）、由變更審查流程把關 — review checklist 上有「對照 breaking 清單」一項、重大變更走 API design review。流程層是三層裡唯一蓋得住全部變更類型的、也是唯一依賴人自覺的 — 所以務實的配置是三層疊加：格式層鎖結構、工具層鎖 spec、流程層鎖語意。</p>
<h2 id="到期與豁免的邊界設計">到期與豁免的邊界設計</h2>
<p>紀律需要兩個邊界條款才能長期運作。<strong>到期行為</strong>：宣告過的 breaking 變更到期執行時、回明確錯誤而非靜默改語意 — 原則的完整推導與 Facebook Graph v1.0 的反例展開見 <a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1 的違約模式段</a>；審查視角的增量是把到期行為當成變更提案的必填欄位、跟 brownout 這類預告機制（見 <a href="/blog/backend/11-api-design/cases/versioning-github-password-auth-brownout/" data-link-title="11.C13 GitHub：密碼認證廢止的 brownout 執行" data-link-desc="deprecation 執行機制案例：公告觸及不到的長尾 client、用排程 brownout 的短暫真實故障叫醒">11.C13</a>）一起在 review 時就定案、而非退場當天即興。<strong>豁免宣告</strong>：每次變更公告要明列「誰不受影響」— GitHub 的密碼認證廢止同時明列 2FA 使用者、GHES、GitHub App 不受影響、讓多數消費者第一段就能停止閱讀、注意力留給真正要動的人。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「這算不算 breaking」在 review 裡反覆吵</td>
          <td>缺明文清單、先立清單、吵架轉為修清單</td>
      </tr>
      <tr>
          <td>相容性事故的變更當初過了 CI</td>
          <td>檢查粒度低於消費者實際依賴的層、對照 buf 四級思路重選</td>
      </tr>
      <tr>
          <td>內部重構常被相容檢查誤擋</td>
          <td>檢查粒度高於任何消費者依賴的層、同上反向調整</td>
      </tr>
      <tr>
          <td>消費者依賴了相容清單裡宣告可變的性質</td>
          <td>契約已明文、屬消費者責任、但值得檢討清單的傳達位置</td>
      </tr>
  </tbody>
</table>
<p>四個訊號的排查有方向性：前一個的修法是後三個的前提 — 清單沒立好、CI 粒度沒有校準對象；粒度爭議反覆出現、多半是清單跟消費者依賴的實況脫節。從清單開始修、工具與流程的爭論通常跟著消失。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Breaking 決定要做之後的分期與退場：<a href="/blog/backend/11-api-design/versioning-and-deprecation/" data-link-title="11.5 版本策略與 deprecation" data-link-desc="版本方案怎麼選（URI/header/date-based）、支援窗口怎麼承諾、舊版怎麼安全退場 — 承諾分期與回收的操作設計">11.5 版本策略與 deprecation</a></li>
<li>消費端驗證（consumer-driven contract test 把「誰依賴什麼」顯性化）：<a href="/blog/backend/06-reliability/contract-testing/" data-link-title="6.10 Contract Testing 與 Schema 演進" data-link-desc="把跨服務 / API / event schema 的隱性期待變成可驗證契約，控制演進相容性">6.10 契約測試</a></li>
<li>相容檢查工具進 CI 的組織面：<a href="/blog/backend/11-api-design/api-governance/" data-link-title="11.10 API 規範治理" data-link-desc="設計規範怎麼讓幾十個團隊持續遵守 — 提案制、Guild 制、分軌制的治理模式比較、linting 進 CI、規範失敗的成因">11.10 API 規範治理</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.7 集合介面設計</title><link>https://tarrragon.github.io/blog/backend/11-api-design/collection-interface-design/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/collection-interface-design/</guid><description>&lt;p>集合介面的設計難點在規模是變數：同一個 list endpoint、資料一千筆時各方案差異可忽略、一億筆加高頻寫入時、當初的分頁決策變成效能與一致性問題。本章處理三個規模敏感的介面模式：分頁、批次、長時操作。&lt;/p>
&lt;h2 id="分頁offset-的兩個失效模式">分頁：offset 的兩個失效模式&lt;/h2>
&lt;p>offset 分頁（&lt;code>?page=3&amp;amp;limit=50&lt;/code>）的介面直觀、失效有明確的機制。Slack 的工程紀錄給了一手描述：其一、&lt;code>LIMIT / OFFSET&lt;/code> 深頁掃描 — 資料庫要掃過並丟棄前面所有列、頁數越深成本越高；其二、page window 漂移 — 高頻寫入下、兩次請求之間有新資料插入、消費者會看到跳項或重複（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/pagination-slack-cursor-migration/" data-link-title="11.C37 Slack：offset 到 opaque cursor 的分頁遷移" data-link-desc="深頁掃描與 page window 漂移兩個失效模式、opaque cursor 把分頁狀態表示權留在 server 端">11.C37&lt;/a>）。&lt;/p>
&lt;p>Slack 的解法是遷移到 opaque cursor：介面收斂為 &lt;code>cursor&lt;/code> 加 &lt;code>limit&lt;/code>、回傳 &lt;code>next_cursor&lt;/code>、cursor 內容 Base64 編碼、消費者不可解析。opaque 這個性質是設計重點 — &lt;strong>分頁狀態的表示權留在 server 端&lt;/strong>、消費者不能解析就不能依賴內部格式、server 可以自由更換底層策略（keyset、shard 位置、甚至混合）而不動介面。同一份紀錄明列了付出的代價：失去 total count 與跳頁能力 — 這是明示的產品決策、選 cursor 前要跟產品端確認「第 N 頁」跟「共幾筆」是不是真需求。&lt;/p>
&lt;p>判準：資料量小、寫入頻率低、產品要跳頁 — offset 合理且便宜；資料量大或寫入頻繁 — cursor、並從第一版就 opaque（先給透明 cursor 再收緊、又是一次 breaking change）。offset、cursor、keyset 的完整交鋒與「cursor 不透明性算承諾還是逃生門」的爭議、收在掛本章的分頁爭論文章 backlog（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁&lt;/a>）。&lt;/p>
&lt;h2 id="批次操作部分失敗是預設不是例外">批次操作：部分失敗是預設、不是例外&lt;/h2>
&lt;p>批次介面（一次建立 100 筆）的核心設計問題是部分失敗語意：第 37 筆驗證失敗、前 36 筆算什麼。三種可承諾的語意、各有成立情境：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>全有全無&lt;/strong>：包成一個 transaction、任一筆失敗全部回滾。語意最乾淨、消費者重試最簡單（整包重送）；成本是 server 端要撐住大 transaction、且單筆失敗導致整批回滾的體驗、在大批次下代價過高。&lt;/li>
&lt;li>&lt;strong>獨立處理、逐筆回報&lt;/strong>：回應是跟請求等長的結果陣列、每筆自己的成功或錯誤。務實預設、但消費者的重試邏輯變複雜 — 要能只重送失敗子集、這又要求逐筆操作冪等（&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8&lt;/a> 的主題）。&lt;/li>
&lt;li>&lt;strong>fail-fast&lt;/strong>：處理到第一個錯誤即停、回報已處理數。適合順序有意義的批次（匯入）、消費者從斷點續傳。&lt;/li>
&lt;/ul>
&lt;p>判準是消費者的重試能力與資料的順序性；唯一的反模式是不宣告 — 文件沒寫部分失敗語意的批次介面、消費者只能拿 production 事故來逆向工程。&lt;/p>
&lt;h2 id="長時操作把進行中實體化">長時操作：把「進行中」實體化&lt;/h2>
&lt;p>超過請求逾時預算的操作（報表、匯入、佈建）、介面要回的是「工作的身分」而非結果。Google AIP-151 是這個模式的系統化規範：長時方法回傳 Operation resource、client 輪詢其 &lt;code>done&lt;/code> / &lt;code>response&lt;/code> / &lt;code>error&lt;/code> 狀態、回應型別事先宣告、operation 約 30 天過期（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/longrun-google-aip151/" data-link-title="11.C44 Google AIP-151：長時操作實體化成 Operation resource" data-link-desc="202 &amp;#43; 輪詢模式的系統化版本：回應型別先宣告、client 統一寫一套 polling、operation 生命週期明訂">11.C44&lt;/a>）。比起裸的 202 加 Location、Operation resource 的增量價值在統一：所有長時操作共用同一個查詢介面、client 寫一套 polling 邏輯到處用；&lt;code>done=true&lt;/code> 直接回的 validate-only 條款、示範了用同一個介面模式涵蓋同步捷徑的手法（C44 判讀）。&lt;/p>
&lt;p>設計時要明訂的三件事：operation 的生命週期（查詢結果保留多久 — AIP 選 30 天）、輪詢的節奏指引（配合 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9&lt;/a> 的限流語意、避免消費者用 while-true 打爆查詢端點）、以及完成通知的替代路徑（webhook 回呼、屬 styles/realtime 的 backlog 範圍）。&lt;/p>
&lt;h2 id="常見設計錯誤">常見設計錯誤&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>透明 cursor&lt;/strong>：消費者 decode 後依賴內部欄位、底層換策略即斷 — 從第一版就 opaque。&lt;/li>
&lt;li>&lt;strong>批次語意未宣告&lt;/strong>：部分失敗行為靠消費者猜。&lt;/li>
&lt;li>&lt;strong>長時操作同步等&lt;/strong>：把 5 分鐘的工作掛在一條 HTTP 連線上、逾時、重試、重複執行三連發。&lt;/li>
&lt;li>&lt;strong>list 端點無上限&lt;/strong>：&lt;code>limit&lt;/code> 沒有 max、一個請求拉全表 — 上限是集合介面的基本流量防線（完整語意見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9&lt;/a>）。&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>批次重試的前提：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計&lt;/a>&lt;/li>
&lt;li>深頁掃描背後的資料庫機制：&lt;a href="https://tarrragon.github.io/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&amp;#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 Query 反模式&lt;/a>&lt;/li>
&lt;li>案例原文：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>集合介面的設計難點在規模是變數：同一個 list endpoint、資料一千筆時各方案差異可忽略、一億筆加高頻寫入時、當初的分頁決策變成效能與一致性問題。本章處理三個規模敏感的介面模式：分頁、批次、長時操作。</p>
<h2 id="分頁offset-的兩個失效模式">分頁：offset 的兩個失效模式</h2>
<p>offset 分頁（<code>?page=3&amp;limit=50</code>）的介面直觀、失效有明確的機制。Slack 的工程紀錄給了一手描述：其一、<code>LIMIT / OFFSET</code> 深頁掃描 — 資料庫要掃過並丟棄前面所有列、頁數越深成本越高；其二、page window 漂移 — 高頻寫入下、兩次請求之間有新資料插入、消費者會看到跳項或重複（見 <a href="/blog/backend/11-api-design/cases/pagination-slack-cursor-migration/" data-link-title="11.C37 Slack：offset 到 opaque cursor 的分頁遷移" data-link-desc="深頁掃描與 page window 漂移兩個失效模式、opaque cursor 把分頁狀態表示權留在 server 端">11.C37</a>）。</p>
<p>Slack 的解法是遷移到 opaque cursor：介面收斂為 <code>cursor</code> 加 <code>limit</code>、回傳 <code>next_cursor</code>、cursor 內容 Base64 編碼、消費者不可解析。opaque 這個性質是設計重點 — <strong>分頁狀態的表示權留在 server 端</strong>、消費者不能解析就不能依賴內部格式、server 可以自由更換底層策略（keyset、shard 位置、甚至混合）而不動介面。同一份紀錄明列了付出的代價：失去 total count 與跳頁能力 — 這是明示的產品決策、選 cursor 前要跟產品端確認「第 N 頁」跟「共幾筆」是不是真需求。</p>
<p>判準：資料量小、寫入頻率低、產品要跳頁 — offset 合理且便宜；資料量大或寫入頻繁 — cursor、並從第一版就 opaque（先給透明 cursor 再收緊、又是一次 breaking change）。offset、cursor、keyset 的完整交鋒與「cursor 不透明性算承諾還是逃生門」的爭議、收在掛本章的分頁爭論文章 backlog（見 <a href="/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁</a>）。</p>
<h2 id="批次操作部分失敗是預設不是例外">批次操作：部分失敗是預設、不是例外</h2>
<p>批次介面（一次建立 100 筆）的核心設計問題是部分失敗語意：第 37 筆驗證失敗、前 36 筆算什麼。三種可承諾的語意、各有成立情境：</p>
<ul>
<li><strong>全有全無</strong>：包成一個 transaction、任一筆失敗全部回滾。語意最乾淨、消費者重試最簡單（整包重送）；成本是 server 端要撐住大 transaction、且單筆失敗導致整批回滾的體驗、在大批次下代價過高。</li>
<li><strong>獨立處理、逐筆回報</strong>：回應是跟請求等長的結果陣列、每筆自己的成功或錯誤。務實預設、但消費者的重試邏輯變複雜 — 要能只重送失敗子集、這又要求逐筆操作冪等（<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8</a> 的主題）。</li>
<li><strong>fail-fast</strong>：處理到第一個錯誤即停、回報已處理數。適合順序有意義的批次（匯入）、消費者從斷點續傳。</li>
</ul>
<p>判準是消費者的重試能力與資料的順序性；唯一的反模式是不宣告 — 文件沒寫部分失敗語意的批次介面、消費者只能拿 production 事故來逆向工程。</p>
<h2 id="長時操作把進行中實體化">長時操作：把「進行中」實體化</h2>
<p>超過請求逾時預算的操作（報表、匯入、佈建）、介面要回的是「工作的身分」而非結果。Google AIP-151 是這個模式的系統化規範：長時方法回傳 Operation resource、client 輪詢其 <code>done</code> / <code>response</code> / <code>error</code> 狀態、回應型別事先宣告、operation 約 30 天過期（見 <a href="/blog/backend/11-api-design/cases/longrun-google-aip151/" data-link-title="11.C44 Google AIP-151：長時操作實體化成 Operation resource" data-link-desc="202 &#43; 輪詢模式的系統化版本：回應型別先宣告、client 統一寫一套 polling、operation 生命週期明訂">11.C44</a>）。比起裸的 202 加 Location、Operation resource 的增量價值在統一：所有長時操作共用同一個查詢介面、client 寫一套 polling 邏輯到處用；<code>done=true</code> 直接回的 validate-only 條款、示範了用同一個介面模式涵蓋同步捷徑的手法（C44 判讀）。</p>
<p>設計時要明訂的三件事：operation 的生命週期（查詢結果保留多久 — AIP 選 30 天）、輪詢的節奏指引（配合 <a href="/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9</a> 的限流語意、避免消費者用 while-true 打爆查詢端點）、以及完成通知的替代路徑（webhook 回呼、屬 styles/realtime 的 backlog 範圍）。</p>
<h2 id="常見設計錯誤">常見設計錯誤</h2>
<ul>
<li><strong>透明 cursor</strong>：消費者 decode 後依賴內部欄位、底層換策略即斷 — 從第一版就 opaque。</li>
<li><strong>批次語意未宣告</strong>：部分失敗行為靠消費者猜。</li>
<li><strong>長時操作同步等</strong>：把 5 分鐘的工作掛在一條 HTTP 連線上、逾時、重試、重複執行三連發。</li>
<li><strong>list 端點無上限</strong>：<code>limit</code> 沒有 max、一個請求拉全表 — 上限是集合介面的基本流量防線（完整語意見 <a href="/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9</a>）。</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>批次重試的前提：<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計</a></li>
<li>深頁掃描背後的資料庫機制：<a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 Query 反模式</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.8 API 層冪等設計</title><link>https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/</guid><description>&lt;p>API 層冪等處理一個無法迴避的物理事實：網路請求會在結果不明時中斷、消費者只能重送。POST 這類無冪等承諾的操作（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3 的 method 承諾段&lt;/a>）、重送就可能重複執行 — 支付重複扣款、訂單重複建立。冪等鍵機制是對外的補強契約：消費者為每個操作生成唯一 key、服務端保證同 key 的重送拿到同樣結局。本章寫這份契約的條款設計；內部去重的處理實作在 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計&lt;/a>、冪等性質的驗證在 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 冪等與重放驗證&lt;/a>、本章只收對外語意。&lt;/p>
&lt;h2 id="冪等是協作三種失敗點是分析骨架">冪等是協作、三種失敗點是分析骨架&lt;/h2>
&lt;p>冪等契約由兩端共同履行、server 端的 replay 快取只解一半。Stripe 的設計文章把失敗拆成三個時點 — 連線建立前失敗（重送安全、根本沒到 server）、執行中失敗（server 要決定 replay 什麼）、回應遺失（操作成功了、消費者不知道）— 並明確劃出 client 端的責任：exponential backoff 加 jitter、否則故障當下的集體重試會壓垮服務（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/idempotency-stripe-design-blog/" data-link-title="11.C38 Stripe 冪等設計哲學：retry 是 client-server 協作" data-link-desc="三種失敗點的 replay 行為分析、client 端 backoff &amp;#43; jitter 責任；冪等只做 server 半邊會放大故障">11.C38&lt;/a>）。三個時點是設計冪等機制時的分析骨架：replay 行為要對三種情況分別給出答案。&lt;/p>
&lt;h2 id="冪等契約的條款清單">冪等契約的條款清單&lt;/h2>
&lt;p>冪等契約要承諾的條款有五項；逐條的基準參照是 Stripe 的 API reference — 目前公開文件裡最明確的一份（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/idempotency-stripe-api-contract/" data-link-title="11.C39 Stripe 冪等鍵契約條款：24h 保存、500 也重放" data-link-desc="可承諾的冪等契約細節：重放的是該次請求的結局而非成功結果、同 key 不同參數即錯">11.C39&lt;/a>）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>key 由誰生成&lt;/strong>：消費者生成、建議 UUIDv4、上限 255 字元 — key 代表「消費者眼中的同一次操作」、只有消費者知道邊界。&lt;/li>
&lt;li>&lt;strong>存多久&lt;/strong>：至少 24 小時、逾期同 key 視為新請求 — 保存期是承諾、要明文、消費者據此設計重試窗口。&lt;/li>
&lt;li>&lt;strong>replay 回什麼&lt;/strong>：首次請求的 status code 加 body、&lt;strong>包含 500 也照樣快取重放&lt;/strong>。這條最容易自建做錯 — 快取的是「該次請求的結局」、而非「成功結果」；只快取成功的實作、會在 server 錯誤後讓同 key 重試觸發第二次執行、冪等保證在最需要它的時刻失效。&lt;/li>
&lt;li>&lt;strong>衝突怎麼回&lt;/strong>：同 key 不同參數直接報錯 — key 綁定請求語意、防止被當 session id 濫用。Stripe 的錯誤模型甚至為此保留一級錯誤型別 &lt;code>idempotency_error&lt;/code>（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/error-model-design/" data-link-title="11.4 錯誤模型設計" data-link-desc="錯誤該分幾類、格式怎麼定才有演化空間、機器判讀跟人類訊息怎麼分工 — 錯誤作為契約一級公民的設計判準">11.4&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>作用範圍&lt;/strong>：只限 POST — GET 與 DELETE 的冪等由 method 語意承諾、不需要 key。&lt;/li>
&lt;/ul>
&lt;h2 id="無標準的現況條款逐家不同">無標準的現況：條款逐家不同&lt;/h2>
&lt;p>冪等鍵是「業界事實標準先行、正式標準停滯」的典型。IETF 的 Idempotency-Key header draft 推進到第 7 版後過期、狀態 expired（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/idempotency-ietf-key-header-draft/" data-link-title="11.C40 IETF Idempotency-Key draft：標準化停在 expired" data-link-desc="de facto 先於 de jure 的具體例：業界事實標準先行、IETF 標準化跟進後停滯；引用必標 expired 狀態">11.C40&lt;/a>）— 引用它只能引語意骨架、不能宣稱 RFC。後果是各家條款實際有差、對照 PayPal 可見三個維度（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/idempotency-paypal-request-id/" data-link-title="11.C41 PayPal-Request-Id：同語意、不同契約的冪等實作" data-link-desc="跟 Stripe 的三點對照：header 命名、replay 語意（最新狀態 vs 首次結局）、契約精確度">11.C41&lt;/a>）：header 命名不同（&lt;code>PayPal-Request-Id&lt;/code>）；replay 語意不同 — Stripe 回「首次結局快照」、PayPal 回「前次請求的最新狀態」、後者對非同步操作友善、但失去 exactly-once 回應保證；契約精確度不同 — Stripe 承諾 24h、PayPal 的保存期寫「a period of time」。設計自己的 API 時、這三個維度就是條款檢查表；整合外部 API 時、逐家讀條款、拿 Stripe 的語意假設去打 PayPal 會踩錯。&lt;/p></description><content:encoded><![CDATA[<p>API 層冪等處理一個無法迴避的物理事實：網路請求會在結果不明時中斷、消費者只能重送。POST 這類無冪等承諾的操作（見 <a href="/blog/backend/11-api-design/resource-modeling-operation-semantics/" data-link-title="11.3 資源建模與操作語意" data-link-desc="endpoint 該建模成資源還是動作、HTTP method 與 status 承諾了什麼、available actions 由誰計算 — 建模決策的判準">11.3 的 method 承諾段</a>）、重送就可能重複執行 — 支付重複扣款、訂單重複建立。冪等鍵機制是對外的補強契約：消費者為每個操作生成唯一 key、服務端保證同 key 的重送拿到同樣結局。本章寫這份契約的條款設計；內部去重的處理實作在 <a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、冪等性質的驗證在 <a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 冪等與重放驗證</a>、本章只收對外語意。</p>
<h2 id="冪等是協作三種失敗點是分析骨架">冪等是協作、三種失敗點是分析骨架</h2>
<p>冪等契約由兩端共同履行、server 端的 replay 快取只解一半。Stripe 的設計文章把失敗拆成三個時點 — 連線建立前失敗（重送安全、根本沒到 server）、執行中失敗（server 要決定 replay 什麼）、回應遺失（操作成功了、消費者不知道）— 並明確劃出 client 端的責任：exponential backoff 加 jitter、否則故障當下的集體重試會壓垮服務（見 <a href="/blog/backend/11-api-design/cases/idempotency-stripe-design-blog/" data-link-title="11.C38 Stripe 冪等設計哲學：retry 是 client-server 協作" data-link-desc="三種失敗點的 replay 行為分析、client 端 backoff &#43; jitter 責任；冪等只做 server 半邊會放大故障">11.C38</a>）。三個時點是設計冪等機制時的分析骨架：replay 行為要對三種情況分別給出答案。</p>
<h2 id="冪等契約的條款清單">冪等契約的條款清單</h2>
<p>冪等契約要承諾的條款有五項；逐條的基準參照是 Stripe 的 API reference — 目前公開文件裡最明確的一份（見 <a href="/blog/backend/11-api-design/cases/idempotency-stripe-api-contract/" data-link-title="11.C39 Stripe 冪等鍵契約條款：24h 保存、500 也重放" data-link-desc="可承諾的冪等契約細節：重放的是該次請求的結局而非成功結果、同 key 不同參數即錯">11.C39</a>）：</p>
<ul>
<li><strong>key 由誰生成</strong>：消費者生成、建議 UUIDv4、上限 255 字元 — key 代表「消費者眼中的同一次操作」、只有消費者知道邊界。</li>
<li><strong>存多久</strong>：至少 24 小時、逾期同 key 視為新請求 — 保存期是承諾、要明文、消費者據此設計重試窗口。</li>
<li><strong>replay 回什麼</strong>：首次請求的 status code 加 body、<strong>包含 500 也照樣快取重放</strong>。這條最容易自建做錯 — 快取的是「該次請求的結局」、而非「成功結果」；只快取成功的實作、會在 server 錯誤後讓同 key 重試觸發第二次執行、冪等保證在最需要它的時刻失效。</li>
<li><strong>衝突怎麼回</strong>：同 key 不同參數直接報錯 — key 綁定請求語意、防止被當 session id 濫用。Stripe 的錯誤模型甚至為此保留一級錯誤型別 <code>idempotency_error</code>（見 <a href="/blog/backend/11-api-design/error-model-design/" data-link-title="11.4 錯誤模型設計" data-link-desc="錯誤該分幾類、格式怎麼定才有演化空間、機器判讀跟人類訊息怎麼分工 — 錯誤作為契約一級公民的設計判準">11.4</a>）。</li>
<li><strong>作用範圍</strong>：只限 POST — GET 與 DELETE 的冪等由 method 語意承諾、不需要 key。</li>
</ul>
<h2 id="無標準的現況條款逐家不同">無標準的現況：條款逐家不同</h2>
<p>冪等鍵是「業界事實標準先行、正式標準停滯」的典型。IETF 的 Idempotency-Key header draft 推進到第 7 版後過期、狀態 expired（見 <a href="/blog/backend/11-api-design/cases/idempotency-ietf-key-header-draft/" data-link-title="11.C40 IETF Idempotency-Key draft：標準化停在 expired" data-link-desc="de facto 先於 de jure 的具體例：業界事實標準先行、IETF 標準化跟進後停滯；引用必標 expired 狀態">11.C40</a>）— 引用它只能引語意骨架、不能宣稱 RFC。後果是各家條款實際有差、對照 PayPal 可見三個維度（見 <a href="/blog/backend/11-api-design/cases/idempotency-paypal-request-id/" data-link-title="11.C41 PayPal-Request-Id：同語意、不同契約的冪等實作" data-link-desc="跟 Stripe 的三點對照：header 命名、replay 語意（最新狀態 vs 首次結局）、契約精確度">11.C41</a>）：header 命名不同（<code>PayPal-Request-Id</code>）；replay 語意不同 — Stripe 回「首次結局快照」、PayPal 回「前次請求的最新狀態」、後者對非同步操作友善、但失去 exactly-once 回應保證；契約精確度不同 — Stripe 承諾 24h、PayPal 的保存期寫「a period of time」。設計自己的 API 時、這三個維度就是條款檢查表；整合外部 API 時、逐家讀條款、拿 Stripe 的語意假設去打 PayPal 會踩錯。</p>
<h2 id="反例冪等閘門缺席的內部迴圈">反例：冪等閘門缺席的內部迴圈</h2>
<p>冪等的適用範圍大於對外 header — 系統內部會自動重試的 side-effect 動作、同樣需要閘門。Twilio 2013 年計費事故的時序（C45 觀察層）：Redis 故障讓餘額資料遺失歸零且無法寫回、auto-recharge 在「餘額為零、扣款成功、餘額寫不回去」的循環中對約 1.4% 客戶重複扣款（見 <a href="/blog/backend/11-api-design/cases/idempotency-twilio-billing-postmortem/" data-link-title="11.C45 Twilio 2013 計費事故：無冪等防線的重複扣款（反例）" data-link-desc="反例：內部 retry 迴圈缺冪等閘門等效於無限重放、fail-safe 是金流 side effect 的斷路器">11.C45</a>、反例）。把事故抽象成冪等語言（判讀層）：扣款的觸發條件在執行後未被消除、等效於無限重放的非冪等操作。通用形式：冪等閘門 = 執行紀錄先寫、後執行副作用；配套的 fail-safe = 狀態寫不進去、就不准產生金流級 side effect。對外的 idempotency key 只是這個通用形式在 API 邊界的特例。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>客訴重複扣款 / 重複下單、都發生在超時後</td>
          <td>消費者在結果不明時重送、冪等鍵機制缺位或未強制</td>
      </tr>
      <tr>
          <td>replay 快取只存成功結果</td>
          <td>「500 也重放」條款缺、server 錯誤後的重試會二次執行</td>
      </tr>
      <tr>
          <td>同 key 被觀察到跨操作重用</td>
          <td>衝突條款缺（同 key 不同參數要報錯）、key 正在被當 session</td>
      </tr>
      <tr>
          <td>文件沒寫 key 保存期</td>
          <td>消費者無法設計重試窗口、條款補明文</td>
      </tr>
  </tbody>
</table>
<p>這四個訊號有共同的檢查入口：把五項條款清單當 checklist 對照自家文件、缺哪條補哪條 — 客訴類訊號（第一列）是條款缺位的滯後指標、等它出現才補、代價已經發生。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>冪等衝突的錯誤表達：<a href="/blog/backend/11-api-design/error-model-design/" data-link-title="11.4 錯誤模型設計" data-link-desc="錯誤該分幾類、格式怎麼定才有演化空間、機器判讀跟人類訊息怎麼分工 — 錯誤作為契約一級公民的設計判準">11.4 錯誤模型設計</a></li>
<li>retry 節奏與限流的互動：<a href="/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9 對外流量語意</a></li>
<li>內部交付語意（at-least-once 下的去重處理與驗證）：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 冪等與重放驗證</a>、<a href="/blog/backend/knowledge-cards/processing-semantics/" data-link-title="Processing Semantics" data-link-desc="說明 consumer 處理事件後業務結果是否正確，與投遞成功分屬不同責任">Processing Semantics 知識卡</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.9 對外流量語意</title><link>https://tarrragon.github.io/blog/backend/11-api-design/external-traffic-semantics/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/external-traffic-semantics/</guid><description>&lt;p>限流的執行機制在 gateway、限流的對外語意在契約 — 本章收後者。消費者面對限流時要回答的問題全部來自介面設計：怎麼知道額度多少、怎麼知道快用完了、被擋之後等多久重試。執行面的 token bucket、分散式計數器屬 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台&lt;/a> 的入口層範圍（該模組現有章節尚未主寫限流實作、屬其 backlog）；本章的問題是這些機制對外承諾成什麼語意。&lt;/p>
&lt;h2 id="承諾邊界配額資訊是預警不是保證">承諾邊界：配額資訊是預警、不是保證&lt;/h2>
&lt;p>限流語意的第一條設計原則是明確劃出承諾邊界。IETF 的 RateLimit header draft（active、v11、引用需標狀態）把配額資訊拆成兩個 header — &lt;code>RateLimit-Policy&lt;/code>（靜態政策：quota、window、partition key）與 &lt;code>RateLimit&lt;/code>（動態剩餘量）— 並明文「client MUST NOT assume 正配額保證下一請求會被服務」（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/ratelimit-ietf-header-fields/" data-link-title="11.C42 IETF RateLimit headers：政策與狀態拆兩個 header" data-link-desc="限流 header 的承諾邊界：informational only、不是 SLA；active draft、引用標版本">11.C42&lt;/a>）。兩個設計都值得借用：政策與即時狀態拆開、政策可快取、狀態逐請求變動；informational only 條款把配額 header 定位成禮貌性預警而非 SLA — 服務端保留在異常流量下提前收緊的權利、消費者的正確姿勢是把 429 處理寫對、而非精算剩餘配額。&lt;/p>
&lt;h2 id="429-與-retry-after被擋之後的契約">429 與 Retry-After：被擋之後的契約&lt;/h2>
&lt;p>拒絕本身也是介面。可承諾的最小集合：status 用 429（讓消費者與中介層知道「可重試、但要等」、跟終態 4xx 區分、錯誤分類見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/error-model-design/" data-link-title="11.4 錯誤模型設計" data-link-desc="錯誤該分幾類、格式怎麼定才有演化空間、機器判讀跟人類訊息怎麼分工 — 錯誤作為契約一級公民的設計判準">11.4&lt;/a>）；&lt;code>Retry-After&lt;/code> 給等待時間、且服務端說到做到 — 消費者等滿再來就該被服務、否則 Retry-After 淪為裝飾、消費者退回盲目退避。GitHub 的文件在這條上有可指出的語意瑕疵：超限回 403 或 429、文件未明確劃分兩者的使用時機（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/ratelimit-github-primary-secondary/" data-link-title="11.C43 GitHub 雙層限流：primary / secondary 與 x-ratelimit 契約" data-link-desc="單一維度配額擋不住真實濫用、前標準時代 x- header 與 IETF 命名並存的遷移期現實">11.C43&lt;/a>）— 消費者要同時處理兩種 status、分支邏輯多一倍。設計新 API 時可直接採納的判準：拒絕的 status 只用一個。&lt;/p>
&lt;p>消費端的配套是 backoff 加 jitter（同 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8&lt;/a> 的 retry 責任）— 限流語意跟冪等語意在消費端匯合：429 之後的重送、要嘛操作本身冪等、要嘛帶 idempotency key。&lt;/p>
&lt;h2 id="單一維度擋不住真實濫用">單一維度擋不住真實濫用&lt;/h2>
&lt;p>GitHub 的雙層限流揭露配額設計的一個實證：primary limit（每小時總額度）之外、還有 secondary limits — 並發上限、單端點吞吐、CPU 時間、內容建立速率（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/ratelimit-github-primary-secondary/" data-link-title="11.C43 GitHub 雙層限流：primary / secondary 與 x-ratelimit 契約" data-link-desc="單一維度配額擋不住真實濫用、前標準時代 x- header 與 IETF 命名並存的遷移期現實">11.C43&lt;/a>）。存在本身就是判讀：單一維度的請求計數擋不住真實濫用模式 — 額度內的高並發、額度內的單端點轟炸、額度內的重查詢。設計配額時從「要保護什麼資源」反推維度、而非從「一小時幾次」起手。&lt;/p>
&lt;p>同一案例也記錄了 header 命名的遷移期現實：GitHub 的 &lt;code>x-ratelimit-*&lt;/code> 是前標準時代的事實慣例、與 IETF 標準命名並存 — 新 API 該出標準 header、消費端 SDK 仍要能讀 x- 系、兩者會共存很久。&lt;/p>
&lt;h2 id="成本模型per-request-假設的破裂">成本模型：per-request 假設的破裂&lt;/h2>
&lt;p>「一個請求算一次」的配額假設、在請求成本不是常數的風格下失效。GitHub 的 GraphQL API 為此建立 point system：依 query 的展開規模計算成本、配額按點數計、另設結構上限當靜態防線、且成本對消費者可預估可查詢（參數細節與機制展開主寫在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/graphql/graphql-execution-cost-security/" data-link-title="GraphQL 執行成本與攻擊面" data-link-desc="resolver 執行模型讓請求成本不再是常數 — N&amp;#43;1 的基礎設施化、成本計點限流、introspection 偵察、persisted queries 的收斂路線">GraphQL 執行成本與攻擊面&lt;/a>、案例 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/graphql-github-cost-rate-limiting/" data-link-title="11.C19 GitHub：GraphQL point system 成本計點限流" data-link-desc="GraphQL 打破 per-request 限流假設、平台被迫發明查詢成本模型、加 node 上限雙層防線">11.C19&lt;/a>）。判準層的收穫有兩條：請求成本變動大的介面（GraphQL、批次、重查詢）、配額要計成本而非計次；成本模型本身要對消費者透明可預估 — 消費者無法預估成本、就無法設計合規的 client。執行成本的機制細節（resolver 展開、N+1、persisted queries）主寫在 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/styles/graphql/graphql-execution-cost-security/" data-link-title="GraphQL 執行成本與攻擊面" data-link-desc="resolver 執行模型讓請求成本不再是常數 — N&amp;#43;1 的基礎設施化、成本計點限流、introspection 偵察、persisted queries 的收斂路線">GraphQL 執行成本與攻擊面&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>限流的執行機制在 gateway、限流的對外語意在契約 — 本章收後者。消費者面對限流時要回答的問題全部來自介面設計：怎麼知道額度多少、怎麼知道快用完了、被擋之後等多久重試。執行面的 token bucket、分散式計數器屬 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台</a> 的入口層範圍（該模組現有章節尚未主寫限流實作、屬其 backlog）；本章的問題是這些機制對外承諾成什麼語意。</p>
<h2 id="承諾邊界配額資訊是預警不是保證">承諾邊界：配額資訊是預警、不是保證</h2>
<p>限流語意的第一條設計原則是明確劃出承諾邊界。IETF 的 RateLimit header draft（active、v11、引用需標狀態）把配額資訊拆成兩個 header — <code>RateLimit-Policy</code>（靜態政策：quota、window、partition key）與 <code>RateLimit</code>（動態剩餘量）— 並明文「client MUST NOT assume 正配額保證下一請求會被服務」（見 <a href="/blog/backend/11-api-design/cases/ratelimit-ietf-header-fields/" data-link-title="11.C42 IETF RateLimit headers：政策與狀態拆兩個 header" data-link-desc="限流 header 的承諾邊界：informational only、不是 SLA；active draft、引用標版本">11.C42</a>）。兩個設計都值得借用：政策與即時狀態拆開、政策可快取、狀態逐請求變動；informational only 條款把配額 header 定位成禮貌性預警而非 SLA — 服務端保留在異常流量下提前收緊的權利、消費者的正確姿勢是把 429 處理寫對、而非精算剩餘配額。</p>
<h2 id="429-與-retry-after被擋之後的契約">429 與 Retry-After：被擋之後的契約</h2>
<p>拒絕本身也是介面。可承諾的最小集合：status 用 429（讓消費者與中介層知道「可重試、但要等」、跟終態 4xx 區分、錯誤分類見 <a href="/blog/backend/11-api-design/error-model-design/" data-link-title="11.4 錯誤模型設計" data-link-desc="錯誤該分幾類、格式怎麼定才有演化空間、機器判讀跟人類訊息怎麼分工 — 錯誤作為契約一級公民的設計判準">11.4</a>）；<code>Retry-After</code> 給等待時間、且服務端說到做到 — 消費者等滿再來就該被服務、否則 Retry-After 淪為裝飾、消費者退回盲目退避。GitHub 的文件在這條上有可指出的語意瑕疵：超限回 403 或 429、文件未明確劃分兩者的使用時機（見 <a href="/blog/backend/11-api-design/cases/ratelimit-github-primary-secondary/" data-link-title="11.C43 GitHub 雙層限流：primary / secondary 與 x-ratelimit 契約" data-link-desc="單一維度配額擋不住真實濫用、前標準時代 x- header 與 IETF 命名並存的遷移期現實">11.C43</a>）— 消費者要同時處理兩種 status、分支邏輯多一倍。設計新 API 時可直接採納的判準：拒絕的 status 只用一個。</p>
<p>消費端的配套是 backoff 加 jitter（同 <a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8</a> 的 retry 責任）— 限流語意跟冪等語意在消費端匯合：429 之後的重送、要嘛操作本身冪等、要嘛帶 idempotency key。</p>
<h2 id="單一維度擋不住真實濫用">單一維度擋不住真實濫用</h2>
<p>GitHub 的雙層限流揭露配額設計的一個實證：primary limit（每小時總額度）之外、還有 secondary limits — 並發上限、單端點吞吐、CPU 時間、內容建立速率（見 <a href="/blog/backend/11-api-design/cases/ratelimit-github-primary-secondary/" data-link-title="11.C43 GitHub 雙層限流：primary / secondary 與 x-ratelimit 契約" data-link-desc="單一維度配額擋不住真實濫用、前標準時代 x- header 與 IETF 命名並存的遷移期現實">11.C43</a>）。存在本身就是判讀：單一維度的請求計數擋不住真實濫用模式 — 額度內的高並發、額度內的單端點轟炸、額度內的重查詢。設計配額時從「要保護什麼資源」反推維度、而非從「一小時幾次」起手。</p>
<p>同一案例也記錄了 header 命名的遷移期現實：GitHub 的 <code>x-ratelimit-*</code> 是前標準時代的事實慣例、與 IETF 標準命名並存 — 新 API 該出標準 header、消費端 SDK 仍要能讀 x- 系、兩者會共存很久。</p>
<h2 id="成本模型per-request-假設的破裂">成本模型：per-request 假設的破裂</h2>
<p>「一個請求算一次」的配額假設、在請求成本不是常數的風格下失效。GitHub 的 GraphQL API 為此建立 point system：依 query 的展開規模計算成本、配額按點數計、另設結構上限當靜態防線、且成本對消費者可預估可查詢（參數細節與機制展開主寫在 <a href="/blog/backend/11-api-design/styles/graphql/graphql-execution-cost-security/" data-link-title="GraphQL 執行成本與攻擊面" data-link-desc="resolver 執行模型讓請求成本不再是常數 — N&#43;1 的基礎設施化、成本計點限流、introspection 偵察、persisted queries 的收斂路線">GraphQL 執行成本與攻擊面</a>、案例 <a href="/blog/backend/11-api-design/cases/graphql-github-cost-rate-limiting/" data-link-title="11.C19 GitHub：GraphQL point system 成本計點限流" data-link-desc="GraphQL 打破 per-request 限流假設、平台被迫發明查詢成本模型、加 node 上限雙層防線">11.C19</a>）。判準層的收穫有兩條：請求成本變動大的介面（GraphQL、批次、重查詢）、配額要計成本而非計次；成本模型本身要對消費者透明可預估 — 消費者無法預估成本、就無法設計合規的 client。執行成本的機制細節（resolver 展開、N+1、persisted queries）主寫在 <a href="/blog/backend/11-api-design/styles/graphql/graphql-execution-cost-security/" data-link-title="GraphQL 執行成本與攻擊面" data-link-desc="resolver 執行模型讓請求成本不再是常數 — N&#43;1 的基礎設施化、成本計點限流、introspection 偵察、persisted queries 的收斂路線">GraphQL 執行成本與攻擊面</a>。</p>
<h2 id="常見設計錯誤">常見設計錯誤</h2>
<ul>
<li><strong>限流回 500</strong>：消費者把它當服務故障告警、但成因是自身超額 — 語意錯位、429 才能觸發正確的消費端行為。</li>
<li><strong>Retry-After 不準</strong>：等滿再來還是被擋、消費者棄用該 header、生態退化成盲目重試。</li>
<li><strong>配額只在文件、不在 header</strong>：消費者無法程式化管理額度、只能在被擋下後才發現額度邊界。</li>
<li><strong>無 burst 設計</strong>：嚴格平滑限流把正常的批次行為（頁面載入拉 10 個資源）也擋掉 — 政策要區分持續超額與瞬時尖峰。</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>執行機制與 gateway 層：<a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 部署平台與網路入口</a>、<a href="/blog/backend/05-deployment-platform/load-balancer-contract/" data-link-title="5.3 load balancer 合約" data-link-desc="整理 idle timeout、draining 與 health check">5.3 Load Balancer 合約</a>（限流實作章屬 05 backlog）</li>
<li>高峰期的容量面：<a href="/blog/backend/09-performance-capacity/peak-event-readiness/" data-link-title="9.11 高峰事件準備" data-link-desc="活動、季節性流量、推廣事件的 capacity readiness 流程">9.11 高峰事件準備</a></li>
<li>429 後的重送安全：<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.10 API 規範治理</title><link>https://tarrragon.github.io/blog/backend/11-api-design/api-governance/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/api-governance/</guid><description>&lt;p>API 規範治理處理的問題在文件之外：規範寫得出來、讓幾十個團隊的 API 長得像同一家公司出的、靠的是組織機制。本章比較三種有公開一手資料的治理模式、再看規範落地的工具層與失敗的成因。前面各章的判準（風格、錯誤、版本、冪等）都要靠這一層才能從「某個團隊的好品味」變成「組織的預設」。&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;th>適合情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>提案制&lt;/td>
 &lt;td>Google AIP&lt;/td>
 &lt;td>編號提案 + 狀態機 + 編輯團簽核&lt;/td>
 &lt;td>規範量大、需要決策可追溯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Guild 制&lt;/td>
 &lt;td>Zalando&lt;/td>
 &lt;td>社群 ownership + PR review + 工具鏈&lt;/td>
 &lt;td>中大型組織、貢獻文化強&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分軌制&lt;/td>
 &lt;td>Microsoft&lt;/td>
 &lt;td>核心 guideline + 產品線專屬軌&lt;/td>
 &lt;td>產品線差異大到蓋不進一份規範&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>提案制&lt;/strong>把規範做成有生命週期的提案系統。Google AIP 用編號提案累積規範、進入 Reviewing 需編輯核可、Approved 需兩位非作者 approver 簽核、TL 是 escalation 終點（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/governance-google-aip-model/" data-link-title="11.C46 Google AIP：規範即提案系統的治理模式" data-link-desc="編號提案制、狀態機、簽核門檻把 IETF RFC 流程內化到單一組織；中心化治理、社群貢獻是輸入">11.C46&lt;/a>）— 本質是把 IETF RFC 流程內化到單一組織、換到的是每條規範的決策可追溯、可演進。結構上仍是中心化：社群貢獻是輸入、不是決策權。&lt;/p>
&lt;p>&lt;strong>Guild 制&lt;/strong>的重點是規範外的配套。Zalando 在 8,000+ 服務、300+ 團隊的規模下、由 API Guild 擁有 guidelines 的 ownership、API spec 以 PR 提交 peer review、重要 API 由 Guild 介入、再加 API Portal 集中可發現性（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/governance-zalando-api-first/" data-link-title="11.C47 Zalando API-first：Guidelines、Guild、Zally、Portal 四件套" data-link-desc="規範存活靠配套組織機制：文件、人的治理、自動化、可發現性缺一環就退化成書架文件">11.C47&lt;/a>）。這個案例的關鍵在完整性：guidelines（文件）、Guild（人）、Zally（自動化）、Portal（可發現性）四件缺一環、規範就退化成書架文件。&lt;/p>
&lt;p>&lt;strong>分軌制&lt;/strong>是規模的誠實妥協。Microsoft 的 guidelines repo 內含核心軌加 Azure 與 Graph 兩條產品線專屬軌（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/governance-microsoft-rest-guidelines/" data-link-title="11.C48 Microsoft REST Guidelines：單一 repo 內的分軌治理" data-link-desc="規範沿組織邊界分化的實況：Core / Azure / Graph 三軌並存、跟「像同一團隊設計」理想的張力">11.C48&lt;/a>）— 單一 guideline 蓋不住差異巨大的產品線、規範沿組織邊界分化。它跟 Zalando「像同一團隊設計」的理想構成一組張力：統一到什麼粒度是治理設計的自變數、不是越統一越好 — 分軌的成本是消費者跨產品線時要學兩套慣例、統一的成本是規範遷就最大公約數而失去產品線的貼合度。&lt;/p>
&lt;h2 id="工具層規範進-ci">工具層：規範進 CI&lt;/h2>
&lt;p>治理成本的關鍵優化是把可機檢的規則從人工 review 前移到 CI。OpenAPI 生態的代表是 Spectral（內建 OpenAPI 與 AsyncAPI rulesets、組織自帶自訂規則）跟 Zalando 的 Zally（預設 ruleset 直接執行 Zalando guidelines）；兩者的生態軌跡本身是選型訊號 — Spectral 有 8.1k dependent projects 且持續發版、Zally 的 release 停在 2022：兩個資料點指向「通用 linter 加組織自帶 ruleset、比單一組織專用 linter 更能存活」的判讀（C49 判讀、樣本有限、見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/governance-linting-spectral-zally/" data-link-title="11.C49 Spectral 與 Zally：guidelines 變成可執行檢查" data-link-desc="治理成本從人工 review 前移到 CI；通用 linter 加組織 ruleset 比單一組織專用 linter 更能存活">11.C49&lt;/a>）。protobuf 生態的對應物是 buf 的 lint 與 breaking check（&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6&lt;/a> 的工具層）。工具的邊界要誠實：linter 蓋得住命名、結構、必填欄位；蓋不住語意（這個資源建模合不合理）— 語意層仍回到 design review、工具的價值是讓人的注意力只花在語意上。&lt;/p>
&lt;h2 id="失敗的模式文件不會自己活著">失敗的模式：文件不會自己活著&lt;/h2>
&lt;p>治理缺席時規範的結局有公開的乾淨反例：White House API Standards — 內容完整的 RESTful guidelines、34 個 commit 後停止維護、2022 年正式 archived（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/governance-whitehouse-api-standards-archived/" data-link-title="11.C54 White House API Standards：規範制定後棄置（反例）" data-link-desc="反例：文件品質不差、但沒有 Guild / linter / review 等執行機制與持續 ownership、34 commits 後封存">11.C54&lt;/a>、反例）。文件品質過關、缺的是 Zalando 四件套裡的另外三件：沒有 owner、沒有 review 流程、沒有工具。跟 C47 並排的結論：&lt;strong>規範的存活取決於配套組織機制、不取決於文件寫得多好&lt;/strong> — 評估自己組織的規範健康度、先問 owner 是誰、上次更新是何時、違反規範的 API 會在哪一關被擋。&lt;/p></description><content:encoded><![CDATA[<p>API 規範治理處理的問題在文件之外：規範寫得出來、讓幾十個團隊的 API 長得像同一家公司出的、靠的是組織機制。本章比較三種有公開一手資料的治理模式、再看規範落地的工具層與失敗的成因。前面各章的判準（風格、錯誤、版本、冪等）都要靠這一層才能從「某個團隊的好品味」變成「組織的預設」。</p>
<h2 id="治理模式三型">治理模式三型</h2>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th>代表</th>
          <th>決策結構</th>
          <th>適合情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>提案制</td>
          <td>Google AIP</td>
          <td>編號提案 + 狀態機 + 編輯團簽核</td>
          <td>規範量大、需要決策可追溯</td>
      </tr>
      <tr>
          <td>Guild 制</td>
          <td>Zalando</td>
          <td>社群 ownership + PR review + 工具鏈</td>
          <td>中大型組織、貢獻文化強</td>
      </tr>
      <tr>
          <td>分軌制</td>
          <td>Microsoft</td>
          <td>核心 guideline + 產品線專屬軌</td>
          <td>產品線差異大到蓋不進一份規範</td>
      </tr>
  </tbody>
</table>
<p><strong>提案制</strong>把規範做成有生命週期的提案系統。Google AIP 用編號提案累積規範、進入 Reviewing 需編輯核可、Approved 需兩位非作者 approver 簽核、TL 是 escalation 終點（見 <a href="/blog/backend/11-api-design/cases/governance-google-aip-model/" data-link-title="11.C46 Google AIP：規範即提案系統的治理模式" data-link-desc="編號提案制、狀態機、簽核門檻把 IETF RFC 流程內化到單一組織；中心化治理、社群貢獻是輸入">11.C46</a>）— 本質是把 IETF RFC 流程內化到單一組織、換到的是每條規範的決策可追溯、可演進。結構上仍是中心化：社群貢獻是輸入、不是決策權。</p>
<p><strong>Guild 制</strong>的重點是規範外的配套。Zalando 在 8,000+ 服務、300+ 團隊的規模下、由 API Guild 擁有 guidelines 的 ownership、API spec 以 PR 提交 peer review、重要 API 由 Guild 介入、再加 API Portal 集中可發現性（見 <a href="/blog/backend/11-api-design/cases/governance-zalando-api-first/" data-link-title="11.C47 Zalando API-first：Guidelines、Guild、Zally、Portal 四件套" data-link-desc="規範存活靠配套組織機制：文件、人的治理、自動化、可發現性缺一環就退化成書架文件">11.C47</a>）。這個案例的關鍵在完整性：guidelines（文件）、Guild（人）、Zally（自動化）、Portal（可發現性）四件缺一環、規範就退化成書架文件。</p>
<p><strong>分軌制</strong>是規模的誠實妥協。Microsoft 的 guidelines repo 內含核心軌加 Azure 與 Graph 兩條產品線專屬軌（見 <a href="/blog/backend/11-api-design/cases/governance-microsoft-rest-guidelines/" data-link-title="11.C48 Microsoft REST Guidelines：單一 repo 內的分軌治理" data-link-desc="規範沿組織邊界分化的實況：Core / Azure / Graph 三軌並存、跟「像同一團隊設計」理想的張力">11.C48</a>）— 單一 guideline 蓋不住差異巨大的產品線、規範沿組織邊界分化。它跟 Zalando「像同一團隊設計」的理想構成一組張力：統一到什麼粒度是治理設計的自變數、不是越統一越好 — 分軌的成本是消費者跨產品線時要學兩套慣例、統一的成本是規範遷就最大公約數而失去產品線的貼合度。</p>
<h2 id="工具層規範進-ci">工具層：規範進 CI</h2>
<p>治理成本的關鍵優化是把可機檢的規則從人工 review 前移到 CI。OpenAPI 生態的代表是 Spectral（內建 OpenAPI 與 AsyncAPI rulesets、組織自帶自訂規則）跟 Zalando 的 Zally（預設 ruleset 直接執行 Zalando guidelines）；兩者的生態軌跡本身是選型訊號 — Spectral 有 8.1k dependent projects 且持續發版、Zally 的 release 停在 2022：兩個資料點指向「通用 linter 加組織自帶 ruleset、比單一組織專用 linter 更能存活」的判讀（C49 判讀、樣本有限、見 <a href="/blog/backend/11-api-design/cases/governance-linting-spectral-zally/" data-link-title="11.C49 Spectral 與 Zally：guidelines 變成可執行檢查" data-link-desc="治理成本從人工 review 前移到 CI；通用 linter 加組織 ruleset 比單一組織專用 linter 更能存活">11.C49</a>）。protobuf 生態的對應物是 buf 的 lint 與 breaking check（<a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6</a> 的工具層）。工具的邊界要誠實：linter 蓋得住命名、結構、必填欄位；蓋不住語意（這個資源建模合不合理）— 語意層仍回到 design review、工具的價值是讓人的注意力只花在語意上。</p>
<h2 id="失敗的模式文件不會自己活著">失敗的模式：文件不會自己活著</h2>
<p>治理缺席時規範的結局有公開的乾淨反例：White House API Standards — 內容完整的 RESTful guidelines、34 個 commit 後停止維護、2022 年正式 archived（見 <a href="/blog/backend/11-api-design/cases/governance-whitehouse-api-standards-archived/" data-link-title="11.C54 White House API Standards：規範制定後棄置（反例）" data-link-desc="反例：文件品質不差、但沒有 Guild / linter / review 等執行機制與持續 ownership、34 commits 後封存">11.C54</a>、反例）。文件品質過關、缺的是 Zalando 四件套裡的另外三件：沒有 owner、沒有 review 流程、沒有工具。跟 C47 並排的結論：<strong>規範的存活取決於配套組織機制、不取決於文件寫得多好</strong> — 評估自己組織的規範健康度、先問 owner 是誰、上次更新是何時、違反規範的 API 會在哪一關被擋。</p>
<h2 id="採現成標準還是自建規範">採現成標準、還是自建規範</h2>
<p>治理的另一個選項是直接採跨組織標準、讓別人維護規範。JSON:API 的價值主張就放在這 —「stop the bikeshedding」、用現成慣例消除團隊內的格式爭論（見 <a href="/blog/backend/11-api-design/cases/standards-jsonapi-antibikeshedding/" data-link-title="11.C50 JSON:API：以停止 bikeshedding 為賣點的格式標準" data-link-desc="價值主張放在組織成本而非技術能力：採現成標準 vs 自建規範加治理是規範治理的核心選擇">11.C50</a>）。反面的量尺是 OData：OASIS 標準加 ISO 認證、生態仍萎縮、Netflix 與 eBay 離場（見 <a href="/blog/backend/11-api-design/cases/standards-odata-decline/" data-link-title="11.C51 OData：ISO 認證救不了生態萎縮（反例）" data-link-desc="反例：正式標準化程度最高、主流化程度不成比例；marquee adopter 離場比標準機構背書更能預測標準命運">11.C51</a>、反例、退場分析屬二手來源）— 標準機構背書不能替代生態動能、採標準前看的是 marquee adopter 與工具鏈、而非認證章。務實的中間路線是「採描述格式標準、自建設計規範」：OpenAPI 描述介面、AsyncAPI 補事件面（兩者的生態關係見 <a href="/blog/backend/11-api-design/cases/standards-openapi-initiative-evolution/" data-link-title="11.C52 OpenAPI Initiative：從 Swagger 捐贈到開放治理" data-link-desc="單一 vendor spec 轉軌中立基金會的成功樣本：治理轉移是把既有動能中立化、不是用背書創造動能">11.C52</a> 與 <a href="/blog/backend/11-api-design/cases/standards-asyncapi-complement/" data-link-title="11.C53 AsyncAPI：刻意相容 OpenAPI 的補位策略" data-link-desc="站在既有標準肩上補 event-driven 缺口：以相容性換採用曲線、描述格式的邊界即治理邊界">11.C53</a>）、設計判準寫成自家 guidelines 配 Spectral ruleset。標準化嘗試的完整興衰史收在 styles/standards 流派層 backlog（見 <a href="/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁</a>）。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每個新 API 的 review 都在吵同樣的問題</td>
          <td>規範缺位或不可發現、把重複爭論寫成 guideline 條目</td>
      </tr>
      <tr>
          <td>有 guidelines、但新 API 明顯不遵守</td>
          <td>缺執行機制、對照四件套補：owner、review、linter、portal</td>
      </tr>
      <tr>
          <td>guidelines repo 一年沒 commit</td>
          <td>治理已死、文件在誤導（讀者以為它反映現況）</td>
      </tr>
      <tr>
          <td>linter 規則被大量 inline 豁免</td>
          <td>規則跟實際需求脫節、修規則而非繼續豁免</td>
      </tr>
  </tbody>
</table>
<p>治理訊號的判讀跟前面各章不同、歸因要先分「機制缺席」還是「機制失效」：前兩個訊號是缺席（規範或執行環節根本沒建）、對照四件套補件；後兩個是失效（建了但跟組織實況脫節）、修內容而非加機制 — 失效時再疊新機制、只會累積更多沒人遵守的規則。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>相容性檢查的工具細節：<a href="/blog/backend/11-api-design/backward-compatibility-discipline/" data-link-title="11.6 向後相容的變更紀律" data-link-desc="哪些變更算 breaking、相容性檢查放人工還是 CI、檢查粒度怎麼選 — 讓介面變更可審可擋的日常紀律">11.6 向後相容的變更紀律</a></li>
<li>各章判準是治理的內容物：從 <a href="/blog/backend/11-api-design/api-boundary-responsibility/" data-link-title="11.1 API 作為服務邊界的責任" data-link-desc="介面變更該由誰付成本、哪些介面性質算對外承諾、承諾違約有哪些模式 — 動手設計 endpoint 前的責任框架">11.1</a> 依序讀</li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item></channel></rss>