軟體設計

codebase-design

以深模組、介面、接縫、適配器、槓桿與局部性等一致詞彙評估軟體結構,透過刪除測試與多方案比較縮小呼叫者負擔並改善可測性。

查看來源 ↗
作者/來源mattpocock★ 257,446 GitHub Stars
測試版本3cca18b368ae更新日期:2026-09-04T08:43:27Z
支援平台OpenAI · Claude本次實測平台
安裝方式npx skills add mattpocock/skills --skill codebase-design
公平比較方式

三個問題,分別測載入前與載入後。

每次回答都在全新環境中產生。兩邊使用相同模型與設定,唯一差別是右側載入了這個 Skill。

模型版本gpt-5.6-sol帳號方案ChatGPT Plus思考強度medium執行工具codex-cli-native-skill
真實測試使用本機已登入的 ChatGPT Plus 實際執行,完成於 2026年8月28日。所有檢查通過後才公開,回答未經人工改寫。
三階段測試3 個問題全部完成版本核對3/3 通過環境清理9/9 通過測試隔離每回合全新環境
01
基礎題

辨識淺層包裝模組

測試問題

審查以下 TypeScript 結構:OrderLookup 只有 getById(id, includeItems, includePayments, includeCustomer) 與 getMany(ids, includeItems, includePayments, includeCustomer),兩個方法都只是把四個旗標原樣轉傳給 Repository;六個呼叫者各自重複『訂單詳情需要 items+payments+customer』的組合。請用 module、interface、depth、leverage、locality 與 deletion test 分析,並提出一個更深的介面草案。不要新增目前沒有第二種實作的 adapter seam。

