第96章:冪等性入門:同じ要求が来ても安全🔁🛡️

この章でやること(ゴール)🎯
- 「リトライ(再送)」や「二重クリック」「イベント再配信」が起きても、二重課金・二重処理にならない設計をつくれるようになる💳🚫
- **冪等キー(Idempotency Key)と重複排除(Dedup)**の基本パターンを、TypeScriptで実装してテストする🧪✨
- 「ドメインの不変条件」と「アプリ層の冪等化」の役割分担が分かる🏗️💡
1) 冪等性ってなに?(超やさしく)🍭
**冪等性(べきとうせい)**は、ざっくり言うと👇
同じ要求が複数回届いても、結果が壊れない(1回分として扱える)性質🔁🛡️
たとえば「支払い確定」ボタンを2回押しちゃった、通信が遅くてアプリが再送した、メッセージが再配信された… こういう**“現実に必ず起きる”**事故に強くなるための考え方だよ☺️🌪️
ちなみにHTTPでも「同じリクエストを何度送っても意図した効果が同じ」みたいな定義で idempotent が説明されてるよ(PUT/DELETEなど)📮✨ (rfc-editor.org)
2) なぜ必要?(非同期・リトライ前提の世界)⏳🌍
第95章で出てきた「非同期」「リトライ」って、成功率を上げるために超大事なんだけど… リトライ = 同じ処理が2回走る可能性を常に持つの🥲🔁
よくある原因👇
- ネットが一瞬切れて、クライアントが「失敗したかも?」で再送📶🔁
- サーバーは処理できてたけど、レスポンスが返る前にタイムアウト⏱️😵
- メッセージブローカーが「少なくとも1回配信(at-least-once)」で再配信📬🔁
- ユーザーが二重クリックしちゃう🖱️🖱️
だから「重複は起きる前提」で設計すると、運用がめちゃくちゃ強くなる💪🛡️✨
3) まず押さえる!冪等性の“守り方”は3段階あるよ🏰
冪等性って、ふわっと「同じでも安全!」じゃなくて、だいたい次の3つに分けて考えると整理しやすいよ📦✨
A. ドメインの不変条件で防ぐ(最低限の城壁)🏯🔒
例:「支払い済みの注文は、もう支払えない」 これは集約(Order)が守るべきルールだよね💡
でも…これだけだと 外部連携(決済) が絡むと弱いことがある😵 「外部に課金リクエストを2回送っちゃった」みたいな事故が起きるから💳💥
B. “要求”を冪等化する(冪等キー)🔑🔁
同じ操作には同じキーを付ける → サーバー側で「このキーはもう処理済み」なら 前回の結果を返す(または何もしない)✨
外部APIもこの仕組みをサポートしてる例が多いよ。たとえば Stripe は「冪等キーで安全に再試行できる」ことを公式に説明してる🧾🔁 (Stripe ドキュメント)
C. “イベント処理”を冪等化する(重複排除)📮🚫
イベントは「同じイベントが再配信される」前提で設計するのが基本。 そのために、イベントIDを記録して二重処理をスキップするよ✍️🛡️
メッセージング基盤側で重複検出が用意されてることもある(例:Azure Service BusのDuplicate detection)📬✨ (Microsoft Learn) 他にも Amazon Web Services のSQS FIFOは「重複排除ウィンドウ(例:5分)」の仕組みがあるよ⏳📨 (AWS ドキュメント) ただし!基盤機能があっても、アプリ側でも冪等に作るのが堅い👍🛡️
4) 例題(カフェ注文)で起きる“二重処理”を具体化しよ☕🧾🔁
今回は PayOrder(支払い) を冪等化するよ💳✨
起きがちな事故シナリオ😵💫
- アプリが
PayOrderを送る - サーバーは決済処理して「支払い済み」にした
- でもレスポンスが返る前にタイムアウト
- アプリが同じ支払いをリトライ
- 二重課金 or 二重で“支払い済み”処理が走る💥
ここを 冪等キーで止める🛑🔑
5) 実装方針(DDDっぽく役割を分ける)🧩🏗️
-
ドメイン(Order集約):
- 「支払いは1回だけ」みたいな不変条件を守る🏯🔒
-
アプリ層(PayOrderユースケース):
- 冪等キーを受け取って、**“同じ要求は1回扱い”**にする🔁🛡️
-
インフラ(IdempotencyStore):
- 処理済みキーと結果を保存する📦🗄️
6) TypeScript実装(最小で体験)💻✨
6-1. 冪等キーVO(Value Object)🔑💎
export class IdempotencyKey {
private constructor(public readonly value: string) {}
static from(value: string): IdempotencyKey {
const v = value.trim();
if (v.length < 10) throw new Error("IdempotencyKey is too short");
return new IdempotencyKey(v);
}
}
本番ではUUIDを使うことが多いよ(例:
crypto.randomUUID())🎲✨
6-2. 冪等ストア(処理済みの記録)🗃️
「キー → 結果」を保存する仕組みを抽象化するよ📦
export type IdempotencyRecord<T> = {
key: string;
createdAt: Date;
result: T;
};
export interface IdempotencyStore<T> {
find(key: string): Promise<IdempotencyRecord<T> | null>;
save(record: IdempotencyRecord<T>): Promise<void>;
}
InMemory版(学習用)🧪
export class InMemoryIdempotencyStore<T> implements IdempotencyStore<T> {
private readonly map = new Map<string, IdempotencyRecord<T>>();
async find(key: string): Promise<IdempotencyRecord<T> | null> {
return this.map.get(key) ?? null;
}
async save(record: IdempotencyRecord<T>): Promise<void> {
this.map.set(record.key, record);
}
}
6-3. “冪等ラッパー”を作る(超便利)🎁🔁
export async function withIdempotency<T>(
store: IdempotencyStore<T>,
key: IdempotencyKey,
action: () => Promise<T>,
): Promise<T> {
const existing = await store.find(key.value);
if (existing) return existing.result; // ここが冪等✨
const result = await action();
await store.save({
key: key.value,
createdAt: new Date(),
result,
});
return result;
}
⚠️本番では「同時に2つ来た」レースを防ぐ必要があるよ(後で説明するね)🏎️💥
6-4. PayOrderユースケースに組み込む💳🧾
(ここではRepositoryやOrder集約は“あるもの”として書くよ)
type PayOrderInput = {
orderId: string;
idempotencyKey: string;
};
type PayOrderOutput = {
orderId: string;
status: "PAID";
};
export class PayOrderUseCase {
constructor(
private readonly orderRepo: { findById(id: string): Promise<any>; save(order: any): Promise<void> },
private readonly idempotencyStore: IdempotencyStore<PayOrderOutput>,
) {}
async execute(input: PayOrderInput): Promise<PayOrderOutput> {
const key = IdempotencyKey.from(input.idempotencyKey);
return withIdempotency(this.idempotencyStore, key, async () => {
const order = await this.orderRepo.findById(input.orderId);
// ドメインの不変条件:「支払い済みは支払えない」などは order.pay() が守る想定🏯🔒
order.pay(); // 例:状態遷移 + Domain Event追加など
await this.orderRepo.save(order);
return { orderId: input.orderId, status: "PAID" };
});
}
}
7) テストで“二重でも安全”を確認しよ🧪🔁✅
同じキーで2回叩いても「結果が同じ」になるテスト✨
import { describe, it, expect } from "vitest";
describe("PayOrderUseCase idempotency", () => {
it("same idempotency key returns the same result without double processing", async () => {
// fake order + repo
const order = {
paid: false,
pay() {
if (this.paid) throw new Error("Already paid");
this.paid = true;
},
};
let saveCount = 0;
const repo = {
async findById() { return order; },
async save() { saveCount++; },
};
const store = new InMemoryIdempotencyStore<{ orderId: string; status: "PAID" }>();
const uc = new PayOrderUseCase(repo, store);
const input = { orderId: "order-1", idempotencyKey: "uuid-like-key-0000000001" };
const r1 = await uc.execute(input);
const r2 = await uc.execute(input);
expect(r1).toEqual(r2);
expect(saveCount).toBe(1); // 2回呼ばれても保存は1回分✨
});
});
8) 超重要:本番の落とし穴(レース対策)🏎️💥🛡️

