イベント駆動アーキテクチャとは?pub/sub・Saga・冪等性を動くコードで
「いい感じに疎結合で」で組むと障害時に追えない。イベントとコマンドの違い、pub/sub、結果整合性、Sagaを動くNode.jsコードで整理した設計メモ。
「ユーザー登録したら、ようこそメールも送って、CRMにも入れて、Slackにも通知して」
最初はひとつの登録APIに全部詰め込みました。動きました。半年は。でもある日、メール業者の障害でメール送信がタイムアウトしただけで、登録APIまるごとが固まったんです。メールが理由でユーザー登録が失敗する。冷静に考えるとおかしい。でも、全部を一本の処理に串刺しにすると、こうなります。
このとき僕が必要だったのが、イベント駆動アーキテクチャ(EDA)でした。「登録した」という事実だけを放流して、メール・CRM・Slackはそれを各自で拾う。誰かがコケても、登録は登録で終わる。今日はこの考え方を、専門用語をなるべく噛み砕いて、写して動くコード付きで説明します。
この記事の要点
- イベントは「起きた事実」(user.created)、コマンドは「やれという命令」(sendEmail)。EDAは事実を放流して、受け手が各自で動く設計
- 放流と購読をつなぐのが pub/sub。送り手は受け手を知らなくていいので、後から購読者を足しても送り手を触らずに済む
- キューは「少なくとも1回」届く前提。だから同じイベントが2回来ても壊れない 冪等性 が必須
- 複数サービスをまたぐ処理は、巨大トランザクションではなく Saga(小さい一歩+失敗したら取り消す一歩)で組む
- 万能ではない。1サービスで完結する処理や、即座に結果が要る画面に無理に入れると、デバッグ地獄になる
イベントとコマンドは別物
最初に、ここだけは外さないでください。イベントは過去形、コマンドは命令形です。
イベントは「もう起きたこと」を知らせます。user.created(ユーザーが作成された)、payment.authorized(決済が承認された)。発行する側は、誰がどう使うかを気にしません。事実を放流するだけ。
コマンドは「これをやれ」と特定の相手に指示します。sendWelcomeEmail(歓迎メールを送れ)。送り手は、受け手と「何をしてほしいか」まで知っている必要があります。
この違いが効いてくる場面を挙げます。user.created というイベントなら、後から「登録したらアンケートも送りたい」となったとき、アンケート係を新しく購読させるだけで済みます。登録APIは一切触りません。これが sendWelcomeEmail というコマンド設計だと、「メールを送れ、CRMに入れろ、アンケートも送れ」と、登録API側が指示先を増やし続けることになります。やがて登録APIが“何でも知っているゴッドオブジェクト”になる。これが冒頭の事故の正体でした。
迷ったときの目安はこれです。「これは事実の通知か、特定の相手への依頼か」。事実ならイベント、依頼ならコマンド。両方が混ざる設計もありますが、まず「事実を放流する」発想に倒すと、結合がゆるみます。
pub/sub:送り手は受け手を知らない
イベントを放流する仕組みが pub/sub(publish/subscribe) です。日本語にすると「発行・購読」。
ラジオ局を想像してください。放送局(publisher)は電波を流すだけで、誰が聞いているかは知りません。リスナー(subscriber)は、自分の聞きたい周波数に合わせるだけ。局とリスナーは互いを直接知らない。間に「電波」という通路があるだけです。この通路にあたるのが イベントバス や メッセージブローカー と呼ばれるものです。
用語を四つだけ覚えれば、最初は十分です。
| 用語 | 役割 | ラジオの例え |
|---|---|---|
| producer(publisher) | イベントを出す側 | 放送局 |
| consumer(subscriber) | イベントを受け取る側 | リスナー |
| event bus / broker | イベントを配送する通路 | 電波・周波数 |
| schema | payload(データ本体)の約束事 | 放送フォーマット |
pub/subの何がうれしいのか。送り手が受け手を知らないことです。登録APIは「user.created を放流した」で仕事が終わる。メール係・CRM係・Slack係が増えようが減ろうが、登録API側のコードは1行も変わりません。受け手が一時的に落ちていても、放流自体は成功する。冒頭の「メールが理由で登録が失敗する」が、構造的に起きなくなります。
公式の考え方をひとつ押さえておくなら、イベントの共通フォーマットは CloudEvents が入口になります。id・type・source・time といった共通メタデータを決めておくと、どのブローカーに載せても観点がぶれません。
メッセージキューの違い(SQS・RabbitMQ・Kafka)
pub/subを実際に動かすには、イベントを運ぶインフラが要ります。代表的な三つを、ざっくりの性格で並べます。最初から完璧に選ぶ必要はなくて、「どれが自分の用途に近いか」をつかめば十分です。
| 種類 | 性格 | 向いている用途 | つまずきやすい点 |
|---|---|---|---|
| Amazon SQS | フルマネージドのシンプルなキュー。運用が軽い | 1対1のジョブ処理、後続タスクの非同期化 | 1つのキューを複数用途で共有すると見通しが悪い |
| RabbitMQ | 柔軟なルーティング。fanout / topic 配信が得意 | 1イベントを複数consumerへ配る pub/sub | 設定項目が多く、運用知識が要る |
| Apache Kafka | ログ型。イベントを保持して後から読み直せる | 大量ストリーム、イベントの再生(replay) | 構成が重く、小規模だと過剰になりがち |
ものすごく雑にまとめると、こうです。「ジョブを1個ずつ処理したい」ならSQS、「1イベントを複数の購読者に配りたい」ならRabbitMQ、「イベントを履歴として残して後から読み直したい」ならKafka。
ここで地味に効くのが、Kafkaの「後から読み直せる」性質です。SQSやRabbitMQは、基本的に処理したメッセージは消えます。Kafkaはログとして残るので、「consumerにバグがあって3時間分の処理を間違えた」ときに、その区間を頭から再生して直せる。この「読み直し(replay)」は後で出てくるので、頭の隅に置いておいてください。
AWSでイベントの絞り込みと配送をマネージドでやりたいなら、Amazon EventBridge も選択肢です。キュー単体の再試行・DLQ設計をもっと深掘りしたいなら、ジョブキューで二重課金を止める に依存なしの動くコードでまとめてあります。
結果整合性と冪等性:2回来ても壊さない
EDAに移ると、必ず向き合うことになるのが 結果整合性(eventual consistency) です。
同期処理なら、登録ボタンを押した瞬間に、メールもCRMも全部終わっています。非同期だと、登録は終わったけどメールはまだ、CRMはこれから、という“ズレ”が一瞬生まれる。最終的には揃うけれど、今この瞬間は揃っていないかもしれない。これが結果整合性です。
このズレ自体は悪ではありません。問題は、それを画面でどう見せるかです。「登録ありがとうございます。確認メールを数分以内にお送りします」のように、少し待つ前提の文言にしておく。逆に「即座に全部終わっている」前提でUIを作ると、ユーザーが「メール来ない!」と混乱します。
そしてもうひとつ、絶対に外せないのが 冪等性(idempotency) です。難しい言葉ですが、意味は「同じ操作を何回やっても、結果が1回分と同じ」。
なぜ要るのか。多くのキューやブローカーは「少なくとも1回(at-least-once)」配送を採用しています。これは「0回になりにくい」代わりに「2回以上届くことがある」という意味です。ネットワークの再送、consumerの再起動、ちょっとしたタイミングのズレで、同じイベントが平気で2回飛んできます。
ここで冪等性がないと、どうなるか。歓迎メールが2通届く。ポイントが2回付く。請求が二重に立つ。冒頭で僕がメールを串刺しにしていた頃、retryを雑に入れたせいで同じ人に歓迎メールを3通送ったことがあります。お詫びメールを送ったら、それも2通行きました。笑えない。
対策はシンプルです。イベントに idempotency key(同じ操作を見分ける鍵)を持たせ、consumer側で「この鍵はもう処理済み」を記録する。処理済みなら黙って無視する。これだけで二重処理が止まります。次の章で、実際に動くコードにします。
コピペで動く:冪等なconsumer
依存ライブラリなしで動く、最小のconsumerです。Node.js(v18以降)で node handler.mjs を実行すると、同じイベントを2回処理しても1回分しか副作用が起きないことが確認できます。本番では processed(処理済み記録)を Map ではなくRedisやDBなど共有ストアに置き換えてください。
import crypto from "node:crypto";
// 処理済みイベントの記録(本番はRedisやDBに置き換える)
const processed = new Map();
const deadLetterQueue = [];
// payloadが同じか比べるためのハッシュ
function payloadHash(data) {
return crypto.createHash("sha256").update(JSON.stringify(data)).digest("hex");
}
// 冪等性の鍵。なければ type と id から作る
function eventKey(event) {
return event.idempotencyKey || `${event.type}:${event.id}`;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 失敗したら少し待って再試行する(指数的にバックオフ)
async function withRetry(fn, attempts = 3, delayMs = 200) {
let lastError;
for (let i = 1; i <= attempts; i += 1) {
try {
return await fn();
} catch (e) {
lastError = e;
if (i === attempts) break;
await sleep(delayMs * i);
}
}
throw lastError;
}
async function handleUserCreated(event, services) {
// 知らない型・未対応バージョンは早めに弾く
if (event.type !== "com.claudecodelab.user.created.v1") {
throw new Error(`想定外のイベント型: ${event.type}`);
}
const key = eventKey(event);
const hash = payloadHash(event.data);
const prev = processed.get(key);
// すでに成功済みで中身も同じ → 何もせず無視(これが冪等性の肝)
if (prev?.status === "succeeded" && prev.hash === hash) {
return { status: "duplicate_ignored", key };
}
// 同じ鍵なのに中身が違う → 設計ミスの兆候。止める
if (prev && prev.hash !== hash) {
throw new Error("同じidempotencyKeyで異なるpayloadが届いた");
}
processed.set(key, { status: "processing", hash });
try {
// 副作用のある処理はそれぞれ再試行つきで
await withRetry(() => services.createWorkspace(event.data.userId));
await withRetry(() =>
services.enqueueWelcomeEmail({
userId: event.data.userId,
email: event.data.email,
}),
);
processed.set(key, { status: "succeeded", hash });
return { status: "processed", key };
} catch (e) {
// 何度やってもダメなら、握りつぶさずDLQへ退避する
processed.set(key, { status: "failed", hash, error: e.message });
deadLetterQueue.push({ key, event, error: e.message });
throw e;
}
}
// ---- 動作確認 ----
const services = {
async createWorkspace(userId) {
console.log("ワークスペース作成:", userId);
},
async enqueueWelcomeEmail(msg) {
console.log("歓迎メール投入:", msg.email);
},
};
const event = {
type: "com.claudecodelab.user.created.v1",
id: "evt_01JZ0YV8Y9N3A7Z7K6Y1G9X2Q4",
idempotencyKey: "user.created:usr_123",
data: { userId: "usr_123", email: "[email protected]", plan: "starter" },
};
// 1回目は処理され、2回目は無視される
console.log(await handleUserCreated(event, services));
console.log(await handleUserCreated(event, services));
実行すると、1回目だけ「ワークスペース作成」「歓迎メール投入」が出て、2回目は duplicate_ignored が返ります。processed に処理済みを記録し、同じ鍵なら副作用をスキップする。たったこれだけで、二重課金や二重メールが構造的に止まります。
Claude Codeにこの種のconsumerを書かせるときは、条件を必ず明記してください。「成功済みイベントは再実行しない」「同じ鍵でpayloadが違ったら止める」「何度やっても失敗したらDLQへ退避」。ここを曖昧に「retryも入れといて」とだけ頼むと、無限にメールを送る実装が普通に出てきます。
Sagaパターン:取り消せる一歩で組む
複数のサービスをまたぐ処理になると、新しい悩みが出ます。「全部成功か、全部失敗か」をどう担保するか。
ひとつのDBの中なら、トランザクションで「全部まとめてコミット、ダメなら全部ロールバック」をやってくれます。でも、注文サービス・在庫サービス・決済サービスがバラバラに存在すると、それらをまたいだ巨大トランザクションは現実的に張れません。サービスごとにDBが分かれているからです。
そこで使うのが Sagaパターン です。発想を変えます。「全部を一気にやる」のではなく、小さい一歩を順番に進め、途中でコケたら、それまでの一歩を取り消す一歩(補償)を逆順に実行する。
旅行の予約で例えます。航空券を取る → ホテルを取る → レンタカーを取る。レンタカーが満車で取れなかったら、もう取ったホテルをキャンセルし、航空券もキャンセルする。各ステップに「取る」と「取り消す(キャンセル)」をペアで用意しておく。これがSagaです。
イベント駆動でSagaを回すと、こんな流れになります。
flowchart LR
A["order.placed"] --> B["在庫を確保"]
B -->|成功| C["payment.authorized<br/>決済を確保"]
B -->|失敗| B2["注文を取消"]
C -->|成功| D["order.confirmed"]
C -->|失敗| C2["在庫を解放<br/>=補償"]
C2 --> B2
ポイントは、各ステップが自分の取り消し方を知っていることです。決済が失敗したら、在庫サービスが「在庫を解放する」補償イベントを受けて元に戻す。注文サービスは「注文を取り消す」。誰かが全体を見張るのではなく、イベントの連鎖で前進と後退が進みます。
注意点をひとつ。補償処理にも冪等性が要ります。「在庫を解放する」が2回来ても、二重に在庫が増えたりしないように。Sagaは便利ですが、ステップが増えるほど「成功・失敗・補償」の組み合わせが膨らむので、最初は2〜3ステップの小さい範囲から始めるのが安全です。サービス境界そのものの切り方は、マイクロサービス分割で僕が最初に失敗した話 に失敗談つきでまとめています。
いつ使う/いつ使わない
ここまで読むと万能に見えるかもしれませんが、EDAは銀の弾丸ではありません。合わない場所に入れると、むしろ地獄を見ます。僕が両方やらかしたので、正直に分けます。
向いている場面
- 1つの事実に対して、後続処理が複数あり、今後も増えそう(登録 → メール・CRM・分析・アンケート…)
- 後続処理が遅い/不安定で、本処理を待たせたくない(外部API、メール、重い集計)
- サービスが複数に分かれていて、互いを直接呼び合いたくない
- 監査ログのように「起きたことを記録として残したい」
向いていない場面
- 1つのサービス内で完結し、後続も1つだけ(普通の関数呼び出しで十分。イベント化は過剰)
- ユーザーが結果を即座に画面で見たい(在庫の残数を今すぐ正確に出したい、など同期が要る処理)
- チームがまだ小さく、分散システムのデバッグ体力がない(ログ・トレースの整備コストが先に来る)
最後の点が、いちばん大事かもしれません。EDAは「動かす」より「壊れたときに調べる」ほうが難しい。イベントが消えた、2回来た、順番が入れ替わった——これらを追える観測(event id・correlation id・consumer名・retry回数をログに残す)がない状態で導入すると、障害調査が「たぶんメール係が悪い」で終わります。同期のままで困っていないなら、無理に非同期化しない。これも立派な設計判断です。
よくある質問
Q. イベント駆動とマイクロサービスは同じものですか? 別物です。マイクロサービスは「サービスをどう分けるか」、イベント駆動は「分けたサービスをどうつなぐか」の話。モノリス(1枚岩のアプリ)の中でもイベント駆動は使えますし、マイクロサービスを同期API呼び出しだけでつなぐこともできます。
Q. キューとpub/subは何が違うんですか? ざっくり、キューは「1つのメッセージを1つのconsumerが処理する(仕事の振り分け)」、pub/subは「1つのイベントを複数のconsumerが各自で受け取る(放送)」です。RabbitMQのように両方こなせるものもあります。
Q. 結果整合性だと、データが食い違ったままになりませんか? 最終的には揃います。揃うまでの一瞬のズレを、画面文言(「数分以内に反映されます」)や、失敗時の再処理(replay)で吸収します。「今この瞬間、完全一致が必要」な処理だけは、同期で持つのが安全です。
Q. Sagaパターンは小規模でも必要ですか? 複数サービスをまたぐ「全部成功か全部失敗か」が要る処理がなければ、不要です。1つのDBで完結するなら、普通のトランザクションで十分。Sagaは分散トランザクションの代替なので、分散していない段階では過剰です。
Q. Claude Codeにイベント駆動設計を丸投げしていいですか? おすすめしません。「最適な設計を考えて」だと、もっともらしいけど運用できない図が出てきます。既存API・DB・障害時の復旧条件を読ませて、「このイベント名は曖昧でないか」「payload変更で既存consumerを壊さないか」「重複配送に耐えるか」をレビューさせる使い方が堅実です。
実際に試した結果
検証用のSaaSで、冒頭の「登録APIに全部串刺し」を、user.created を放流するだけのpub/sub構成に組み替えてみました。いちばん効いたのは、コードの量より事故の種類が変わったことです。
以前は「メール業者が落ちる→登録ごと落ちる」でした。組み替え後は、メール係がコケてもDLQにイベントが溜まるだけ。登録は登録で完了し、復旧後にDLQを読み直せば歓迎メールが送られます。障害の影響範囲が、サービス1個分に閉じたんです。
逆に、痛い学びもありました。最初は冪等性を後回しにして、retryだけ先に入れた。案の定、再送で歓迎メールが二重に飛びました。idempotency keyと「処理済みなら無視」を入れた瞬間に止まった。順番が大事で、冪等性を入れてからretryを入れる。これは身体で覚えました。
イベント駆動は、疎結合という気持ちよさの裏に、「2回来る」「順番が変わる」「一瞬ズレる」という現実がセットでついてきます。そこを冪等性とSagaと観測で受け止める。そして、合わない場所には入れない。賢く非同期化するより、転んでも1サービス分で済む構造を先に作る。これが今の僕の結論です。手を動かして整えたくなったら、研修・導入相談 や 教材一覧 も使ってください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。