Skip to main content

第24章:競合したとき、どうする?(方針だけ)🧯🧠

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

  • 「競合(同時更新)」が起きたときに、どう対応するかの現実的な選択肢を説明できる😊
  • そのうち1つ(この章では サーバー側リトライ)を、最小実装できる✅
  • 競合を「バグ」じゃなく「起こりうる前提」として扱えるようになる🌿

1. 競合って、実際なにが起きてるの?⚔️💥

リトライ処理のフロー

イベントソーシングでは、だいたいこういう流れです👇

  1. ある人(または処理)が、集約のイベント列を読む(version = 10 だった)📚
  2. その間に別の人が先に保存して、version が 11 になった📈
  3. 最初の人が「version=10 のつもりで Append」すると、“期待したversionと違うよ!” で失敗する💣

この「期待したversion(expectedVersion / expectedRevision)」で守る仕組みが、**楽観ロック(Optimistic Concurrency)**だよ🔒✨ たとえば EventStoreDB 系のイベントストアは、期待したリビジョンと現状が違うとエラーにして「古い状態で判断して書き込むのを止める」動きをするよ。(Kurrent - event-native data platform) また、イベントストア実装でも「wrong expected version なら OptimisticConcurrencyException を投げる」みたいな扱いが一般的。(Eventuous)


2. 競合したときの“よくある3方針”🧭✨

方針A:サーバー側で自動リトライする🔁⚡(この章で実装する)

やること:競合したら、最新を読み直して、もう一回 Decide → Append を試す✨ 向いてる

  • そのコマンドが「最新状態で再判断してもだいたい通る」ケース😊
  • ユーザー体験を途切れさせたくないケース🌸

同期(HTTP等)だと、競合時に即リトライして吸収する、という考え方もよく出てくるよ。(Ecotone)


方針B:クライアントに「更新競合」を返して、やり直してもらう🧑‍💻🔄

やること:サーバーは「競合です!」で返す → クライアントは最新を取得 → もう一度操作

  • REST APIなら 409 Conflict が代表例📮(MDN Web Docs)
  • ETag を使う方式だと、If-Match が一致しない場合に 412 Precondition Failed を返す、という設計も定番🧾(Event-Driven)

向いてる

  • 「勝手にリトライして通すと、ユーザーの意図とズレる」ケース😵‍💫 (例:残り在庫が変わった、別の人が内容を大きく変更した、など)

方針C:マージ(自動解決)する🤝🧠(難しいけど強い)

やること:競合した場合でも、差分を見て「両方成立するなら合成」する ただし、どの順番でもOKかどうかは ドメイン次第で、ケースバイケースになりやすい💡(Taskscape)

向いてる

  • “足し算系”で衝突しにくい(例:閲覧回数の加算、在庫の補充)📈
  • ルールが明確で「自動合成しても意味が変わらない」操作✅

3. どれを選ぶ?超かんたん判断ガイド🧩😊

迷ったら、まずこれ👇

  • 競合しても「最新状態で再計算すれば同じ結果になりやすい」? → **方針A(サーバー側リトライ)**がラク😊🔁
  • 競合したら「ユーザーが見て判断しないと危ない」? → **方針B(409/412でやり直し)**が安全🛡️
  • 操作が「順番入れ替えても意味が変わらない」&「自動合成ルールが書ける」? → **方針C(マージ)**に挑戦🤝✨

4. 最小実装:方針A「サーバー側リトライ(最大3回)」🔁✅

ここでは、競合エラーだけを捕まえて、最大3回だけやり直す形にします🌸 (無限リトライは地獄になるので回数制限が大事😇)

4.1 例外(競合)を表す型💥

public sealed class OptimisticConcurrencyException : Exception
{
public string StreamId { get; }
public long ExpectedVersion { get; }
public long ActualVersion { get; }

public OptimisticConcurrencyException(string streamId, long expected, long actual)
: base($"Concurrency conflict on {streamId}. expected={expected}, actual={actual}")
{
StreamId = streamId;
ExpectedVersion = expected;
ActualVersion = actual;
}
}

4.2 EventStore の最小インターフェース📦

public interface IEventStore
{
Task<(IReadOnlyList<object> Events, long Version)> ReadStreamAsync(string streamId, CancellationToken ct);
Task AppendAsync(string streamId, long expectedVersion, IReadOnlyList<object> newEvents, CancellationToken ct);
}

4.3 「Load → Decide → Append」を、競合時だけリトライする🧠🔁

例として「カートに商品を追加する」コマンドを想定するね🛒✨ (集約 CartDecide は既にある前提でOK!)

public sealed record AddItemToCart(Guid CartId, string Sku, int Quantity);

