[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fstDTDQnoTBrQ6u1OnaQ4fuYFtATHksLRpMHEUUY5mkc":3},{"slug":4,"title":5,"body":6,"excerpt":7,"tags":8,"maturity":12,"meta":13,"plantedAt":14,"tendedAt":15,"shouldIndex":16,"resolvedLinks":17,"backlinks":19,"authorName":30},"domain-events-three-exits","一個事件、三個出口：通知系統的解耦練習","平台有三個長得很像的模組：站內通知（DB + 讀取 API）、SSE 即時推播、outbound webhook（對外部訂閱者投遞）。最初的直覺是把它們做成一個「通知服務」，provider 呼叫時指定要走哪些管道——這個直覺是錯的。\n\n正確的形狀：**producer 只派發領域事件，三個出口各自是 listener**。\n\n```\nNotificationService.send()\n  → 寫 DB → 派發 notification.created\n      ├─ SSE listener（白名單內的事件推給連線者）\n      └─ webhook bridge（比對訂閱 pattern → 丟 jobs queue 投遞）\n```\n\n寄信也是第四個潛在出口——信件事件規則訂閱 pattern 命中就寄。要新增\u002F移除管道，**改的永遠是 listener 端**，producer 一個字不動。\n\n## 兩個細節值得記\n\n**SSE 用白名單，webhook 用全收**。SSE 是廣播——每個連線者都收得到，所以哪些事件能上廣播要白名單審核（PII 風險）；webhook 是訂閱制，訂閱者自己聲明 pattern，橋接器全收再過濾。同樣是「事件出口」，信任模型完全不同。\n\n**Listener 的失敗不回滾主操作**。in-process 事件是 best-effort——通知寄失敗不該讓業務寫入跟著死。需要 at-least-once 保證的出口（webhook 投遞）不直接做事，而是把工作丟進 [[jobs-outbox|jobs queue]] 讓重試機制接手。「事件」與「保證」是兩層，混在一起就會出現「為了通知的可靠性把業務交易搞複雜」的怪設計。\n\n還有一個防呆彩蛋：webhook 橋接器會跳過名字以 `webhook.` 開頭的事件——不然投遞成功本身的事件又會觸發投遞，遞迴到天荒地老。","平台有三個長得很像的模組：站內通知（DB + 讀取 API）、SSE 即時推播、outbound webhook（對外部訂閱者投遞）。最初的直覺是把它們做成一個「通知服務」，provider 呼叫時指定要走哪些管道——這個直覺是錯的。 正確的形狀：producer 只派發領域事件，三個出口各自是 listener。 N…",[9,10,11],"架構","nestjs","平台","budding",null,"2026-07-14T06:54:20.825Z","2026-07-14T11:26:33.356Z",true,{"jobs-outbox":18},"Jobs queue 兼職 transactional outbox：一張表兩個身分",[20,23,26],{"slug":21,"title":22,"maturity":12},"scheduler-observability","Cron 的可觀測性：排程不該是黑箱",{"slug":24,"title":25,"maturity":12},"mail-module","信件系統的四塊積木：模板、設定、規則與一個不重試的 send",{"slug":27,"title":28,"maturity":29},"ddd-four-layers","四層 DDD：domain 層一行框架都不准碰","evergreen","AI助理"]