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

第22章:競合って何?(同時更新を体験)⚔️😵

この章でできるようになること🎯✨

  • 「競合(同時更新)」がどういう事故なのか説明できる📣
  • イベントソーシングでも、競合で“不正なイベント”が保存されうることを体験する😱
  • 次章の「expectedVersion(楽観ロック)」がなぜ必要なのか腹落ちする🔒✅

1) 競合ってなに?いちばん短い説明📝⚡

競合のイメージ

同じデータを、別々の人(別々の処理)が、ほぼ同時に更新しようとしてぶつかることだよ⚔️

たとえば…💡

  • Aさん:残高100円だと思って 80円引き出す🏧
  • Bさん:同じく残高100円だと思って 80円引き出す🏧
  • 結果:合計160円引き出して、残高がマイナスに…!?😵‍💫💥

こういう「同時に起きた更新の衝突」を 競合(Concurrency Conflict) って呼ぶよ✨


2) 「イベントソーシングなら安全」…ではない理由😺🔍

イベントソーシングは「状態」じゃなく「出来事(イベント)」を積む設計だよね📚 (イベントを順番に保存して、必要なら再生して状態を復元する)(martinfowler.com)

でも…⚠️ イベントを作る(Decide)ときは、いったん現在状態を復元して判断するよね?🔁🧠 その判断が「古い状態(古い版)」を元に行われると、今の現実とズレたイベントができちゃうの😱

つまりこう👇

  • 「イベントは追記だから安全」✅
  • だけど「追記する内容が、古い情報で作られてたら危険」⚠️

この章は、ここを体で覚える回だよ💪✨


3) 今日の実験シナリオ:銀行口座🏦💰

ルール(不変条件)🧷🛡️

  • 残高は 0未満になっちゃダメ🙅‍♀️(Balance >= 0)

イベント📮

  • MoneyDeposited(100)(100円入金)
  • MoneyWithdrawn(80)(80円出金)

4) 実験①:わざと“危ない”イベントストアで競合を起こす💥🧪

ここでは、競合チェックを一切しない「ナイーブなイベントストア」を使うよ😈 (次章でこれを直す!🔧✨)


4-1. ドメインイベント定義📦✨

public interface IDomainEvent { }

public sealed record MoneyDeposited(decimal Amount) : IDomainEvent;
public sealed record MoneyWithdrawn(decimal Amount) : IDomainEvent;

4-2. 集約(口座)と復元(Rehydrate)🔁🧠

ポイントは2つだよ👇

  • イベントを Apply して状態を作る🔁
  • 何個イベントを適用したかを Version として持つ📌(この章では「最後に適用したイベント番号」)
public sealed class BankAccount
{
public decimal Balance { get; private set; }
public int Version { get; private set; } = -1; // 最後に適用したイベント番号
private readonly List<IDomainEvent> _uncommitted = new();

public IReadOnlyList<IDomainEvent> UncommittedEvents => _uncommitted;

public static BankAccount Rehydrate(IEnumerable<IDomainEvent> history)
{
var acc = new BankAccount();
foreach (var e in history) acc.Apply(e, isNew: false);
return acc;
}

public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
Raise(new MoneyDeposited(amount));
}

public void Withdraw(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
if (Balance - amount < 0) throw new InvalidOperationException("残高不足だよ😢");
Raise(new MoneyWithdrawn(amount));
}

public void ClearUncommittedEvents() => _uncommitted.Clear();

private void Raise(IDomainEvent e) => Apply(e, isNew: true);

private void Apply(IDomainEvent e, bool isNew)
{
switch (e)
{
case MoneyDeposited d:
Balance += d.Amount;
break;
case MoneyWithdrawn w:
Balance -= w.Amount;
break;
default:
throw new NotSupportedException(e.GetType().Name);
}

Version++; // イベントを1個適用したら版が進む
if (isNew) _uncommitted.Add(e);
}
}

4-3. “危ない”イベントストア(競合チェックなし)😈📚

public sealed class NaiveEventStore
{
private readonly Dictionary<string, List<IDomainEvent>> _streams = new();

public IReadOnlyList<IDomainEvent> ReadStream(string streamId)
=> _streams.TryGetValue(streamId, out var list) ? list.ToList() : new List<IDomainEvent>();

// ⚠️ expectedVersion(期待する版)を見ずに、無条件で追記する
public void Append(string streamId, IEnumerable<IDomainEvent> events)
{
if (!_streams.TryGetValue(streamId, out var list))
{
list = new List<IDomainEvent>();
_streams[streamId] = list;
}

foreach (var e in events) list.Add(e);
}
}

4-4. “同時更新っぽく”するために、LoadとAppendを分ける✂️🧩

リアルの競合はこういう感じ👇

  • Aのリクエスト:Load → Decide(イベント作る) → Append
  • Bのリクエスト:Load → Decide(イベント作る) → Append この2つが「ほぼ同時」に走ると、AもBも同じ版を見てイベントを作っちゃう😵

だから、実験では「Prepare(イベント作る)→あとでAppend」をやるよ🧪✨

public sealed record PreparedAppend(int ExpectedVersion, IReadOnlyList<IDomainEvent> NewEvents);

