第16章:Command処理の“型”①(Load → Decide → Append)📮✅
この章でできるようになること🎯✨
- Command(やりたいこと)を受け取って、イベントソーシングの王道フローで処理できるようになる😊
- 処理の流れを **1つの「型」**に固定して、迷子にならないようにする🧭💕
- 「どこに何を書くか」を分けて、コードが読みやすくなる📚✨
まずは“型”を覚えちゃおう🧠💡

イベントソーシングの Command 処理は、だいたいこれでOKです👇✨
- Load:過去イベントを EventStore から読む📚🔁
- Decide:集約(Aggregate)がルールチェックして、新イベントを決める🛡️✨
- Append:新イベントを EventStore に保存する🧾✅
これを Load → Decide → Append って呼ぶよ📮✅
どうしてこの型が大事なの?🤔💗
もし Command の中で “状態を直接書き換え” しちゃうと…
- 「何が起きたか」の履歴が薄くなる😵💫
- テストがやりにくい🧪💥
- 後から仕様変更でぐちゃぐちゃになりやすい🌀
だから、状態の更新はイベントを通して行うのが基本だよ✨
今回のお題(例)🛒🍎
「ショッピングカート」っぽいミニ例でいくよ😊
- Command:
AddItemToCart(商品をカートに追加)🧺 - Event:
ItemAddedToCart(商品が追加された)📦✨ - Aggregate:
ShoppingCart(ルールを守る担当)🛡️
1) Load:過去イベントを読む📚🔁
EventStore には「その集約で過去に起きた出来事」が並んでるよね。
だからまず ストリームID(例:cart-123)でイベント列を読む!
ポイント💡
- Load の責任は「過去を取ってくる」だけ
- ここでは “判断” しない(まだ Decide じゃない)🙅♀️
2) Decide:ルールチェックして、新イベントを作る🛡️✨
Decide は Aggregate の仕事だよ😊
- 不変条件(守るべきルール)をチェック🧷
- OKなら新イベントを作る🎁
- NGなら「止める」(次章で詳しくやるよ🚧)
3) Append:新イベントを保存する🧾✅
最後に新イベントを EventStore に append(追記)するよ✨ このとき「順番(version)」も超大事! (競合は22〜24章でがっつりやるけど、ここから形だけ入れてOK👍)
実装:最小の構成で “型” を書く🧱✨
① ドメイン:Command / Event を用意📮📜
public sealed record AddItemToCart(
Guid CartId,
string Sku,
int Quantity,
Guid CommandId
);
public sealed record ItemAddedToCart(
string Sku,
int Quantity
);
CommandIdは「同じCommandを2回送っちゃった」対策の足がかり(35章で活きるよ🔁🧷)
② EventEnvelope(メタデータ入り)🏷️🍱
イベントには「起きた事実」+「付帯情報」があると運用しやすいよ✨
public sealed record EventEnvelope(
Guid EventId,
DateTimeOffset OccurredAt,
object Event,
Guid? CausationId // どのCommandが原因?
);
③ EventStore の最小インターフェース📦✅
public interface IEventStore
{
Task<StreamReadResult> ReadStreamAsync(string streamId, CancellationToken ct);
Task AppendAsync(
string streamId,
int expectedVersion,
IReadOnlyList<EventEnvelope> events,
CancellationToken ct);
}
public sealed record StreamReadResult(
int Version,
IReadOnlyList<EventEnvelope> Events
);
expectedVersionは “今見たバージョンの続きに書くよ” って意味(競合対策の準備運動🏋️♀️)
④ Aggregate:Rehydrate(復元)と Decide(判断)🧠🔁🛡️
ここがこの章の主役だよ✨ 「イベントを適用して状態を作る」+「Commandからイベントを決める」
public sealed class ShoppingCart
{
private readonly Dictionary<string, int> _items = new();
private bool _isCheckedOut;
public static ShoppingCart Rehydrate(IEnumerable<EventEnvelope> history)
{
var cart = new ShoppingCart();
foreach (var e in history)
{
cart.Apply(e.Event);
}
return cart;
}
// Decide:Commandを受けて「起こすイベント」を返す(状態はここで直接いじらない)
public IReadOnlyList<object> Decide(AddItemToCart cmd)
{
if (cmd.Quantity <= 0) throw new ArgumentOutOfRangeException(nameof(cmd.Quantity));
if (_isCheckedOut)
throw new InvalidOperationException("チェックアウト後は追加できません");
// ここで「何が起きたか」をイベントとして作る✨
return new object[]
{
new ItemAddedToCart(cmd.Sku, cmd.Quantity)
};
}
// Apply:イベントで状態を更新(復元でも、新イベント反映でも使う)
public void Apply(object @event)
{
switch (@event)
{
case ItemAddedToCart e:
_items.TryGetValue(e.Sku, out var current);
_items[e.Sku] = current + e.Quantity;
break;
default:
throw new NotSupportedException($"Unknown event: {@event.GetType().Name}");
}
}
}
ここで大事なのは👇💕
- Decide は “イベントを返す”(何が起きたか)
- Apply は “状態を変える”(どう変わったか)
⑤ CommandHandler:Load → Decide → Append を全部つなぐ📮✅
public sealed class ShoppingCartCommandHandler
{
private readonly IEventStore _store;
public ShoppingCartCommandHandler(IEventStore store)
=> _store = store;
public async Task HandleAsync(AddItemToCart cmd, CancellationToken ct)
{
var streamId = $"cart-{cmd.CartId:D}";
// 1) Load
var read = await _store.ReadStreamAsync(streamId, ct);
// 2) Rehydrate(イベント列から現在状態へ)
var cart = ShoppingCart.Rehydrate(read.Events);
// 3) Decide(新イベントを作る)
var newEvents = cart.Decide(cmd);
// (おまけ)新イベントをApplyしておくと、後続処理が書きやすい時もあるよ✨
foreach (var ev in newEvents) cart.Apply(ev);
// 4) Append(EventEnvelopeに包んで保存)
var envelopes = newEvents
.Select(ev => new EventEnvelope(
EventId: Guid.NewGuid(),
OccurredAt: DateTimeOffset.UtcNow,
Event: ev,
CausationId: cmd.CommandId
))
.ToList();
await _store.AppendAsync(streamId, read.Version, envelopes, ct);
}
}
✅ これが Load → Decide → Append の “型” だよ〜!📮✨
テスト:Given-When-Then で超わかりやすく🧪🌸
ここでは「成功パターン」だけやってみよう😊 (失敗パターンは次章で “弾く” をきれいにするよ🛡️🚧)
テスト用:インメモリEventStore(最小)📦🧪
public sealed class InMemoryEventStore : IEventStore
{
private readonly Dictionary<string, List<EventEnvelope>> _streams = new();
public Task<StreamReadResult> ReadStreamAsync(string streamId, CancellationToken ct)
{
_streams.TryGetValue(streamId, out var list);
list ??= new List<EventEnvelope>();
// version は「最後の index」っぽく扱う(超最小)
var version = list.Count;
return Task.FromResult(new StreamReadResult(version, list.ToList()));
}
public Task AppendAsync(
string streamId,
int expectedVersion,
IReadOnlyList<EventEnvelope> events,
CancellationToken ct)
{
_streams.TryGetValue(streamId, out var list);
list ??= new List<EventEnvelope>();
_streams[streamId] = list;
if (list.Count != expectedVersion)
throw new InvalidOperationException("Version mismatch");
list.AddRange(events);
return Task.CompletedTask;
}
}
xUnit テスト例🧪✨
using Xunit;
public sealed class Chapter16Tests
{
[Fact]
public async Task AddItem_LoadDecideAppend_adds_ItemAdded_event()
{
// Given
var store = new InMemoryEventStore();
var handler = new ShoppingCartCommandHandler(store);
var cartId = Guid.NewGuid();
var cmd = new AddItemToCart(
CartId: cartId,
Sku: "APPLE",
Quantity: 2,
CommandId: Guid.NewGuid()
);
// When
await handler.HandleAsync(cmd, CancellationToken.None);
// Then
var streamId = $"cart-{cartId:D}";
var read = await store.ReadStreamAsync(streamId, CancellationToken.None);
Assert.Single(read.Events);
Assert.IsType<ItemAddedToCart>(read.Events[0].Event);
var ev = (ItemAddedToCart)read.Events[0].Event;
Assert.Equal("APPLE", ev.Sku);
Assert.Equal(2, ev.Quantity);
Assert.Equal(cmd.CommandId, read.Events[0].CausationId);
}
}
つまずきポイント集(あるある)😵💫💥
❶ Decide で状態を直接いじっちゃう🙅♀️
- Decide は「イベントを決める」
- 状態更新は Apply に寄せる(復元でも使えるからね🔁)
❷ Load したのに Rehydrate してない😇
- ただイベントを読んだだけだと、今の状態が作れてないよ〜!
- **Rehydrate(Apply連打)**は必須✨
❸ Append するイベントに “メタ情報” がない🏷️
- EventId / OccurredAt / CausationId があると、後で助かる確率が爆上がり📈✨
ミニ演習(10〜25分)✍️🌸
演習A:Commandを1つ増やす➕🧺
RemoveItemFromCartを追加ItemRemovedFromCartを追加- Decide と Apply を実装して、テスト1本追加🧪✨
演習B:イベントの“形”を見直す🔎
Skuだけで足りる?- 将来「商品名変更」「価格変動」が来たらどうする?(payload入れすぎ注意🍱⚠️)
AI活用(Copilot / Codex)プロンプト例🤖✨
1) Handlerの雛形を作らせる🧰
C#でイベントソーシングのCommandHandlerを書きたいです。
型は Load(過去イベント読む) → Rehydrate → Decide(新イベント生成) → Append(保存) です。
IEventStore は ReadStreamAsync(streamId) と AppendAsync(streamId, expectedVersion, events) を持ちます。
ShoppingCart の Decide は AddItemToCart を受けて ItemAddedToCart を返します。
最小で読みやすいコードを提案してください。
2) テスト(Given-When-Then)を作らせる🧪
xUnitでGiven-When-Then形式のテストを書いてください。
Given: 空のストリーム
When: AddItemToCart
Then: ItemAddedToCart が1件追加され、CausationIdがCommandIdと一致する
最新メモ(この章に関係するツールの動き)🧩🪟
- Visual Studio 2026 は v18 系としてリリース履歴が公開されていて、2026-01-20 に 18.2.1 が出ています。(Microsoft Learn)
- Visual Studio 2026 のリリースノートも公開されています。(Microsoft Learn)
- .NET 10 の “What’s new” が公開されていて、ASP.NET Core 10 などの更新がまとまっています。(Microsoft Learn)
- VS Code 側も Insiders の 2026年1月アップデート(1.109)が更新されています。(Visual Studio Code)
まとめ:この章の合言葉📮✅
- Load:過去を読む📚
- Decide:ルールで決める🛡️
- Append:履歴として残す🧾
この“型”が、次の章(不変条件で弾く🛡️🚧)にもそのまま繋がるよ✨