已載入 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。