第17章:Command処理の“型”②(不変条件で弾く)🛡️🚧
この章でできるようになること 🎯✨
- ルール違反のコマンドを、イベントを1つも積まずに止める🙅♀️🧱
- 「なぜダメか」を ドメインエラー(失敗理由) として返せる📮💬
- Load → Decide → Append の “Decide” に 不変条件(Invariants) を集めて、設計がブレないようにできる🧠🧷
1. 不変条件ってなに?🧷🛡️
不変条件(Invariant)は、ざっくり言うと…
-
**「このドメインでは、絶対にこうでなきゃダメ!」**というルール✅
-
例:
- カートは チェックアウト後に商品追加できない🛒❌
- 数量は 1以上じゃないとダメ🔢❌
- 空のカートは チェックアウトできない🧺❌
ここで大事なのは、“入力チェック”よりも強いってこと🌟 UIの入力チェックは「親切」だけど、**不変条件は“最後の砦”**🛡️✨ (UIをすり抜けても、ドメインが守る!)
2. どこで不変条件をチェックするの?🧭
答え:Decide(イベントを作る直前) でチェックする✅
✅ 正しい流れ
- 過去イベントを読む(Load)📚
- 状態を復元(Rehydrate)🔁
- **不変条件チェック → OKなら新イベントを作る(Decide)**🧠✨
- Append(保存)📦
❌ よくある事故
- Appendした後に「あ、ルール違反だった」 → もう履歴が汚れる😱
- いろんな層(UI/Service/Repository)にチェックが散って、ルールが食い違う🥲
だから、Decideにルールを集めるのが強い💪✨
3. “例外”じゃなくて “ドメインエラー” で返す 🚦😊

