メインコンテンツまでスキップ

第36章 Readモデル分離③ 冪等性(2回走っても壊れない)🔁🧱✨

この章はね、CQRSをやるならほぼ必修だよ〜!😊 「二重送信」「リトライ」「再実行」が起きても、DBやReadモデルが壊れないようにする考え方と実装をつくるよ💪💕


1) 冪等性ってなに?(超ざっくり)🍵🔁

同じ操作を2回やっても、結果が“1回やった時と同じ”になる性質のことだよ✨ たとえば…

  • ✅ 「注文を作成」ボタンを2回連打しても、注文が2件できない
  • ✅ バックグラウンド処理が同じイベントを2回処理しても、Readモデルが二重更新されない
  • ✅ ネットワークが不安定で同じリクエストを再送しても、事故らない

冪等性ってなに?(超ざっくり)🍵🔁

HTTPの世界でも「冪等」って定義があって、PUT/DELETE は冪等、POST は冪等が保証されない、みたいな話があるよ📮 (※定義そのものは RFC に載ってる) (RFC エディタ)


2) CQRSで冪等性が“爆重要”な2箇所💣✨

A. Command API(書き込み)の入口 ✍️📮

POSTで「作成」系をやるとき、二重送信=二重作成になりがち😱 → ここは「冪等化」して守る価値が高い!

B. Projection(Readモデル更新)🪞📊

Readモデル更新は、現実には “少なくとも1回” (at-least-once) 実行になりやすいのね。 つまり、同じイベントが2回届く/2回処理されるが普通に起きる😇 → だから Projection側は冪等がデフォ


3) よくある「二重」の原因あるある😵‍💫📶

  • ユーザーの二度押し👆👆
  • ブラウザが「送ったかわからん…」で再送
  • モバイル回線でタイムアウト→アプリがリトライ
  • バックグラウンド処理が途中で落ちて、再開時に同じメッセージを再処理
  • キュー/イベント配送が “同じのを2回渡す” ことがある(仕様として普通)

4) 実装パターン①:Idempotency-KeyでPOSTを冪等にする🔑✨

考え方(めっちゃ大事)🧠

クライアントが Idempotency-Key(だいたい GUID)を付けてPOSTする → サーバーはそのキーで「これ前やった?」を判定 → 前やってたら 同じ結果を返す(再実行しない)🎯

Stripeもこの思想で、キーの寿命や、同じキーで内容が違うとエラーにする…みたいな運用をしてるよ(Stripe ドキュメント) ASP.NET Coreでも同じ方針で実装できるよ〜(milanjovanovic.tech)


4-1) DBに「冪等キーの台帳」を作る🗃️🔑

こんな感じのテーブル(またはEntity)を持つよ:

  • Key(Idempotency-Key)
  • Route(どのAPIか)
  • RequestHash(リクエスト内容のハッシュ)
  • StatusCode, ResponseBody(返す結果を保存)
  • CreatedAt

「同じキーで、別の内容を送ってきた」事故を防ぐために RequestHash を保存してチェックするのがコツ👌✨


4-2) Minimal APIのサンプル(超実用版)🧩✨

※SQLiteでもSQL ServerでもOK(ここではDB差分を吸収する書き方にしてるよ)

using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite("Data Source=app.db")); // 好きなDBでOK

var app = builder.Build();

app.MapPost("/orders", async (CreateOrderRequest req, HttpContext http, AppDbContext db) =>
{
// 1) クライアントが送ってくる冪等キー(なければ通常処理にする/必須にするは方針次第)
var key = http.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(key))
{
// 教材としては“必須”にしちゃうのがわかりやすい
return Results.BadRequest(new { message = "Idempotency-Key を付けてね🙏" });
}

var route = "/orders";

// 2) リクエスト内容のハッシュ(同じキーで別内容を防ぐ)
var reqBytes = JsonSerializer.SerializeToUtf8Bytes(req);
var reqHash = Convert.ToHexString(SHA256.HashData(reqBytes));

// 3) すでに処理済みなら、その結果を返す
var existing = await db.IdempotencyEntries
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Key == key && x.Route == route);

if (existing is not null)
{
if (existing.RequestHash != reqHash)
return Results.Conflict(new { message = "同じIdempotency-Keyで別内容は送れないよ🙅‍♀️" });

return Results.Text(existing.ResponseBody, "application/json", statusCode: existing.StatusCode);
}

