第19章:ユースケース実装②(更新系)🔧✨
この章でできるようになること 🎯💡
更新(= 既に存在する集約の状態を変える)を、コマンド → 新イベントとしてきれいに実装できるようになります😊✨ この章では「更新の代表格」を2〜3個つくって、更新イベントの粒度も体で覚えます🧠⚖️
1. 更新系ってなにが難しいの?😵💫➡️😊
更新系が難しく感じる理由はだいたいこの3つ👇
- 同じ更新でもパターンが多い(追加・削除・数量変更・属性変更…)🧩
- イベント名がブレやすい(でかすぎ・細かすぎ問題)⚖️
- 不変条件(ルール)をどこで守るか迷う🛡️
でも安心してね🌸
第16章の型(Load → Decide → Append)をそのまま使えば、更新もキレイにハマります✅
2. 今回の題材(例):ショッピングカート🛒✨
第18章で「作成系(CartCreated)」をやった前提で、更新系を足します😊 ここでは更新コマンドを2つ(+おまけ1つ)つくるよ👇
- ✅ AddItem:商品を追加する ➕📦
- ✅ ChangeQuantity:数量を変更する 🔢🔁
- ⭐(おまけ)RemoveItem:商品を削除する 🗑️
3. 更新イベントの設計(まずここが命!)🧠🔥

