ストーリー
Saga ステップの3分類
| 分類 | 特徴 | 例 |
|---|---|---|
| Compensatable | 補償可能、ピボット前に実行 | 在庫引当、仮予約 |
| Pivot | 補償不可能、成功=前進確定 | 外部決済API課金 |
| Retriable | 必ず成功する(リトライで) | メール送信、ログ記録 |
実行順序の設計
graph LR
A["Compensatable<br/>在庫引当"] --> B["Compensatable<br/>仮予約"] --> C["Pivot<br/>決済処理"] --> D["Retriable<br/>出荷指示"] --> E["Retriable<br/>通知送信"]
style A fill:#fff3e0,stroke:#e65100
style B fill:#fff3e0,stroke:#e65100
style C fill:#ffebee,stroke:#c62828
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#e8f5e9,stroke:#2e7d32
失敗パターン:
① 在庫引当で失敗 → 即キャンセル(補償不要)
② 仮予約で失敗 → 在庫引当解除(1つ補償)
③ 決済で失敗 → 仮予約キャンセル + 在庫解除(2つ補償)
④ 出荷で失敗 → リトライで必ず成功させる(補償なし)
補償設計のパターン
パターン1: 状態遷移による補償
// 注文のライフサイクル
enum OrderStatus {
CREATED = 'CREATED', // 作成
STOCK_RESERVED = 'RESERVED', // 在庫引当済み
PAID = 'PAID', // 決済済み
SHIPPED = 'SHIPPED', // 出荷済み
DELIVERED = 'DELIVERED', // 配送完了
CANCELLED = 'CANCELLED', // キャンセル
REFUNDED = 'REFUNDED', // 返金済み
}
// 補償 = 逆方向の状態遷移
const compensationMap: Record<string, string> = {
'RESERVED': 'CREATED', // 引当解除
'PAID': 'REFUNDING', // 返金処理中
'SHIPPED': 'RETURN_REQUESTED', // 返品受付
};
パターン2: 金額の補償
class PaymentCompensation {
async compensate(paymentId: string): Promise<void> {
const payment = await this.paymentRepo.findById(paymentId);
if (payment.status === 'CHARGED') {
// 全額返金(新しいトランザクションとして記録)
const refund = await this.stripeClient.refunds.create({
payment_intent: payment.externalId,
amount: payment.amount,
});
await this.paymentRepo.save({
...payment,
status: 'REFUNDED',
refundId: refund.id,
refundedAt: new Date(),
});
}
// 既にREFUNDED → 冪等(何もしない)
}
}
パターン3: タイムアウト付き仮予約
class StockReservation {
async reserve(orderId: string, items: Item[]): Promise<Reservation> {
const reservation = await this.reservationRepo.create({
orderId,
items,
status: 'ACTIVE',
expiresAt: addMinutes(new Date(), 15), // 15分で自動解除
});
return reservation;
}
// 定期バッチ: 期限切れの仮予約を自動解除
async releaseExpired(): Promise<void> {
const expired = await this.reservationRepo.findExpired();
for (const res of expired) {
await this.release(res.id);
}
}
}
補償失敗の対処
補償自体が失敗した場合の戦略:
| 戦略 | 説明 | 適用場面 |
|---|---|---|
| リトライ | 指数バックオフで再試行 | 一時的な障害 |
| Dead Letter Queue | 失敗メッセージを別キューへ | 手動対応が必要 |
| アラート | 運用チームに通知 | 緊急対応が必要 |
| 定期バッチ | 不整合を定期的にスキャン・修正 | 最終防衛線 |
async function compensateWithRetry(
action: () => Promise<void>,
maxRetries: number = 3
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await action();
return;
} catch (error) {
if (attempt === maxRetries) {
// DLQに送信 + アラート
await deadLetterQueue.send({ action: action.name, error });
await alertService.critical('Compensation failed permanently');
return;
}
await sleep(Math.pow(2, attempt) * 1000); // 指数バックオフ
}
}
}
まとめ
| ポイント | 内容 |
|---|---|
| 3分類 | Compensatable → Pivot → Retriable の順で設計 |
| 状態遷移 | 逆方向の遷移として補償を定義 |
| タイムアウト | 仮予約は自動解除で安全性確保 |
| 補償失敗 | リトライ→DLQ→アラート→バッチの多層防御 |
チェックリスト
- 3分類(Compensatable/Pivot/Retriable)を説明できる
- 補償の実装パターンを理解した
- タイムアウト付き仮予約の設計を理解した
- 補償失敗時の対処戦略を説明できる
次のステップへ
次は Transactional Outbox パターンを学びます。
推定読了時間: 40分