// 4) “最初の1回だけ” 実行する(競合しても壊れないようにDBの一意制約を使う)
await using var tx = await db.Database.BeginTransactionAsync();

try
{
// 4-1) まずキーを確保(ここで一意制約が効く)
var entry = new IdempotencyEntry
{
Id = Guid.NewGuid(),
Key = key,
Route = route,
RequestHash = reqHash,
StatusCode = 0,
ResponseBody = "",
CreatedAt = DateTimeOffset.UtcNow
};

db.IdempotencyEntries.Add(entry);
await db.SaveChangesAsync();

// 4-2) ここから“本体”の処理(例:注文作成)
var orderId = Guid.NewGuid();

// 本当はWriteモデル保存+Outbox追加…などをする
// (ここでは結果だけ作る)
var responseObj = new { orderId };

var responseJson = JsonSerializer.Serialize(responseObj);

// 4-3) 結果を保存(次回以降はこの結果を返す)
entry.StatusCode = StatusCodes.Status201Created;
entry.ResponseBody = responseJson;
await db.SaveChangesAsync();

await tx.CommitAsync();
return Results.Text(responseJson, "application/json", statusCode: StatusCodes.Status201Created);
}
catch (DbUpdateException)
{
// 同時に同じキーが来て、先に誰かが保存した可能性
await tx.RollbackAsync();

var saved = await db.IdempotencyEntries
.AsNoTracking()
.FirstAsync(x => x.Key == key && x.Route == route);

if (saved.RequestHash != reqHash)
return Results.Conflict(new { message = "同じIdempotency-Keyで別内容は送れないよ🙅‍♀️" });

return Results.Text(saved.ResponseBody, "application/json", statusCode: saved.StatusCode);
}
});

app.Run();

record CreateOrderRequest(string CustomerName, decimal Total);

class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

public DbSet<IdempotencyEntry> IdempotencyEntries => Set<IdempotencyEntry>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<IdempotencyEntry>()
.HasIndex(x => new { x.Key, x.Route })
.IsUnique();
}
}

class IdempotencyEntry
{
public Guid Id { get; set; }
public string Key { get; set; } = default!;
public string Route { get; set; } = default!;
public string RequestHash { get; set; } = default!;
public int StatusCode { get; set; }
public string ResponseBody { get; set; } = default!;
public DateTimeOffset CreatedAt { get; set; }
}

✅ これで「同じキーのPOST」を何回投げても、最初の結果が返るようになるよ🔁✨


5) 実装パターン②:Projectionを冪等にする(Inboxで重複排除)📥🪞✨

ここが第36章の本丸〜!🏯✨ Readモデル更新は、同じイベントが2回届く前提で作るのが基本だよ😺

5-1) アイデア:Inboxテーブルを作る📥

「このイベントID、処理したっけ?」を記録する台帳ね。

  • InboxMessageId(イベントID / OutboxMessageId)
  • ProcessedAt

InboxMessageId を一意制約にしておくと、 同じイベントが2回来ても 2回目のINSERTが失敗=重複だと分かる


5-2) Projection処理の鉄板アルゴリズム(超重要)🧠🧱

  1. トランザクション開始🔒
  2. Inboxに MessageId をINSERT(重複なら即終了)📥
  3. Readモデルを更新🪞
  4. Outbox側に「処理済み」を記録(同一DBなら一緒に)✅
  5. コミット🎉

これで「途中まで成功して壊れる」みたいなのが激減するよ💪


5-3) Inbox + ReadモデルのEntity例🧩

class InboxMessage
{
public Guid MessageId { get; set; }
public DateTimeOffset ProcessedAt { get; set; }
}

class OrderListItem
{
public Guid OrderId { get; set; }
public string CustomerName { get; set; } = default!;
public decimal Total { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<InboxMessage>()
.HasKey(x => x.MessageId); // これが“重複排除の要”✨

modelBuilder.Entity<OrderListItem>()
.HasKey(x => x.OrderId); // OrderIdが同じなら2回INSERTできない
}

5-4) Projection(イベント処理)例🔁🪞