載入前 · 0 個 Skill載入後 · 版本已核對再次確認 · 0 個 Skill已確認模型套用 Skill三個環境皆已清理
未載入 Skill844 tokens · 36908 ms
目前的 `OrderLookup` 是淺模組:它雖然增加了一層名稱,卻沒有吸收任何複雜度。 | 面向 | 審查 | |---|---| | module | `OrderLookup` 沒有封裝查詢意圖或載入策略,只是 Repository 的轉接層。 | | interface | 四個位置參數讓呼叫者必須理解 items、payments、customer 的載入細節;`getById` 與 `getMany` 還重複相同旗標介面。布林參數也容易因順序錯置而出錯。 | | depth | 介面複雜度接近實作複雜度:呼叫者傳四項,實作再原樣傳四項,深度很低。 | | leverage | 六個呼叫者都要重建「訂單詳情」規則。修改詳情內容時必須修改多處,這一層幾乎沒有提供槓桿。 | | locality | 「訂單詳情包含 items、payments、customer」這項領域決策散落在六個呼叫者,而不是集中在最了解訂單讀取方式的模組中。 | | deletion test | 如果刪除 `OrderLookup`,讓呼叫者直接呼叫 Repository,系統中的概念與重複幾乎不會增加;主要只是少一層轉傳。這表示目前抽象沒有承擔足夠責任。 | 更深的介面應表達呼叫者的意圖,並把關聯載入組合藏在模組內: ```ts export interface OrderLookup { getDetails(id: OrderId): Promise<OrderDetails | null>; getManyDetails(ids: readonly OrderId[]): Promise<readonly OrderDetails[]>; } export class OrderLookupService implements OrderLookup { constructor(private readonly repository: OrderRepository) {} getDetails(id: OrderId): Promise<OrderDetails | null> { return this.repository.getById( id, true, // items true, // payments true, // customer ); } getManyDetails( ids: readonly OrderId[], ): Promise<readonly OrderDetails[]> { return this.repository.getMany( ids, true, // items true, // payments true, // customer ); } } ``` 如此六個呼叫者只需表達「取得訂單詳情」: ```ts const order = await orderLookup.getDetails(orderId); const orders = await orderLookup.getManyDetails(orderIds); ``` 這個設計的深度來自介面只暴露 `id`/`ids`,實作則吸收四個旗標、旗標順序及「details」的組成規則。若未來詳情增加其他關聯,只需修改此模組,六個呼叫者不變。 不應另外建立 `OrderRepositoryAdapter` 或可插拔 provider 介面;目前沒有第二種實作,這類 adapter seam 只會增加間接層。若 `OrderLookup` 也沒有替身或多實作需求,甚至可只匯出具體類別,將真正需要穩定的 `getDetails`/`getManyDetails` API 視為介面,而不額外宣告 TypeScript `interface`。
已載入 Skill +2023 tokens · 60110 ms
## 審查 ### Module 與 interface 目前 `OrderLookup` 是一個淺 module: - interface 暴露 `includeItems`、`includePayments`、`includeCustomer`,要求呼叫者理解 Repository 的載入機制。 - implementation 只原樣轉傳參數,沒有封裝業務語意。 - 「訂單詳情」這個真正的概念沒有進入 interface,反而散落成六處布林值組合。 - 布林參數在呼叫點也缺乏可讀性,例如 `getById(id, true, true, true)` 無法直接表達意圖。 這裡的 interface 不只包含 TypeScript 型別,還包含呼叫者必須知道「哪些旗標組合才構成完整訂單詳情」這項隱含規則。 ### Depth 目前 interface 的複雜度幾乎等於 implementation 的複雜度:呼叫者指定載入策略,`OrderLookup` 只負責轉送。因此 depth 很低。 加深 module 的方向不是增加方法或設定物件,而是把穩定的業務語意藏進 implementation: > 呼叫者要求「訂單詳情」;module 決定這代表 items、payments 與 customer。 ### Leverage 目前六個呼叫者仍各自承擔相同知識,因此 `OrderLookup` 幾乎沒有提供 leverage。若改為語意化 interface: ```ts orderDetails.getById(id) ``` 每個呼叫者只需學會「取得訂單詳情」,不再需要理解三個 include 參數。一次 implementation 可以服務六個呼叫點及其測試。 ### Locality 目前規則散落在六個呼叫者: ```ts getById(id, true, true, true) ``` 如果「訂單詳情」日後還要包含其他資料,就必須搜尋並修改所有呼叫者,也容易漏改。 把組合集中到 `OrderDetailsLookup` 後,規則、修改與驗證都集中在一處。這提升 locality:改一次,所有呼叫者一致生效。 ### Deletion test 刪除目前的 `OrderLookup` 後,呼叫者只需改成直接呼叫 Repository 並傳入相同旗標;幾乎沒有額外複雜度重新出現。這表示目前 module 沒有真正封裝知識,接近純 pass-through。 刪除改善後的 `OrderDetailsLookup`,則「訂單詳情包含哪些關聯資料」的規則會重新散回六個呼叫者。這表示新 module 確實在集中並隱藏複雜度,能通過 deletion test。 ## 更深的 interface 草案 單筆與多筆具有不同回傳形狀,保留兩個方法是合理的;移除載入旗標,讓名稱直接表達用例: ```ts type OrderDetails = Order & { items: readonly OrderItem[]; payments: readonly Payment[]; customer: Customer; }; class OrderDetailsLookup { constructor(private readonly repository: OrderRepository) {} getById(id: OrderId): Promise<OrderDetails | undefined> { return this.repository.getById( id, true, // includeItems true, // includePayments true, // includeCustomer ); } getMany(ids: readonly OrderId[]): Promise<readonly OrderDetails[]> { return this.repository.getMany( ids, true, // includeItems true, // includePayments true, // includeCustomer ); } } ``` 其公開 interface 為: ```ts getById(id: OrderId): Promise<OrderDetails | undefined> getMany(ids: readonly OrderId[]): Promise<readonly OrderDetails[]> ``` 仍需依既有行為確認 `getMany` 的順序、重複 ID 與缺失 ID 規則;這些也屬於 interface,應明確記錄,而不是留給呼叫者猜測。 不需要新增 `IOrderDetailsLookup`、Repository wrapper 或其他 adapter seam。`OrderDetailsLookup` 可以是直接使用現有具體 Repository 的 module;等到確實出現第二種 implementation,再考慮建立 adapter seam。
02
實務題

判斷真實接縫

測試問題

