第23章:Outbox②:最小Outbox実装(テーブルに書くだけ)🗒️✅
(今どきの前提として:.NET 10 + C# 14が最新ラインだよ〜✨)(Microsoft Learn)
今日のゴール🎯
「注文の更新」+「イベント送信の予約(Outboxに書く)」を、同じトランザクションで確実にセットで成功させること!💪✨ 送信はまだしないよ!まずは「書く」だけ✍️🗒️
Transactional Outbox は「DB更新とメッセージ送信(予定)」の二重書き(Dual-Write)問題を避けるための定番パターンだよ〜📦🔁(microservices.io)
まずダメ例😇 → 良い例😎
ダメ例😇(ズレるやつ💥)
- DBの注文を
Paidに更新 ✅ - そのあとメッセージブローカーに送信しようとする 📤
- でもネットワークやブローカー障害で送信失敗… 😭 → DBは更新されたのに、通知だけ飛ばない(ズレ)💔
良い例😎(Outboxでズレ止め🛡️)

- DBの注文を
Paidに更新 ✅ - 同じDBのOutboxテーブルに「送るべきイベント」を1行INSERT ✅
- これらを同一トランザクションでコミット🎯 → 以後、送信処理が落ちても、Outboxに残ってるから後で必ず再送できる💪✨(microservices.io)
手を動かす(C#)⌨️✨
ここでは 最小構成でいくよ!「テーブルに書くだけ」🗒️✅ (配信ループは次章:第24章でやるよ🔁🚚)
1) Outboxテーブル(最低限)🧱🗄️
「まず動く」最小カラムはこれ👇
Id:主キー(Guid)🔑OccurredOnUtc:いつ起きたイベント?🕰️Type:イベント種別(文字列)🏷️Payload:イベント本体(JSON文字列)📦ProcessedOnUtc:配信済みなら日時、未送信ならNULL✅❌
「未送信は ProcessedOnUtc = NULL」って覚えると分かりやすいよ😊✨
2) EF Core のエンティティを作る🧩
using System.Text.Json;
public sealed class OutboxMessage
{
public Guid Id { get; init; } = Guid.NewGuid();
public DateTime OccurredOnUtc { get; init; } = DateTime.UtcNow;
// 例: "Ordering.OrderPaid"
public string Type { get; init; } = default!;
// JSON化したイベント本体
public string Payload { get; init; } = default!;
// NULLなら未送信、値ありなら送信済み
public DateTime? ProcessedOnUtc { get; set; }
}
3) DbContext に足す🧠🛠️
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OutboxMessage>(b =>
{
b.ToTable("OutboxMessages");
b.HasKey(x => x.Id);
b.Property(x => x.Type).HasMaxLength(200).IsRequired();
b.Property(x => x.Payload).IsRequired();
// ポーリングしやすいように(次章で効いてくる✨)
b.HasIndex(x => x.ProcessedOnUtc);
b.HasIndex(x => x.OccurredOnUtc);
});
}
}
4) 「注文更新」+「Outbox書き込み」を同じ SaveChanges に乗せる🧁✨
ここが本章のキモ‼️ 1回の SaveChanges でまとめて保存すると、EF Coreは(プロバイダが対応していれば)トランザクションで包んでくれるよ✅(Microsoft Learn)
例:支払い完了(OrderPaid)をOutboxに積む💳🔔
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public sealed record OrderPaid(Guid OrderId, decimal Amount, DateTime PaidOnUtc);
public sealed class PayOrderService
{
private readonly AppDbContext _db;
public PayOrderService(AppDbContext db) => _db = db;
public async Task PayAsync(Guid orderId, decimal amount)
{
var order = await _db.Orders.SingleAsync(x => x.Id == orderId);
// ① 業務更新(例:支払い済みにする)
order.MarkAsPaid(amount);
// ② “送るべきこと”を Outbox に書くだけ(送らない!)
var evt = new OrderPaid(orderId, amount, DateTime.UtcNow);
_db.OutboxMessages.Add(new OutboxMessage
{
Type = nameof(OrderPaid), // まずは最小でOK(本格運用は後で改善✨)
Payload = JsonSerializer.Serialize(evt),
});
// ③ 1回の SaveChanges でまとめて確定(=同一トランザクションになりやすい)
await _db.SaveChangesAsync();
}
}
✅ これで「注文だけ更新されて、イベントが消える」みたいなズレが激減するよ〜💪✨ (※複数回 SaveChanges したり、別DbContextに跨ると話が変わるので、まずはこの形を基本にしよ😊)
ここでの理解ポイント🧠✨
✅ Outbox行は「配送の予約メモ」🗒️📌
- Outboxに書いた瞬間に「送った」ではないよ!
- 送るのは次章(第24章)で、未送信(
ProcessedOnUtc = NULL)を拾って配送する🔁🚚
✅ なんで “同一トランザクション” が大事?🔒
- DB更新とOutbox INSERT がセットで成功/失敗するから✨
- 「片方だけ成功」が消える(これが勝ち筋)🏆(microservices.io)
ミニ演習📝🌸
OrderShippedイベントも作って、発送処理でOutboxに積んでみよう🚚📦Typeをnameof(...)じゃなくてtypeof(OrderPaid).FullNameにしてみよう🔎(あとでルーティングしやすいよ)Payloadを見やすくするためにJsonSerializerOptions { WriteIndented = true }を試してみよう✨
AI活用プロンプト例🤖✨(コピペOK)
EF Core 10 / .NET 10 前提で、最小の OutboxMessages テーブル設計を提案して。
カラムは「Id, OccurredOnUtc, Type, Payload, ProcessedOnUtc」を必須にして、
インデックス案も添えて。SQL Server想定。
次のC#コードにOutboxを追加して。
「注文更新」と「Outbox追加」を同一 SaveChanges にまとめたい。
例外時に片方だけ保存されないように注意点も教えて。
(コード:…)
OutboxMessage.Type の命名を、後からイベントのバージョニングに耐える形にしたい。
初心者向けに、まずやるべき安全な命名ルールを3案出して。
まとめ(覚える1行)📌✨
**「送信は後でOK。まず Outbox に同じトランザクションで “書いて残す”!」**🗒️✅🔁
おまけ:使いどころ注意⚠️🙂
Outboxは超強いけど、状況によっては“やりすぎ”になることもあるよ〜(複雑さや運用コストが増える)🧯 「本当に外部へ確実に届けたい?」がYESのときに採用すると気持ちいい✨(squer.io)
次の第24章では、いよいよ 「未送信Outboxを拾って配信する」(ポーリング/バッチ/別プロセス)を作って、配送の流れを完成させよ〜🔁🚚🎉