「同じ冪等キーのリクエストがほぼ同時に2つ来た」場合、これが起きる😵
- Aが
find()→ まだ無い - Bも
find()→ まだ無い - Aが
action()実行 - Bも
action()実行 → 二重処理発生💥
本番での定番対策🍀
- DBに
idempotency_keyの UNIQUE制約を貼る🔒 - 「なければINSERT(原子的)」にする(トランザクション)🧾
- INSERT競合したら「先に入れた結果を読む」に切り替える🔁
このへんはインフラ層の責務で、アプリ層のコードはスッキリ保てるよ✨🏗️
9) 冪等キー設計のコツ(地味に効く)🔑✨
-
キーのスコープを決める:
- 「この操作(PayOrder)に対して一意」なのか
- 「ユーザー × 操作 × 注文」なのか👤🧾
-
**保存期間(TTL)**を決める:
- ずっと保存だと肥大化する🥲 → 期間を決めて掃除🧹
-
結果を保存するか:
- 「同じレスポンスを返したい」なら保存が便利📦
- 「処理をスキップするだけ」なら“処理済み印”でもOK✅
-
イベント側は eventId で重複排除が分かりやすい📮🛡️
10) “イベント購読”の冪等化(ミニ版)🔔🔁
イベントハンドラは、基本こうするよ👇
-
handled_eventsみたいな保存先にeventIdhandlerNamehandledAtを記録✍️
-
すでに記録があったら 処理をスキップ✨
クラウド側で重複検出機能があっても(例:Azure Service Bus)📬、アプリも冪等にしておくと“安心感”が段違いだよ🛡️💖 (Microsoft Learn)
11) AI(Copilot/Codex)に頼むなら、この聞き方が強いよ🤖💬✨
そのまま貼れるプロンプト例👇
- 「PayOrderを冪等化したい。冪等キーを受け取り、処理済みなら前回結果を返すラッパー関数をTypeScriptで。ストレージはinterfaceで抽象化して」🔁🧩
- 「同時に2件来るレースを防ぐために、DBのUNIQUE制約前提の疑似コードも併記して」🏎️🔒
- 「イベント購読側で eventId を保存して重複排除するInbox(ProcessedMessage)パターンの雛形を出して」📮✅
“コード生成”は骨格だけ出してもらって、ルール(不変条件)と責務分担は自分が握るのがコツだよ✊💡
12) まとめ(この章のチェックリスト)✅🛡️
- 冪等性は「重複が来る前提」の防御設計🔁
- ドメインの不変条件だけでは外部連携で負けることがある💳
- **冪等キー(要求の冪等化)**と **重複排除(イベントの冪等化)**を使い分ける🔑📮
- 本番は レース対策(原子性 + UNIQUE) が必須🏎️🔒
ちょい課題(次章に効くやつ)🎓✨
-
PlaceOrderにもidempotencyKeyを付けて冪等化してみよう🧾🔁 -
“同じキーだけど入力が違う”場合(悪意 or バグ)にどうする?を考えてみよう😈🧠
- 例:キーに「入力ハッシュ」を紐づけて検出する案など💡
次の第97章(Outbox)で、「DB更新と通知のズレ」までまとめて強くしていくよ📤📬🔥