3.1 イベント命名(過去形)⏳✅
イベントは「やったこと(事実)」なので、過去形が基本✨
ItemAddedToCart(カートに商品が追加された)ItemQuantityChanged(数量が変更された)ItemRemovedFromCart(商品が削除された)
3.2 粒度(でかすぎ vs ちょうどいい)⚖️👀
ありがちなNG👇
- ❌
CartUpdated(でかすぎて、何が起きたか分からない😇) - ❌
CartItemQuantityIncrementedBy1(細かすぎてイベント爆発😵)
おすすめは「画面の操作」じゃなくて「ドメインの意味」で切ること😊✨ 例:数量変更は「+1」「-1」ではなく「数量がXになった」の方が後々ラクなことが多いよ🔧
4. 不変条件(更新系の守りどころ)🛡️🧷
この章のカート例で、最低限のルールを決めよう👇(シンプルでOK!)
- 数量は 1以上(0やマイナスは禁止)🙅♀️
- 追加するSKUは空文字禁止🙅♀️
- 変更対象の商品が存在しないなら変更できない🙅♀️
- (おまけ)削除対象が存在しないなら削除できない🙅♀️
このルールは Decideの中で弾く のが基本だよ✅
5. 実装:Domain(イベント・コマンド・集約)🧱✨
ここからは「そのまま貼って動く」寄りのサンプルでいきます😊 (イベントストアは第13〜17章の最小実装を使う想定📦)
5.1 イベント定義 📜✨
public interface IDomainEvent;
public sealed record CartCreated(Guid CartId) : IDomainEvent;
public sealed record ItemAddedToCart(Guid CartId, string Sku, int Quantity) : IDomainEvent;
public sealed record ItemQuantityChanged(Guid CartId, string Sku, int NewQuantity) : IDomainEvent;
public sealed record ItemRemovedFromCart(Guid CartId, string Sku) : IDomainEvent;
ポイント💡
-
更新系は「差分」を入れるか「結果(NewQuantity)」を入れるか迷うけど、初心者コースでは **結果(NewQuantity)**がおすすめ😊
- リプレイしてもブレない✅
- 途中の計算が減る✅
5.2 コマンド定義 📮✨
public interface ICommand;
public sealed record CreateCart(Guid CartId) : ICommand;
public sealed record AddItem(Guid CartId, string Sku, int Quantity) : ICommand;
public sealed record ChangeQuantity(Guid CartId, string Sku, int NewQuantity) : ICommand;
public sealed record RemoveItem(Guid CartId, string Sku) : ICommand;
5.3 集約(Cart Aggregate)🧺✨
public sealed class Cart
{
private readonly Dictionary<string, int> _items = new();
public Guid Id { get; private set; }
public bool IsCreated { get; private set; }
// Rehydrate 用(外から new させない)
private Cart() { }
public static Cart Rehydrate(IEnumerable<IDomainEvent> history)
{
var cart = new Cart();
foreach (var e in history)
{
cart.Apply(e);
}
return cart;
}
// Decide:コマンド -> 新イベント(ここで不変条件を守る)
public IReadOnlyList<IDomainEvent> Decide(ICommand command) =>
{
command switch
{
CreateCart c => DecideCreate(c),
AddItem c => DecideAddItem(c),
ChangeQuantity c => DecideChangeQuantity(c),
RemoveItem c => DecideRemoveItem(c),
_ => throw new NotSupportedException($"Unknown command: {command.GetType().Name}")
};
}
private IReadOnlyList<IDomainEvent> DecideCreate(CreateCart c)
{
if (IsCreated) throw new InvalidOperationException("Cart is already created.");
if (c.CartId == Guid.Empty) throw new InvalidOperationException("CartId is required.");
return new IDomainEvent[]
{
new CartCreated(c.CartId)
};
}
private IReadOnlyList<IDomainEvent> DecideAddItem(AddItem c)
{
EnsureCreated();
EnsureSku(c.Sku);
if (c.Quantity <= 0) throw new InvalidOperationException("Quantity must be >= 1.");
// ここは方針が2つあるよ👇
// A) 同じSKUなら「追加」じゃなく「数量変更」に統一する
// B) 追加は追加、変更は変更(イベントを分ける)
// 今回は分かりやすさ優先で B にする😊
return new IDomainEvent[]
{
new ItemAddedToCart(Id, c.Sku, c.Quantity)
};
}
private IReadOnlyList<IDomainEvent> DecideChangeQuantity(ChangeQuantity c)
{
EnsureCreated();
EnsureSku(c.Sku);
if (c.NewQuantity <= 0) throw new InvalidOperationException("NewQuantity must be >= 1.");
if (!_items.ContainsKey(c.Sku))
throw new InvalidOperationException("Item does not exist in cart.");
return new IDomainEvent[]
{
new ItemQuantityChanged(Id, c.Sku, c.NewQuantity)
};
}
private IReadOnlyList<IDomainEvent> DecideRemoveItem(RemoveItem c)
{
EnsureCreated();
EnsureSku(c.Sku);
if (!_items.ContainsKey(c.Sku))
throw new InvalidOperationException("Item does not exist in cart.");
return new IDomainEvent[]
{
new ItemRemovedFromCart(Id, c.Sku)
};
}
// Apply:イベント -> 状態更新(ここはルール判定しない)
private void Apply(IDomainEvent e)
{
switch (e)
{
case CartCreated x:
Id = x.CartId;
IsCreated = true;
break;
case ItemAddedToCart x:
if (_items.TryGetValue(x.Sku, out var current))
_items[x.Sku] = current + x.Quantity;
else
_items[x.Sku] = x.Quantity;
break;
case ItemQuantityChanged x:
_items[x.Sku] = x.NewQuantity;
break;
case ItemRemovedFromCart x:
_items.Remove(x.Sku);
break;
default:
throw new NotSupportedException($"Unknown event: {e.GetType().Name}");
}
}
private void EnsureCreated()
{
if (!IsCreated) throw new InvalidOperationException("Cart is not created.");
}
private static void EnsureSku(string sku)
{
if (string.IsNullOrWhiteSpace(sku))
throw new InvalidOperationException("Sku is required.");
}
// デバッグ用(状態を見たいとき便利😊)
public IReadOnlyDictionary<string, int> GetItems() => _items;
}
ここ大事😊✨
- Decide:不変条件(ルール)を守る場所🛡️
- Apply:イベントを状態に反映するだけ(判定はしない)🔁
6. 実装:Application(ハンドラ)📮➡️📦
第16章の型をそのまま使うよ✅ イベントストア側は「ReadStream」「Append(streamId, expectedVersion, events)」がある想定📦
public sealed class CartCommandHandler
{
private readonly IEventStore _store;
public CartCommandHandler(IEventStore store)
{
_store = store;
}
public void Handle(ICommand command)
{
var cartId = command switch
{
CreateCart c => c.CartId,
AddItem c => c.CartId,
ChangeQuantity c => c.CartId,
RemoveItem c => c.CartId,
_ => throw new NotSupportedException()
};
var streamId = $"cart-{cartId:N}";
var history = _store.ReadStream(streamId); // IEnumerable<IDomainEvent>
var cart = Cart.Rehydrate(history);
var newEvents = cart.Decide(command);
var expectedVersion = history.Count(); // ※第14章のversion管理の形に合わせて調整してOK
_store.Append(streamId, expectedVersion, newEvents);
}
}
※ history.Count() は IEnumerable を2回なめがちなので、実務なら List にしてから使うのが安全だよ😊(この章では分かりやすさ優先✨)
7. ミニ演習(更新コマンド2つを通す)🧪✨
演習A:AddItemを通す➕📦
CreateCartするAddItem(SKU="APPLE", Quantity=2)を投げる- イベントストリームを読んで、最後に
ItemAddedToCartが追加されているか確認✅
演習B:ChangeQuantityを通す🔢🔁
- すでに
APPLEが入っている状態から ChangeQuantity(SKU="APPLE", NewQuantity=5)を投げるItemQuantityChangedが積まれるか確認✅
演習C:失敗パターンを作る🙅♀️💥
NewQuantity=0を投げて、ちゃんと弾かれるか確認- 存在しないSKUを変更して弾かれるか確認
8. “更新イベントの粒度診断” ミニチェック⚖️🔍
更新イベントを作ったら、これを自分に質問してみてね😊
- 「何が起きたか、イベント名だけで分かる?」👀
- 「後から見た人が、なぜこのイベントが必要か説明できる?」🗣️
- 「将来 “監査ログ” に並んだ時に、人間が読める?」📜
- 「
CartUpdatedみたいに“便利そうで意味が薄い”イベントになってない?」😇
9. AI活用(Copilot / Codex)🤖✨
9.1 まずはイベント案を出してもらう🧠💬
プロンプト例👇(そのまま貼ってOK)
ショッピングカートのイベントソーシングを学習中です。
更新系のユースケースとして「商品追加」「数量変更」「商品削除」を実装したいです。
条件:
- イベント名は過去形で、何が起きたか分かること
- payloadは最小限(入れすぎない)
- 不変条件(数量は1以上、SKU空は禁止、存在しない商品は変更/削除できない)を守る
提案して:
1) コマンド名
2) イベント名
3) それぞれのpayload設計
4) 粒度がでかすぎ/細かすぎのNG例も
9.2 出てきた案の“手直しポイント”✍️✨
AIがよくやりがち👇
- payloadに 画面表示用の情報 を入れようとする(やりすぎ🍱💦)
CartUpdatedみたいな 万能イベント を作りたがる(危険⚠️)
だから、最後はあなたがこの方針で整えると勝ち😊
- 「ドメイン的に意味がある?」
- 「後から読める?」
- 「Applyが単純?」
10. まとめ(この章のゴール確認)✅🎉
この章で押さえたこと👇
- 更新系も Load → Decide → Append の型で迷子にならない📮✅
- Decideで不変条件、Applyで状態更新 を徹底🛡️🔁
- 更新イベントは「意味が分かる名前」と「ちょうどいい粒度」⚖️✨
次章(第20章)では、この更新も含めて Given-When-Then でテストを書いて「怖くない」状態にしていくよ🧪🌸
参考(2026年時点の開発スタックの根拠)📚✨
- .NET 10 は LTS で、サポート期間や入手について公式に案内されています。(Microsoft for Developers)
- .NET 10 の新機能概要(ASP.NET Core 10 など)も公式ドキュメントにまとまっています。(Microsoft Learn)
- C# 14 は .NET 10 SDK / Visual Studio 2026 で試せる旨が公式に明記されています。(Microsoft Learn)
- Visual Studio 2026 と .NET 10 で F5 起動が最大30%高速化、などの改善がリリースノートにあります。(Microsoft Learn)
- xUnit v3 側も .NET 10 ターゲットなどの情報をリリースノートとして公開しています(テスト章で活用しやすいです)。(xunit.net)