async Task ProjectOrderCreatedAsync(Guid messageId, OrderCreatedV1 ev, AppDbContext db)
{
await using var tx = await db.Database.BeginTransactionAsync();

try
{
// 1) InboxにイベントIDを登録(重複ならここで落ちる)
db.InboxMessages.Add(new InboxMessage
{
MessageId = messageId,
ProcessedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();

// 2) Readモデル更新(同じOrderIdなら2回INSERTできない)
var exists = await db.OrderListItems.AnyAsync(x => x.OrderId == ev.OrderId);
if (!exists)
{
db.OrderListItems.Add(new OrderListItem
{
OrderId = ev.OrderId,
CustomerName = ev.CustomerName,
Total = ev.Total,
CreatedAt = ev.CreatedAt
});
await db.SaveChangesAsync();
}
// ここを “Upsert” にすると、さらに強いよ💪✨(更新イベントにも対応しやすい)

await tx.CommitAsync();
}
catch (DbUpdateException)
{
// Inboxの一意制約に引っかかった=すでに処理済み
await tx.RollbackAsync();
return; // 2回目以降は何もしない😊
}
}

record OrderCreatedV1(Guid OrderId, string CustomerName, decimal Total, DateTimeOffset CreatedAt);

✅ これで、同じmessageIdを2回処理してもReadモデルが壊れない🎉


6) ミニ演習:二重送信 & 二重Projectionでも壊れないのを確認しよう🧪🔁

演習A:同じIdempotency-KeyでPOSTを2回投げる📮

例:HTTPファイル(VS / Rider / 拡張機能で実行できるやつ)

POST https://localhost:5001/orders
Content-Type: application/json
Idempotency-Key: 11111111-1111-1111-1111-111111111111

{
"customerName": "Sakura",
"total": 1980
}

👉 これを2回送っても、返ってくる orderId は同じになるのが理想だよ🔁✨


演習B:同じmessageIdでProjectionを2回呼ぶ🪞

  • ProjectOrderCreatedAsync(messageId, ev, db)2回呼ぶ
  • OrderListItems を見る
  • 行が 1行だけになっていれば勝ち🏆✨

7) つまずきポイント集(ここ超あるある)😇🧯

😱「部分成功が残って壊れる」

トランザクションで「Inbox登録+Read更新」をまとめるのが効く🔒✨

😵「同じキーで内容を変えて送ってきた」

RequestHashで検知して 409 Conflict にしよ🙅‍♀️✨ (Stripeも“同じキーの別パラメータ”は事故防止で扱いがあるよ)(Stripe ドキュメント)

🧊「冪等テーブルが増え続ける」

→ 期限を決めて掃除しよ🧹 例:24時間〜数日で削除、みたいな運用がよくあるよ(Stripe ドキュメント)

🧨「冪等=“絶対に1回だけ”だと思ってた」

冪等は “何回やっても同じ結果”。 “絶対に1回だけ” (exactly-once) はもっと難しい世界…!なのでまず冪等で守るのが現実的😊


8) AI(Copilot等)に頼むと捗るプロンプト例🤖✨

  • 「ASP.NET Core Minimal APIで Idempotency-Key を使った冪等POSTを実装して。DBはEF Core。重複キーは同じレスポンスを返して」
  • 「Inboxテーブルで重複排除するProjectionの実装例。トランザクション境界も含めて」
  • 「同じIdempotency-Keyで別リクエストが来たときの設計(409返す、ログ出す、監視項目)を提案して」

🧡 ただし!AIはたまに 「一意制約が無いのに“二重防止できる風”」みたいなコードを出すので、**“DBの一意制約どこ?”**だけは必ずチェックしてね🔍👀


9) まとめ🎁✨

  • 冪等性は「二重送信」「再実行」「リトライ」の事故を止める盾🛡️✨
  • Command APIは Idempotency-Key + 結果保存 が強い🔑
  • Projectionは Inbox(重複排除)+ 一意制約 + トランザクション が鉄板📥🧱
  • “2回走っても壊れない” ができると、CQRSが一気に現実で戦える💪😺

必要なら次の第37章(セキュリティ & API契約)につながる形で、

  • 「Idempotency-Keyを誰スコープにする?(ユーザー単位?クライアント単位?)」🔐
  • 「ログにキーを残す?PIIどうする?」🧾
  • 「Rate limitと併用する?」🚦(ASP.NET Coreにも公式のレート制限があるよ)(Microsoft Learn)

…みたいな“卒業前の最終チェック”もセットで作れるよ😊✨