第32章:Outboxと冪等性(現実運用の最低ライン)📮🔁
32.1 この章でわかること 🎯✨
- 「イベント取りこぼし」「二重処理」がどうやって起きるかを説明できる 😱💥
- Outboxパターンの“考え方”と、最小のテーブル設計ができる 📮🧠
- 「少なくとも1回(at-least-once)」前提で、冪等にして事故を止められる 🔁🛡️
- EF Coreで “業務データ + Outbox” を同じ保存で確定するイメージが掴める 💾✨
32.2 まず怖い話:取りこぼし&二重送信 😱📨
取りこぼし(送れてないのに、DBは確定してる)🕳️💔
たとえば「注文確定 → ‘支払い依頼イベント’ を送る」って流れで…
- DBに注文は保存できた ✅
- その直後、アプリが落ちた 💥
- “イベント送信” が実行されなかった ❌
👉 すると「支払い依頼が飛ばないのに注文だけある」みたいな、地味にヤバい状態になります…😇
二重送信(二回送っちゃった)👯♀️📨
逆に…
- イベント送信は成功した ✅
- “送信済みフラグ更新” の前にアプリが落ちた 💥
- 再起動後、同じイベントをまた送る 🔁
👉 受け手が同じ処理を2回やると「二重課金」「二重引当」「二重発送準備」みたいな事故に直結します 💸📦🔥
32.3 Outboxパターン:発想はめちゃシンプル 📮✨