ルール違反は、たいてい 「想定内の失敗」 だよね? そのときに例外を投げると…
- ログがエラーで埋まる🪵💥
- try/catchだらけになりがち🕸️
- 失敗理由の扱いが雑になりやすい🥺
そこでこの章では、まず一歩として ドメインエラーを返す型 を用意するよ✅ (もっと洗練した設計は後の章で強化していくよ〜💖)
4. 実装してみよう:Result と DomainError 🧱✨
public interface IDomainEvent { }
public sealed record DomainError(string Code, string Message);
public readonly record struct Result<T>(T? Value, DomainError? Error)
{
public bool IsOk => Error is null;
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(string code, string message)
=> new(default, new DomainError(code, message));
}
コツ🧠✨
-
Codeは 機械向け(安定した識別子) 🏷️ -
Messageは 人間向け(表示文) 💬 -
Codeは後で翻訳や表示切替に使えるから、短く・一貫性ある命名がおすすめ✨- 例:
cart.alreadyCheckedOut/cart.empty/quantity.outOfRange
- 例:
5. 例:ショッピングカートで「不変条件で弾く」🛒🛡️
5.1 イベント定義 📜
public sealed record CartCreated(Guid CartId) : IDomainEvent;
public sealed record ItemAdded(Guid CartId, string Sku, int Quantity) : IDomainEvent;
public sealed record CartCheckedOut(Guid CartId, DateTimeOffset At) : IDomainEvent;
5.2 状態(復元用)🔁
public sealed class ShoppingCartState
{
public bool Created { get; private set; }
public bool CheckedOut { get; private set; }
private readonly Dictionary<string, int> _items = new();
public IReadOnlyDictionary<string, int> Items => _items;
public void Apply(IDomainEvent e)
{
switch (e)
{
case CartCreated:
Created = true;
break;
case ItemAdded ia:
if (_items.TryGetValue(ia.Sku, out var current))
_items[ia.Sku] = current + ia.Quantity;
else
_items[ia.Sku] = ia.Quantity;
break;
case CartCheckedOut:
CheckedOut = true;
break;
}
}
public static ShoppingCartState From(IEnumerable<IDomainEvent> history)
{
var state = new ShoppingCartState();
foreach (var e in history) state.Apply(e);
return state;
}
}
5.3 Decide:ここが本題!不変条件で弾く🛡️🚧
public static class ShoppingCartDecider
{
public static Result<IReadOnlyList<IDomainEvent>> AddItem(
Guid cartId, ShoppingCartState state, string sku, int quantity)
{
// 不変条件①:作成されてないカートには追加できない
if (!state.Created)
return Result<IReadOnlyList<IDomainEvent>>.Fail(
"cart.notCreated",
"カートがまだ作成されていないみたい…🥺 先に作成してね!");
// 不変条件②:チェックアウト後は変更できない
if (state.CheckedOut)
return Result<IReadOnlyList<IDomainEvent>>.Fail(
"cart.alreadyCheckedOut",
"このカートは購入確定済みだよ🧾✨ 追加はできないの…");
// 不変条件③:SKUは空にしない
if (string.IsNullOrWhiteSpace(sku))
return Result<IReadOnlyList<IDomainEvent>>.Fail(
"sku.required",
"商品コードが空っぽだよ😵 ちゃんと指定してね!");
// 不変条件④:数量は1〜99(例)
if (quantity is < 1 or > 99)
return Result<IReadOnlyList<IDomainEvent>>.Fail(
"quantity.outOfRange",
"数量は1〜99の範囲でお願い🙏✨");
// OK!イベントを作る(まだ保存はしない)
return Result<IReadOnlyList<IDomainEvent>>.Ok(
new IDomainEvent[] { new ItemAdded(cartId, sku, quantity) }
);
}
public static Result<IReadOnlyList<IDomainEvent>> Checkout(
Guid cartId, ShoppingCartState state, DateTimeOffset now)
{
if (!state.Created)
return Result<IReadOnlyList<IDomainEvent>>.Fail(
"cart.notCreated",
"カートがまだ作成されていないみたい…🥺");
if (state.CheckedOut)
return Result<IReadOnlyList<IDomainEvent>>.Fail(
"cart.alreadyCheckedOut",
"もう購入確定済みだよ🧾✨");
// 不変条件:空のカートは購入できない
if (state.Items.Count == 0)
return Result<IReadOnlyList<IDomainEvent>>.Fail(
"cart.empty",
"空っぽのカートは購入できないよ🧺💦 先に商品を入れてね!");
return Result<IReadOnlyList<IDomainEvent>>.Ok(
new IDomainEvent[] { new CartCheckedOut(cartId, now) }
);
}
}
6. Command Handler側:失敗なら Append しない 🧯✅
“DecideがFailならイベントは積まない” が最重要ルールだよ💡
public interface IEventStore
{
Task<IReadOnlyList<IDomainEvent>> ReadStream(Guid streamId);
Task Append(Guid streamId, IReadOnlyList<IDomainEvent> events);
}
public sealed class CartApplicationService
{
private readonly IEventStore _store;
public CartApplicationService(IEventStore store) => _store = store;
public async Task<Result<IReadOnlyList<IDomainEvent>>> HandleAddItem(
Guid cartId, string sku, int quantity)
{
var history = await _store.ReadStream(cartId);
var state = ShoppingCartState.From(history);
var decision = ShoppingCartDecider.AddItem(cartId, state, sku, quantity);
if (!decision.IsOk)
return decision; // ✅ ここで止める(Appendしない)
await _store.Append(cartId, decision.Value!);
return decision;
}
}
7. ミニ演習:NGケースを作って弾こう🙅♀️🧪
お題🎁
「チェックアウト済みカートに AddItem したら弾く」をテストで確認しよう✅
※ xUnit は v3 が .NET 8 以降をサポートしてるので、この教材の環境でもそのまま使えるよ🧪✨ (xUnit.net)
using Xunit;
public sealed class CartInvariantsTests
{
[Fact]
public void AddItem_should_fail_when_cart_is_already_checked_out()
{
// Given(過去イベント)
var cartId = Guid.NewGuid();
var history = new IDomainEvent[]
{
new CartCreated(cartId),
new ItemAdded(cartId, "SKU-APPLE", 1),
new CartCheckedOut(cartId, DateTimeOffset.UtcNow),
};
var state = ShoppingCartState.From(history);
// When(コマンド相当)
var result = ShoppingCartDecider.AddItem(cartId, state, "SKU-BANANA", 1);
// Then(失敗していること)
Assert.False(result.IsOk);
Assert.Equal("cart.alreadyCheckedOut", result.Error!.Code);
}
}
8. エラーメッセージ設計のコツ 💬✨
ユーザー向けの Message は、こうすると優しくて強い😊🌸
- 何がダメ?(事実)📌
- どうすればいい?(次の行動)🧭
- 責めない言い方にする💖
例:
- ❌「不正です」
- ✅「購入確定済みだから追加できないよ🧾✨ もし変更したいなら新しいカートでやってみてね!」
9. ありがちな落とし穴と対策 🕳️🪜
落とし穴①:UIで弾いたから大丈夫と思う
→ API直叩きやバグで突破される😱 ✅ ドメインで必ず守る🛡️
落とし穴②:Applyの中で検証し始める
→ 「過去の履歴が読み直せない」事故になることがある💥 ✅ Applyは黙って状態反映、検証はDecideへ🧠
落とし穴③:エラーコードが場当たりになる
✅ 命名ルールを決める(例:cart.*, quantity.*)🏷️✨
10. AI活用:ここはAIが超得意🤖💖
10.1 不変条件を洗い出すプロンプト🧠
あなたはDDDの観点で不変条件をレビューする役です。
ショッピングカートの仕様は以下:
- カート作成、商品追加、購入確定
- 購入確定後は変更不可
- 数量は1以上
このとき「不変条件」を5〜10個、短い理由つきで提案して。
また、それぞれにエラーコード案(例: cart.empty)も付けて。
10.2 エラーメッセージ改善プロンプト💬
次のエラーメッセージを、ユーザーが次に何をすればいいか分かるように、
やさしく短く直して。責める言い方は禁止。絵文字も少し入れて。
- code: cart.alreadyCheckedOut
- message: "Checkout済み"
10.3 テスト生成プロンプト🧪
Given-When-Then形式で、失敗ケースのxUnitテストを2本作って。
対象は ShoppingCartDecider.AddItem と Checkout。
「イベント履歴(Given)→ Decide呼び出し(When)→ Error.Code検証(Then)」の形にして。
11. この章のチェックリスト ✅📝
- Decideの中に不変条件がまとまっている🧠
- 失敗したら イベントを返さない(Appendしない)🛑
- エラーコードが一貫している🏷️
- 失敗ケースのテストが最低1本ある🧪
- 例外は「想定外」だけに寄せられている💥
本日時点の“環境の最新っぽさ”メモ 🆕🪟
- .NET 10 は LTSで、公開日やサポート期限が明記されているよ(2025-11-11 リリース、サポートは 2028-11-14 まで)。(Microsoft)
- Visual Studio 2026 + .NET 10 では、F5起動が最大30%速くなる、という案内も出てるよ🚀 (learn.microsoft.com)