public sealed class CartCommandHandler
{
private readonly IEventStore _store;

public CartCommandHandler(IEventStore store) => _store = store;

public async Task<CommandResult> Handle(AddItemToCart cmd, CancellationToken ct)
{
const int maxRetries = 3;

for (int attempt = 1; attempt <= maxRetries; attempt++)
{
var streamId = $"cart-{cmd.CartId}";
var (history, version) = await _store.ReadStreamAsync(streamId, ct);

var cart = Cart.Rehydrate(history); // Applyで復元🔁
var newEvents = cart.Decide(cmd); // 不変条件チェック→イベント生成🛡️

try
{
await _store.AppendAsync(streamId, version, newEvents, ct); // expectedVersionで守る🔒
return CommandResult.Ok();
}
catch (OptimisticConcurrencyException) when (attempt < maxRetries)
{
// 競合した!→ もう一回 “最新を読み直して” やり直す🔁
continue;
}
}

// 3回やってもダメなら、利用者に「更新競合」を伝えるのが無難😊
return CommandResult.Conflict("更新が競合しました。最新の状態で再度お試しください。");
}
}

public abstract record CommandResult
{
public sealed record OkResult : CommandResult;
public sealed record ConflictResult(string Message) : CommandResult;

public static CommandResult Ok() => new OkResult();
public static CommandResult Conflict(string message) => new ConflictResult(message);
}

ポイントはここだよ👇✨

  • 競合したら必ず Read からやり直す(古い状態で再Appendしない)🔁
  • リトライするのは 競合系だけ(不変条件違反はリトライしても同じになりがち)🛡️
  • 回数制限をつける(maxRetries)🧯

5. ミニ演習(手を動かすパート)✍️💪

演習1:競合を“わざと”起こす💥

  • テスト内で、ReadStreamAsync の直後に別の Append が走ったことにする
  • AppendAsyncOptimisticConcurrencyException を投げるようにする

演習2:リトライで最終的に成功するのを確認✅

  • 1回目は失敗
  • 2回目は最新を読み直して成功🎉

6. テスト例(Given-When-Thenっぽく)🧪🌸

※「競合が一度だけ起きる偽イベントストア」を使う例だよ😊

using Xunit;

public sealed class ConflictOnceEventStore : IEventStore
{
private readonly List<object> _events = new();
private bool _alreadyConflicted;

public Task<(IReadOnlyList<object> Events, long Version)> ReadStreamAsync(string streamId, CancellationToken ct)
=> Task.FromResult(((IReadOnlyList<object>)_events.ToArray(), (long)_events.Count));

public Task AppendAsync(string streamId, long expectedVersion, IReadOnlyList<object> newEvents, CancellationToken ct)
{
var actualVersion = _events.Count;

// 1回だけ「誰かが先に書いた」ことにして競合させる💥
if (!_alreadyConflicted)
{
_alreadyConflicted = true;
_events.Add(new SomethingElseHappened()); // 横入りイベント
actualVersion = _events.Count;

throw new OptimisticConcurrencyException(streamId, expectedVersion, actualVersion);
}

// 2回目以降は普通にチェックして追加
if (expectedVersion != _events.Count)
throw new OptimisticConcurrencyException(streamId, expectedVersion, _events.Count);

_events.AddRange(newEvents);
return Task.CompletedTask;
}

private sealed record SomethingElseHappened;
}

public class Chapter24Tests
{
[Fact]
public async Task Retries_on_concurrency_conflict_and_succeeds()
{
var store = new ConflictOnceEventStore();
var handler = new CartCommandHandler(store);

var result = await handler.Handle(new AddItemToCart(Guid.NewGuid(), "SKU-1", 1), CancellationToken.None);

Assert.IsType<CommandResult.OkResult>(result);
}
}

7. よくある落とし穴(ここ注意!)⚠️😵‍💫

  • 無限リトライ:混雑時に一生終わらないことがある🌀 → 回数制限必須🧯

  • リトライ前に副作用がある:メール送信・外部API呼び出しを先にやると地獄📨🔥 → Append 成功後にやるか、Outboxなどの仕組みへ(後の章で扱うやつ)📦

  • “競合”と“不変条件違反”をごっちゃにする

    • 競合=読み直せば通るかも🔁
    • 不変条件違反=読み直しても通らないことが多い🛡️

8. AI活用(メリデメ表&テスト案を一瞬で出す)🤖🪄

8.1 メリデメ表を作るプロンプト例📋✨

「イベントソーシングの競合対応として、(A)サーバー側リトライ (B)409/412でやり直し (C)マージ のメリット/デメリット、向いている例、向いていない例を表で出して。前提:expectedVersion の楽観ロック。」

8.2 テストケースを増やすプロンプト例🧪✨

「上の CartCommandHandler の競合リトライについて、落とし穴込みでテストケースを10個提案して。成功/失敗/境界値/同時実行っぽいケースを混ぜて。」


9. まとめ🎀😊

競合は「たまに起きるけど、起きたときの扱いが大事」な現象だよ⚔️✨

  • まずは リトライ(方針A) で吸収できる場面が多い🔁
  • 危ない操作は 409/412でやり直し(方針B) が安全🛡️
  • 自動合成できるなら マージ(方針C) が強いけど、ルール設計が難しい🤝🧠

次の章の「スナップショット」につながるように、競合も“運用で起こる前提”として扱えるようになったら最高だよ〜📸✨