ポイントはこれだけ👇
- 業務データを保存するのと同じタイミングで
- 「あとで送るイベント」もDBに一緒に保存する
つまり「送信」じゃなくて「送信予約のメモをDBに残す」感じ📮📝
イメージ図(ざっくり)👇
-
1回の保存(同じトランザクション)で
- Orders(注文)✅
- OutboxMessages(送る予定のイベント)✅ を同時に確定する💾
そのあとで(別プロセス/別スレッドで)
- OutboxDispatcher が OutboxMessages を読んで
- メッセージブローカーや外部APIに配送する📨🚚
こうすれば「注文は確定したのにイベントが消えた」が起きにくくなるよ、って仕組みです😊
32.4 “なぜ効くの?”の根っこ:SaveChangesの性質 🧠💡
EF Coreは基本的に 1回の SaveChanges が “全部成功 or 全部失敗” になるように、DBが対応していればトランザクションで包みます。(Microsoft Learn)
👉 だから「注文の保存」と「Outbox行の保存」を同じSaveChangesでやれば、
- 注文だけ成功、Outboxだけ失敗 みたいな中途半端が減ります ✨
32.5 Outboxテーブル:最小の現実ライン 📮🧱
最低限これがあると強い💪
Id(GUID)… Outbox行の主キー 🆔OccurredOn(日時)… いつ起きたイベント? ⏰Type(文字列)… イベント型名(例:OrderPlaced)🏷️PayloadJson(文字列)… イベントの中身(JSON)📦ProcessedOn(日時 nullable)… 送信完了したら埋める ✅AttemptCount(int)… 何回失敗した? 🔁LastError(文字列 nullable)… 最後のエラー 🧯
さらに実運用っぽくするなら👇
NextRetryAt(次に再試行する時刻)⏳LockId/LockedUntil(複数ワーカー対策)🔒
32.6 実装パターンは2つあるよ(初心者向けに整理)🧩✨
A) 手動でOutboxに入れる(理解しやすい)🖐️📮
アプリ層(ユースケース)で 「注文を保存する前にOutbox行も追加」する方式。
B) SaveChangesInterceptorで半自動(スッキリ)🪄📮
EF Coreの Interceptor を使って 「SaveChangesされる直前に、ドメインイベント→Outbox行」を自動で追加する方式。 EF Coreは SaveChanges を含む処理を Interceptor で横取りできるよ、って公式の仕組みがあるよ(Microsoft Learn)
32.7 ミニ実装:Outbox(半自動版)📮🪄(EF Core)
① ドメインイベントを溜める(集約側)📣🌳
public interface IDomainEvent
{
Guid EventId { get; }
DateTimeOffset OccurredOn { get; }
}
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _events;
protected void AddEvent(IDomainEvent ev) => _events.Add(ev);
public void ClearEvents() => _events.Clear();
}
② Outboxエンティティ(DBに残す“発送予定メモ”)📮📝
public sealed class OutboxMessage
{
public Guid Id { get; init; } = Guid.NewGuid();
public Guid EventId { get; init; }
public DateTimeOffset OccurredOn { get; init; }
public string Type { get; init; } = "";
public string PayloadJson { get; init; } = "";
public DateTimeOffset? ProcessedOn { get; set; }
public int AttemptCount { get; set; }
public string? LastError { get; set; }
}
③ SaveChangesInterceptorで「イベント→Outbox」を自動追加 🪄📮
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public sealed class OutboxInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var db = eventData.Context;
if (db is null) return base.SavingChangesAsync(eventData, result, cancellationToken);
// AggregateRoot を拾って、ドメインイベントを集める
var aggregates = db.ChangeTracker.Entries()
.Select(e => e.Entity)
.OfType<AggregateRoot>()
.ToList();
var events = aggregates.SelectMany(a => a.DomainEvents).ToList();
if (events.Count == 0) return base.SavingChangesAsync(eventData, result, cancellationToken);
// Outboxに詰める
foreach (var ev in events)
{
db.Set<OutboxMessage>().Add(new OutboxMessage
{
EventId = ev.EventId,
OccurredOn = ev.OccurredOn,
Type = ev.GetType().FullName ?? ev.GetType().Name,
PayloadJson = JsonSerializer.Serialize(ev, ev.GetType())
});
}
// 重要:イベントはここでクリア(同じSaveChangesで二重登録しない)
foreach (var a in aggregates) a.ClearEvents();
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
Interceptorは DbContext 設定で登録できます(AddInterceptors)(Microsoft Learn)
32.8 OutboxDispatcher:DBから拾って送る係 🚚📨(超ミニ)
やることはこの3つだけ👇
- 未処理(
ProcessedOn is null)を少し取る - 送る(Publish/HTTP/何でも)
- 成功なら
ProcessedOnを埋める。失敗ならAttemptCount+ エラー保存
using Microsoft.EntityFrameworkCore;
public sealed class OutboxDispatcher : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public OutboxDispatcher(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var batch = await db.OutboxMessages
.Where(x => x.ProcessedOn == null)
.OrderBy(x => x.OccurredOn)
.Take(20)
.ToListAsync(stoppingToken);
foreach (var msg in batch)
{
try
{
// ここで実際の送信(例:メッセージブローカー or 外部API)
// await _bus.PublishAsync(msg.Type, msg.PayloadJson, msg.EventId);
msg.ProcessedOn = DateTimeOffset.UtcNow;
msg.LastError = null;
}
catch (Exception ex)
{
msg.AttemptCount++;
msg.LastError = ex.Message;
}
}
await db.SaveChangesAsync(stoppingToken);
}
}
}
※このDispatcherは「同じメッセージを2回送る可能性」が普通にあります(落ちるタイミング次第)😇 だから次が超重要👇
32.9 冪等性(Idempotency):Outboxの相棒 🧡🔁
結論:イベント処理は「少なくとも1回」前提で考える 🧠
イベント駆動は at-least-once(重複があり得る) を前提にするのが責任ある設計だよ、ってMicrosoftのガイダンスでも明言されています(Microsoft Learn) 👉 だから 受け手は冪等 にするのが“最低ライン”✨
32.10 受け手を冪等にする:Inboxテーブル(最小)📥✅
仕組み(超シンプル)📌
-
受け手DBに
ProcessedMessages(MessageId)を作る -
何か来たら、まず
MessageIdを INSERT してみる- 成功 → 初回だから処理してOK ✅
- 失敗(ユニーク違反)→ 既に処理済みなので即return 🏃♀️💨
ミニコード(雰囲気)📥
public sealed class ProcessedMessage
{
public Guid MessageId { get; init; }
public DateTimeOffset ProcessedOn { get; init; } = DateTimeOffset.UtcNow;
}
public async Task HandlePaymentSucceededAsync(Guid messageId, CancellationToken ct)
{
// 1) まず Inbox に登録(ユニーク制約で重複を弾く)
_db.ProcessedMessages.Add(new ProcessedMessage { MessageId = messageId });
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// 既に処理済み → 何もしない(冪等!)
return;
}
// 2) ここから “副作用あり” の本処理
// - 注文を Paid にする
// - 発送準備をキックする
// など…
await _db.SaveChangesAsync(ct);
}
これで「同じイベントが2回来ても、1回分だけ効く」💪✨
32.11 さらに現実っぽく:フレームワークのOutbox(例:MassTransit)🧰📮
「自作が大変〜😭」ってときは、Outbox/Inboxを仕組みとして提供してるライブラリもあります。 MassTransitのEF Core Outboxは、InboxState / OutboxMessage / OutboxState みたいなテーブルを使って配送と重複検出を支援します(masstransit.io)
設定例の雰囲気(最小イメージ)👇(概念だけね!)
AddEntityFrameworkOutbox<DbContext>(...)AddInboxStateEntity()/AddOutboxMessageEntity()/AddOutboxStateEntity()など(masstransit.io)
32.12 実運用チェックリスト(最低ライン)✅📋✨
Outbox側 📮
- 業務データとOutbox行を 同じSaveChanges で確定する(同一トランザクション)(Microsoft Learn)
- Outboxに
ProcessedOn/AttemptCount/LastErrorを持つ - OutboxDispatcherは リトライ前提(落ちてもまた拾える)🔁
受け手側(冪等)📥
- “少なくとも1回” が前提。重複が来ても壊れない設計にする(Microsoft Learn)
- Inbox(ProcessedMessages)で MessageId重複を弾く
- ドメイン的にも「同じ状態なら何もしない」ができると最強(状態機械の発想🚦)
32.13 総合ミニ課題:注文→支払い→(遅れて)発送準備 ☕️💳📦
お題 🎀
「注文確定→支払い→(遅れて)発送準備」を、即時整合と最終的整合を分けて設計しよう✨
① 即時整合にする部分(同一トランザクションで守る)🔒
例:
- 注文作成(明細・合計・注文状態)
- 「注文は確定した」まで
② 最終的整合でOKな部分(イベント + Outbox)⏳
例:
- 支払い処理(外部決済)
- 発送準備の開始(別集約/別サービスでもOK)
設計してほしいアウトプット ✍️✨
-
どのタイミングで何のイベントを出す?(過去形で命名📣)
OrderPlacedPaymentSucceededなど
-
Outboxに何が入る?(Type/Payload/Id)📮
-
受け手はどう冪等にする?(Inbox or ユニーク制約)📥🔁
-
状態遷移はどうなる?(例:Placed → Paying → Paid → ShippingReady)🚦
勝利条件🏆✨
- 「どこが即時整合で、どこが最終的整合か」を言葉で説明できる
- 「二重イベントが来ても壊れない」対策が入ってる(冪等!)
- 「イベント取りこぼし」をOutboxで塞いでる📮🛡️
32.14 今の“最新世代”メモ(2026-01-27時点)🗓️✨
- EF Core 10 は 2025年11月リリースのLTSで、.NET 10が必要だよ(Microsoft Learn)
- 2026年1月の更新として .NET 10.0.2 が案内されてるよ(Microsoft サポート)
- EF Core も NuGet上で 10.0.2 が配布されてる(例:
Microsoft.EntityFrameworkCore)(nuget.org)
32.15 AI(Copilot/Codex)に頼ると速いポイント 🤖✨
- 「OutboxMessageの設計(列とインデックス)」を3案出してもらう📮
- 「OutboxDispatcherの失敗時リトライ戦略」を箇条書きで出してもらう🔁
- 「Inbox方式の冪等ハンドラ」を、ユニーク制約前提で作ってもらう📥
- そのまま採用せず、**“重複が来たらどうなる?”**を必ず質問して確認するのがコツ🙆♀️✅