付款模組目前有 StripePaymentAdapter 與 FakePaymentAdapter,兩者都滿足 charge(request): ChargeResult;另有 EmailFormatter 只有唯一實作,而且只是呼叫 template.replace。請分別判斷 seam 是否真實、interface 是否夠深、測試應在哪個表面進行,並提出保留或刪除建議。必須區分 implementation 與 adapter,不要使用『boundary』代稱 seam。

載入前 · 0 個 Skill載入後 · 版本已核對再次確認 · 0 個 Skill已確認模型套用 Skill三個環境皆已清理
未載入 Skill795 tokens · 27469 ms
| 模組 | seam 是否真實 | interface 深度 | 測試表面 | 建議 | |---|---|---|---|---| | `charge(request): ChargeResult` | 真實 seam。付款供應商是可替換且會失敗的外部協作者;即使正式環境目前只有 Stripe,測試仍需要可控替身。 | 若此介面隱藏 Stripe SDK、驗證、錯誤轉換、冪等性與回傳格式,則屬於夠深的介面。只有一個方法不代表淺;關鍵是它隱藏了多少複雜度。 | 業務邏輯在 `charge` 介面表面測試,使用 `FakePaymentAdapter` 控制成功、拒付及錯誤。另為所有 adapter 建立契約測試;`StripePaymentAdapter` 再做 Stripe sandbox/整合測試。不要以 fake 的測試代替 Stripe 整合測試。 | 保留付款介面、`StripePaymentAdapter` 與 `FakePaymentAdapter`。若 fake 行為日益偏離真實付款語意,應以共享契約測試約束,而不是刪除 seam。 | | `EmailFormatter` | 不是真實 seam:目前沒有合理的替換需求,唯一實作也沒有隔離外部系統或演算法變體。 | 過淺;介面只把 `template.replace` 再包一層,抽象成本高於隱藏的複雜度。 | 在呼叫端的可觀察輸出測試格式化結果;若模板規則本身重要,可直接對純函式或具體 formatter 測試。不需要介面契約測試。 | 刪除 `EmailFormatter` 介面並行內 `replace`,或改成具名純函式。只有在出現多種實質格式化策略、複雜 escaping/locale 規則時才重新引入 seam。 | `StripePaymentAdapter` 與 `FakePaymentAdapter` 是同一付款介面的兩個 implementation;它們之所以稱為 adapter,是因為各自把 Stripe SDK或測試控制模型轉換成應用程式的 `charge` 協定。`EmailFormatter` 的唯一 implementation 只是薄封裝,沒有這種轉換責任,因此不應僅因實作了介面就稱為 adapter。
已載入 Skill +1147 tokens · 37010 ms
| 模組 | Seam 是否真實 | Interface 深度 | 測試表面 | 建議 | |---|---|---|---|---| | 付款模組 | 真實。`StripePaymentAdapter` 與 `FakePaymentAdapter` 是兩個 adapter,能在 `charge(request): ChargeResult` 這個 seam 替換行為。adapter 是它們扮演的角色;各自內部的程式碼才是 implementation。 | 大致夠深:單一操作隱藏 Stripe 呼叫、錯誤轉換與測試替代行為。但前提是 `ChargeRequest`、`ChargeResult` 不洩漏 Stripe 專屬型別,並完整定義失敗、重試及冪等語意;否則 interface 仍需深化。 | 以 `charge` interface 為共同測試表面,對兩個 adapter 跑 contract tests。呼叫端測試可注入 fake;Stripe adapter 另以相同 interface 做 sandbox/integration tests,不應穿透 interface 測其內部 implementation。 | 保留 seam 與兩個 adapter。 | | `EmailFormatter` | 不真實。目前只有一個 implementation,沒有實際可替換的 adapter;這是預想中的 seam。 | 不夠深。若 interface 背後只是一次 `template.replace`,interface 幾乎沒有隱藏複雜度,也沒有提供足夠 leverage。 | 刪除抽象後,從實際使用者可觀察的輸出測試,例如寄信流程產生的內容。若保留具名純函式,可直接測該函式;不需要建立 adapter contract tests。 | 刪除 formatter interface 及其 adapter 形式;將 implementation 內聯或改成具名純函式。只有當格式化規則確實會擴展、並能在小 interface 後隱藏實質複雜度時,才重新引入 seam。 |
03
壓力題