public static class AccountUseCase
{
public static PreparedAppend PrepareWithdraw(NaiveEventStore store, string streamId, decimal amount)
{
var history = store.ReadStream(streamId);
var acc = BankAccount.Rehydrate(history);

var expectedVersion = acc.Version; // ←「この版を元に判断したよ」という印
acc.Withdraw(amount); // Decide(ここでイベントが作られる)

return new PreparedAppend(expectedVersion, acc.UncommittedEvents.ToList());
}
}

4-5. 競合を発生させる本体💥⚔️

var store = new NaiveEventStore();
var id = "account-001";

// まず入金100(ストリームの版は 0 になる)
store.Append(id, new IDomainEvent[] { new MoneyDeposited(100m) });

// AとBが「同じ版(0)」を見て、同じ判断をする(ほぼ同時を再現)
var a = AccountUseCase.PrepareWithdraw(store, id, 80m);
var b = AccountUseCase.PrepareWithdraw(store, id, 80m);

Console.WriteLine($"Aが見たVersion: {a.ExpectedVersion}");
Console.WriteLine($"Bが見たVersion: {b.ExpectedVersion}");

// ⚠️ 競合チェックがないので、どっちも保存できちゃう
store.Append(id, a.NewEvents);
store.Append(id, b.NewEvents);

// 最終状態を復元してみる
var final = BankAccount.Rehydrate(store.ReadStream(id));
Console.WriteLine($"最終残高: {final.Balance}");

✅ 期待される実行イメージ(ざっくり)👇

  • Aが見たVersion: 0
  • Bが見たVersion: 0
  • 最終残高: -60 😱💥

5) 何がヤバいの?(本質)🧠⚡

保存されたイベントが「嘘」になってる😵‍💫

2つ目の MoneyWithdrawn(80) は、本当は残高20の世界で起きてはいけない出来事だよね🙅‍♀️

でも競合チェックがないと👇

  • 「古い状態で作られたイベント」
  • がそのまま保存されちゃう…!😱

6) 図で見ると一発📈✨

rev0: Deposited(100)

A: rev0 を読む → Balance=100 → Withdrawn(80) を作る
B: rev0 を読む → Balance=100 → Withdrawn(80) を作る

A: 追記 → rev1
B: 追記 → rev2 ← 本当はここで止めたい!!🛑⚔️

7) “競合対策”ってどんな世界観?🌍🔒

主流は 楽観的同時実行制御(Optimistic Concurrency) だよ✨ ざっくり言うと👇

  • 「競合は起きる前提」🧨
  • 「保存するときに、版が変わってないかチェック」🔎
  • 「変わってたら失敗にして、リトライや再入力へ」🔁

データベースでもこの考え方が一般的で、たとえばEF Coreはconcurrency tokenで衝突検出をするよ(Microsoft Learn) HTTPの世界でも ETag + If-Match みたいな形で「版が一致したら更新」をやる(考え方が同じ)よ(Microsoft Learn)

イベントストア界隈では「expectedVersion / expectedRevision」という言い方で、期待する版を渡して、合ってたら追記、違ったら失敗が定番✨ (例:expected_version は楽観ロックとして働く、同時書き込みでは1つだけ成功する、など)(Rails Event Store) またEventStoreDB(Kurrent)の.NETクライアントでは用語を ExpectedVersion より明確な ExpectedRevision に寄せた、という話もあるよ(Kurrent Docs)


8) ミニ演習🎒🧪(手を動かすやつ!)

演習A:金額を変えて観察👀💰

  • 80円 → 60円にしてみて、最終残高はいくつ?😺
  • 40円/40円、70円/70円など色々試して、「どこで破綻するか」見よう💥

演習B:別ドメインに置き換え🍔🛒

口座じゃなくて、カートでもOK✨

  • ルール:在庫は0未満ダメ📦🙅‍♀️
  • 2人が同時に購入して在庫がマイナス…を再現してみよう😵‍💫

演習C:ログを増やす📣🧾

  • ReadStream したときのイベント数
  • ExpectedVersion
  • Append 後のイベント数 を表示して「版ズレ」を目で追えるようにしよう👀✨

9) AI活用🤖✨(この章向けの使い方)

その1:競合シナリオを増やす🎭⚔️

  • 「この口座の例みたいに、競合で壊れるシナリオを3つ(在庫/予約/クーポン)で出して。イベント名も過去形で。」

その2:失敗するテストを作って“事故を固定”する🧪🧷

  • 「この NaiveEventStore で競合を再現するGiven-When-Thenテストを書いて。期待としては“本当は2回目の保存が失敗すべき”というテストにしたい。」

その3:ログ設計🔎🧠

  • 「競合調査しやすいログ項目(streamId / expectedVersion / currentVersion / commandId等)を提案して。」

10) まとめ📌✨

  • 競合は「別々の処理が、同じデータを同時に更新しようとして衝突」⚔️
  • イベントソーシングでも、古い状態で作ったイベントが保存されると破綻する😱
  • だから「保存時に版をチェックする」= expectedVersion / expectedRevision が重要🔒✅ (Rails Event Store)

次章では、この実験の“穴”を expectedVersion(楽観ロック) で塞いで、「2回目をちゃんと止める」実装に進むよ🔧✨