設計兩種深介面

測試問題

通知功能目前讓每個呼叫者依序做 resolveRecipients、loadPreferences、renderTemplate、send、recordDelivery,且錯誤與重試規則散落。請在不實際修改程式的前提下設計兩個差異明顯的 module interface 方案,逐一說明 seam、隱藏進 implementation 的複雜度、callers 得到的 leverage、maintainers 得到的 locality 與測試表面,最後選一個並解釋取捨。不要把內部步驟重新暴露成同樣多的方法。

載入前 · 0 個 Skill載入後 · 版本已核對再次確認 · 0 個 Skill已確認模型套用 Skill三個環境皆已清理
未載入 Skill2369 tokens · 76650 ms
目前工作區未提供可檢視的程式碼,因此以下以題述流程為基礎;既有同步回應、交易與訊息基礎設施仍屬未知。 ## 方案一:單一同步用例介面 ```ts interface NotificationService { deliver(command: DeliverNotification): Promise<DeliveryResult>; } type DeliverNotification = { notificationId: string; // idempotency key audience: AudienceRef; // 群組、帳號或領域對象,不是解析後名單 template: TemplateRef; data: Record<string, unknown>; policy?: NotificationPolicyRef; }; type DeliveryResult = | { status: "delivered"; summary: DeliverySummary } | { status: "partially_delivered"; summary: DeliverySummary } | { status: "rejected"; reason: RejectionReason }; ``` ### Seam 邊界是一個完整的「交付通知」use case。Caller 描述意圖與受眾,不再協調 `resolveRecipients`、偏好、模板、傳送或紀錄。 介面只承諾業務結果,不承諾內部執行順序,也不回傳 `renderError`、`sendError` 等 pipeline 細節。若需要診斷,應透過內部 telemetry 或以 `notificationId` 查詢,而非擴大公開介面。 ### 隱藏進 implementation 的複雜度 - 收件者解析、去重與無收件者處理 - 偏好、退訂與頻道選擇 - 模板版本、locale、fallback 與渲染 - provider 選擇、限流、timeout、重試與 circuit breaker - 冪等、防止重複傳送及 delivery recording - partial failure 的歸類與聚合 - 各階段錯誤轉換成少量穩定的業務結果 ### Callers 得到的 leverage Caller 從五段控制流程縮成一次呼叫,無須知道哪些錯誤可重試,也不會因新增頻道、偏好規則或 provider 而修改。同步結果仍適合必須立即知道「是否已交付」的互動流程。 代價是 caller 的 latency 會涵蓋完整交付,而且 request timeout、內部重試與「其實已送出但回應遺失」的語意較難處理。 ### Maintainers 的 locality 與測試表面 流程與政策集中在 `NotificationService` implementation;新增 fallback 或調整重試規則時,主要改動留在通知模組。 公開 contract 測試只需覆蓋: - 成功、部分成功、拒絕三類結果 - 冪等重送 - 穩定的輸入驗證及結果映射 內部則以少數 scenario/component tests 驗證整條流程,再針對偏好、模板及 provider adapter 做細粒度測試。缺點是同步 orchestrator 仍可能成為大型類別,需要在模組內部分解,但這些 collaborator 不應成為 caller API。 ## 方案二:非同步通知提交介面 ```ts interface NotificationGateway { submit(intent: NotificationIntent): Promise<SubmissionReceipt>; } type NotificationIntent = { notificationId: string; audience: AudienceRef; template: TemplateRef; data: Record<string, unknown>; policy?: NotificationPolicyRef; }; type SubmissionReceipt = | { accepted: true; notificationId: string } | { accepted: false; reason: SubmissionRejection }; ``` 後續結果透過既有事件機制發布,例如: ```ts type NotificationOutcome = | { notificationId: string; status: "delivered"; summary: DeliverySummary } | { notificationId: string; status: "partially_delivered"; summary: DeliverySummary } | { notificationId: string; status: "failed"; reason: TerminalFailure }; ``` 這不是把內部步驟改成非同步方法;公開 command 仍只有 `submit`,outcome 是生命週期事件,而非 pipeline control API。 ### Seam 邊界是「可靠地接受通知意圖」,不是「在本次呼叫內完成傳送」。成功 receipt 只表示模組已取得責任;最終結果由事件、既有狀態投影或營運監控呈現。 ### 隱藏進 implementation 的複雜度 除方案一的所有複雜度外,還包括: - durable queue、transactional outbox/inbox 或等價可靠接收機制 - worker concurrency、排程與 backpressure - retry schedule、最大嘗試次數與 dead-letter handling - crash recovery、at-least-once delivery 下的冪等 - outcome event 與 delivery record 的一致性 - 延遲交付、批次化及 provider rate-limit smoothing `accepted` 後無法處理的錯誤不能再丟回 caller,必須由模組完成重試並最終產生 terminal outcome。 ### Callers 得到的 leverage Caller 只需可靠提交意圖,延遲與 provider 暫時故障不再佔用 request lifecycle。所有入口得到一致的重試、限流與觀測行為,也更容易承受流量尖峰。 代價是 caller 不會立即取得最終交付結果;需要即時確認的產品流程必須接受 pending 狀態或訂閱 outcome。系統也必須已有、或願意引入可靠訊息基礎設施。 ### Maintainers 的 locality 與測試表面 交付生命週期完整歸通知模組所有:提交、排程、重試、終止與紀錄可在同一 bounded context 內演進。錯誤政策不再散落於 HTTP handler、job 或各種 caller。 公開 contract 測試聚焦於: - 接受與拒絕語意 - 相同 `notificationId` 的冪等提交 - accepted intent 最終產生且只產生一個 terminal outcome - outcome schema 的相容性 內部測試則涵蓋 worker crash、重複訊息、重試耗盡、partial delivery、dead letter 與記錄一致性。測試表面比方案一多了訊息生命週期,但故障模式集中且可以確定性測試,不再由每個 caller 重複模擬。 ## 選擇 建議採用方案二。 題述中已存在 `send`、`recordDelivery`、錯誤與重試規則,表示核心問題不只是簡化五個呼叫,而是統一「可靠交付」的所有權。非同步提交 seam 能讓通知模組真正接管重試、限流、冪等和最終紀錄;方案一雖然也改善封裝,卻仍把完整交付綁在 caller 的 request lifecycle,較容易再次出現 timeout 與重試責任不清。 取捨是引入 eventual consistency、營運監控及 durable messaging 成本。若現況沒有可靠 queue/outbox,或 caller 確實必須同步取得最終交付結果,則應先選方案一作為過渡;其 `DeliverNotification` 意圖模型可保留,日後將同步 implementation 改為提交式模型,而無須重新暴露五個內部步驟。
已載入 Skill +5259 tokens · 243964 ms
以下假設通知可能跨程序執行,且傳送供應商屬於 true external dependency;若實際上所有通知都必須在單一 request 內完成,方案一會更合適。 ## 方案一:同步、結果導向的 `NotificationDispatcher` Seam 放在「完成一項通知意圖」,而不是任何內部步驟。 ```ts type NotificationIntent<K extends NotificationKind> = { idempotencyKey: string; kind: K; subject: SubjectFor<K>; // 業務對象,不是 email/phone facts: FactsFor<K>; // 版本化、typed domain facts }; type DeliveryOutcome = { notificationId: string; delivered: number; suppressed: number; permanentlyFailed: number; }; interface NotificationDispatcher { deliver<K extends NotificationKind>( intent: NotificationIntent<K>, ): Promise<DeliveryOutcome>; } ``` Caller: ```ts const outcome = await notifications.deliver({ idempotencyKey: `invoice-due:${invoice.id}:v2`, kind: "invoice.due", subject: { accountId: invoice.accountId }, facts: { invoiceId: invoice.id, dueAt: invoice.dueAt, }, }); ``` ### Interface contract - `deliver` 在所有 logical deliveries 進入 terminal state 後才完成。 - 相同 `idempotencyKey` 與相同內容回傳原結果;相同 key、不同內容回 `IdempotencyConflict`。 - `suppressed` 是偏好或政策結果,不是錯誤。 - 可重試錯誤由 implementation 在有限 retry budget 內處理。 - 永久失敗以 `DeliveryOutcome` 表達,不要求 caller 解讀 provider error。 - 只有 `InvalidIntent`、`Unauthorized`、`IdempotencyConflict` 或整體基礎設施不可用等無法產生可信結果的情況才 reject。 - 不承諾 exactly-once;若 provider 不支援 idempotency,send 後、record 前的 crash window 仍可能造成重複。 ### Implementation 隱藏的複雜度 - recipient expansion、去重及地址解析 - preference precedence、quiet hours、法規例外 - locale、channel、template revision 選擇與 rendering - provider routing、timeout、rate limit、fallback - transient/permanent error 分類 - bounded backoff、jitter、retry exhaustion - per-delivery idempotency、delivery ledger、audit - PII redaction、metrics 與告警 五個既有步驟可以繼續作為私有函式或 internal seams,但不屬於外部 interface。 ### Leverage、locality 與測試表面 Caller 學一個 operation,便取得偏好遵循、rendering、傳送、重試及紀錄的 leverage;不再有自行排序或漏記 delivery 的可能。 錯誤分類、retry budget、模板選擇或 provider fallback 都集中在同一 implementation,maintainer 可在一處修改並驗證,locality 很高。 主要測試直接跨 `deliver`: - opt-out 產生 `suppressed`,且不呼叫 provider - transient failure 經 fake clock 推進後成功 - permanent failure 不做無效重試 - 部分成功產生正確 aggregate outcome - 相同 idempotency key 不重複傳送 - crash recovery 符合明示的 at-least-once 語義 True external provider 使用 scripted mock adapter;remote-but-owned recipient directory 使用 in-memory adapter;delivery store 若可 local-substitutable,測試使用本地資料庫 stand-in。這些都是 implementation 的 internal seams。 ### 代價 這個 interface 很深且容易測試,但 caller latency 包含解析、傳送及 retry。大量收件者、長時間 backoff 或程序重啟會讓 request lifecycle 與 delivery lifecycle 綁得太緊,因此較適合低 fan-out、需要立即 terminal outcome 的通知。 --- ## 方案二:持久化、所有權轉移的 `NotificationJobs` Seam 放在「module 已可靠接管通知責任」。Caller 提交意圖後即可離開,execution lifecycle 屬於 implementation。 ```ts type SubmitNotification<K extends NotificationKind> = { tenantId: string; idempotencyKey: string; intent: { kind: K; subject: SubjectFor<K>; facts: FactsFor<K>; }; notBefore?: string; expiresAt?: string; }; type NotificationReceipt = { notificationId: string; disposition: "accepted" | "alreadyAccepted"; acceptedAt: string; }; type NotificationOutcome = | { state: "accepted" | "processing"; notificationId: string; } | { state: "completed"; notificationId: string; delivered: number; suppressed: number; permanentlyFailed: number; completedAt: string; }; interface NotificationJobs { submit<K extends NotificationKind>( command: SubmitNotification<K>, ): Promise<NotificationReceipt>; getOutcome( tenantId: string, notificationId: string, ): Promise<NotificationOutcome>; } ``` Caller: ```ts const receipt = await notifications.submit({ tenantId, idempotencyKey: `invoice-due:${invoice.id}:v2`, intent: { kind: "invoice.due", subject: { accountId: invoice.accountId }, facts: { invoiceId: invoice.id, dueAt: invoice.dueAt, }, }, }); ``` `getOutcome` 是觀察結果,不是下一個 orchestration step。若產品完全不需要 caller 查詢結果,可以刪除它,只留下 `submit`。 ### Interface contract - `accepted` 表示 intent 與 work item 已原子、durably 儲存,不表示已送達。 - `(tenantId, idempotencyKey)` 唯一;同 key、不同內容回 `IdempotencyConflict`。 - 接受後 intent 不可變,所有 retry 使用同一份 intent。 - Caller 不能指定 channel、provider、template revision、retry 次數或略過 preferences。 - `notBefore` 表示不得提早執行,不承諾準點;`expiresAt` 後不開始新 attempt。 - 接受後的 provider timeout、recipient lookup failure、rendering failure 和 retry exhaustion 不回流原 caller,而是進入 durable workflow、outcome 與營運告警。 - 不同 notification 間不保證 FIFO;同一 logical delivery 的 attempts 不得並行。 - Provider 不支援 idempotency 時仍只能承諾 at-least-once attempt。 ### Implementation 隱藏的複雜度 除了方案一列出的內容,implementation 還擁有: - transactional outbox 或等價的 durable intake - job state machine、leases 與 worker concurrency - crash recovery、dead-letter handling - scheduling、expiration、backpressure - recipient-level terminal records及 aggregate projection - process restart 後的 retry continuity - operational metrics、tracing 與人工處置資訊 這些能力沒有以 `resolve`、`render`、`send` 等方法重新洩漏到 interface。 ### Leverage、locality 與測試表面 Caller 只負責建立 typed domain intent。一次 `submit` 便取得可靠接受、排程、偏好治理、傳送、持久化 retry、稽核與狀態查詢,對需要可靠通知的 caller 有更高 leverage。 Retry taxonomy、provider quirks、crash recovery 和 ledger schema 全部集中,maintainer 不必跨 caller 搜尋錯誤處理,locality 優於方案一。 主要測試跨 `NotificationJobs` interface: - `accepted` 後即使 worker restart,工作仍完成 - 重複 submit 只建立一個 workflow - opt-out 最終為 `suppressed` - transient failure 按 fake clock retry - permanent failure 不 retry - `notBefore` 前不傳送,`expiresAt` 後不開新 attempt - send 後、record 前 crash 使用相同 provider idempotency key 恢復 - 部分成功形成正確 terminal outcome - tenant 無法讀取其他 tenant 的 outcome Provider、recipient directory、clock、scheduler 和 storage adapters 留在 internal seams;business caller 不注入或組裝它們。 ### 代價 - Implementation 與營運模型比方案一複雜,需要 durable store、worker 和狀態投影。 - Caller 得到的是 acceptance,而不是立即 delivery outcome,必須接受 eventual consistency。 - `getOutcome` 增加第二個 entry point;若沒有真實查詢需求,應移除。 - Typed notification catalog 需要 schema/version 管理,但能避免 template 名稱、variables 和 provider 選項穿透 seam。 ## 比較與選擇 | 面向 | 方案一:同步結果 | 方案二:持久化工作 | |---|---|---| | Seam | 完成通知意圖 | 可靠接管通知責任 | | Caller 等待 | 等到 terminal outcome | 等到 durable acceptance | | Retry | 受單次呼叫生命週期限制 | 可跨程序、跨重啟 | | Interface | 1 個 method | 1–2 個 methods | | 最強 leverage | 立即取得完整結果 | 可靠性、排程、恢復能力 | | Maintainer locality | workflow 規則集中 | workflow 與營運狀態皆集中 | | 主要風險 | 高 latency、request coupling | eventual consistency、營運成本 | 我會選方案二。現況既然已經需要 `recordDelivery`,且重試規則散落,通常代表 delivery lifecycle 不適合由 caller request 擁有。把 seam 移到 durable ownership transfer,才能讓錯誤分類、重試與 crash recovery 真正具有 locality,而不只是把五個呼叫包進一個同步函式。 取捨是 caller 不再立即知道送達結果。若確定所有通知皆為低 fan-out、重試時間極短,而且業務必須在原 request 中取得 terminal outcome,則應改選方案一;除此之外,方案二提供較長期且較深的 module。
測試紀錄

這次測試記錄了什麼?

固定不變兩邊使用相同的模型版本、帳號方案、系統指令、思考強度與測試問題。

分開測試每次回答都使用全新環境。載入前不含任何 Skill,載入後只含本頁指定的 Skill。

完整才公開三題的所有步驟與環境清理都成功後,才顯示結果。若無法確認模型是否套用 Skill,也會清楚標示。