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

第17章:不変条件② Entityで守る(状態とルール)🧾🔒

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

  • 「VOで守るべきルール」と「Entityで守るべきルール」を分けられる 💎🆚🧾
  • **Order(注文)**の中に業務ルールを閉じ込めて、外から壊されない設計にできる 🏠🔒
  • **状態(Draft/Confirmed/Canceled)**が絡むルールを、スッキリ実装できる 🔁✅
  • テストで「確定後に明細が変えられない」をちゃんと保証できる 🧪✨

※ いまの最新の開発基盤は .NET 10(LTS)C# 14が中心で、Visual Studio 2026 も .NET 10 を含みます。 (Microsoft)


1. まず感覚をつかもう!VOとEntity、どっちが何を守るの?💡💎🧾

VOが守るもの(値の正しさ)💎✅

  • Money が マイナスになれない 🚫💰
  • Quantity が 1以上 📦✅
  • Email が 変な形式じゃない 🚫📧

→ これは「その値単体」で決まるから、VOで守るのが得意だよ〜✨

Entityが守るもの(状態・関係・業務ルール)🧾🔒

  • 注文が 確定(Confirmed)したら、明細を追加・削除できない 🚫🧾
  • 注文は 空のまま確定できない(明細0件はダメ)🚫
  • キャンセルは キャンセル済みをもう一回キャンセルできない 😵‍💫🚫
  • (もっと現実寄りだと)支払い後はキャンセル不可、など 💳🚫

→ これは「注文という存在(Entity)」の状態や中身(明細)に依存するから、Entityが守るのが得意!🏠✨


2. “ルールが散る”とどうなる?😱🧯

ありがちな事故👇

  • 画面Aでは「確定後は変更禁止」をチェックしてるのに…
  • 画面Bやバッチ処理ではチェックし忘れて、確定後でも変更できちゃう 😇💥

対策はシンプル:ルールはOrderの中に閉じ込める! 外側(UI/Controller/Service)は「お願い」するだけ。 最終判断は Orderがする 🧾👑


3. 今日の題材ルール(学内カフェ注文)☕️🧾✨

状態(OrderStatus)を3つにするよ!🔁

  • Draft 🟡:下書き(明細いじってOK)
  • Confirmed 🟢:確定(明細変更NG)
  • Canceled 🔴:キャンセル(操作ほぼNG)

ルール(この章の主役)📌

  1. Draft のときだけ AddLine / RemoveLine / ChangeQuantity ができる ✅
  2. Confirm()明細が1件以上ないと失敗 🚫
  3. Confirmed になったら 明細変更は全部失敗 🚫🧾
  4. Cancel() は Draft/Confirmed ならOK、Canceled はNG 🚫

4. 実装:Orderに「不変条件ガード」を埋め込もう 🏠🔒✨

ここからは “外から壊せない” 形を作るよ〜!😆 ポイントは👇

  • プロパティの set を公開しない(勝手に状態を書き換えられないように)🔒
  • 明細リストは List をそのまま公開しない(外で .Add() されると終わる😇)🚫
  • 失敗は Resultで返す(失敗理由も返す)⚠️➡️🧾

4-1. Result(超ミニ版)🧩✨

namespace Cafe.Domain;

public readonly record struct DomainError(string Code, string Message);

public readonly record struct Result(bool IsSuccess, DomainError? Error)
{
public static Result Success() => new(true, null);

public static Result Failure(string code, string message)
=> new(false, new DomainError(code, message));
}

💡Code は「機械向け」、Message は「人間向け」って覚えると便利だよ〜🤖🫶


4-2. 状態(OrderStatus)🔁

namespace Cafe.Domain;

public enum OrderStatus
{
Draft,
Confirmed,
Canceled
}

4-3. OrderLine(今回は“明細はVOっぽく”シンプルに)🧾💎

※ OrderLine を Entity にするかは後半で深掘りするけど、まずは学習しやすい形でOK!🙆‍♀️✨

namespace Cafe.Domain;

public sealed record OrderLine(
ProductId ProductId,
Quantity Quantity,
Money UnitPrice
)
{
public Money LineTotal => UnitPrice * Quantity;
}

ProductId / Quantity / Money はVOとして既にある想定だよ💎)


4-4. Order(この章の本体)🧾👑✨

using System.Collections.ObjectModel;

namespace Cafe.Domain;

public sealed class Order
{
private readonly List<OrderLine> _lines = new();

public OrderId Id { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;

// 外から List を触れないようにする!超重要🔒
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

private Order(OrderId id)
{
Id = id;
}

public static Order CreateNew(OrderId id) => new(id);

// --- ルール:Draft だけ編集OK ---
public Result AddLine(OrderLine line)
{
var guard = EnsureDraft();
if (!guard.IsSuccess) return guard;

// 例:同じ商品がすでにあるなら数量を足す、みたいなルールもここに置けるよ✨
_lines.Add(line);
return Result.Success();
}

public Result RemoveLine(ProductId productId)
{
var guard = EnsureDraft();
if (!guard.IsSuccess) return guard;

var index = _lines.FindIndex(x => x.ProductId == productId);
if (index < 0)
return Result.Failure("Order.LineNotFound", "その商品は明細にありません🧾💦");

_lines.RemoveAt(index);
return Result.Success();
}

public Result Confirm()
{
if (Status == OrderStatus.Canceled)
return Result.Failure("Order.Canceled", "キャンセル済みの注文は確定できません🔴🚫");

if (Status == OrderStatus.Confirmed)
return Result.Failure("Order.AlreadyConfirmed", "すでに確定済みです🟢✨");

if (_lines.Count == 0)
return Result.Failure("Order.Empty", "明細が0件の注文は確定できません🚫🧾");

Status = OrderStatus.Confirmed;
return Result.Success();
}

public Result Cancel()
{
if (Status == OrderStatus.Canceled)
return Result.Failure("Order.AlreadyCanceled", "すでにキャンセル済みです🔴💦");

Status = OrderStatus.Canceled;
return Result.Success();
}

private Result EnsureDraft()
{
if (Status != OrderStatus.Draft)
return Result.Failure("Order.NotDraft", "確定後は明細を変更できません🧾🔒");

return Result.Success();
}
}

✅ これで「確定後にAddLineできない」が Orderの中で絶対に守られる よ!🧾🔒✨ UI側がチェックし忘れても、Orderが止めてくれる💪😆


5. ミニ演習:Confirm後にAddLineできないようにする 🚫🧾✨(テストから!)

ここ、テスト先に書くのが超おすすめ!🧪💕

5-1. 失敗するテストを書く(最初は赤でOK❤️)

using Cafe.Domain;
using Xunit;

public class Order_InvariantTests
{
[Fact]
public void Confirmed_order_cannot_add_line()
{
var order = Order.CreateNew(new OrderId(Guid.NewGuid()));

// 1件入れて確定
order.AddLine(new OrderLine(
new ProductId("coffee"),
Quantity.Create(1).Value, // VOの想定
Money.Create(300, "JPY").Value // VOの想定
));
order.Confirm();

// 確定後に追加しようとすると失敗するはず!
var result = order.AddLine(new OrderLine(
new ProductId("tea"),
Quantity.Create(1).Value,
Money.Create(250, "JPY").Value
));

Assert.False(result.IsSuccess);
Assert.Equal("Order.NotDraft", result.Error?.Code);
}
}

Quantity.CreateMoney.Create は前の章で作った「ResultでVO生成」想定だよ💎 ここでは Value を取れてる=生成成功してる前提でOK!

5-2. 実装を直してテストを緑にする💚

さっきの EnsureDraft() を入れると通る!✅✨ (入ってなかったら、ここで追加してね!😆)


6. ルール×状態のテストは「表」で考えると勝ち📊✨

状態と操作の相性をざっくり表にすると👇

  • Draft:Add/Remove/Confirm/Cancel ✅
  • Confirmed:Confirm(再実行)🚫、Add/Remove 🚫、Cancel ✅
  • Canceled:ほぼ全部🚫(Cancelだけ再実行🚫)

この “表” をそのままテストに落とすと、抜け漏れが減るよ〜!🧠✨


7. AI活用(Copilot/Codex)で爆速にするコツ 🤖⚡️

7-1. テストケースを増やすプロンプト例 🧪✨

「Orderの状態遷移に対するテストを増やしたい」ってとき👇

  • 「OrderStatus が Draft/Confirmed/Canceled のときに、AddLine/RemoveLine/Confirm/Cancel が成功/失敗するテストケースを列挙して。失敗時は Error.Code も提案して」🤖📝
  • 「‘確定は明細1件以上’ の境界値テストを出して(0件/1件/複数件)」🎯🧪

7-2. AIの提案でありがちな地雷 ☠️😇

  • public set; を付けてくる → 絶対ダメ! 外から壊される🔓💥
  • List<OrderLine> をそのまま返す → 外で .Add() されて終わる😇

採用基準はこれ👇

  • ルールが Order のメソッド内にある?🏠
  • 外から状態や明細が変えられない?🔒
  • 失敗理由(Code/Message)が返せてる?🧾✨

8. よくある事故集(この章の落とし穴)🧯😵‍💫

❌事故1:確定後でもプロパティを書き換えられる

  • public OrderStatus Status { get; set; } とかは即アウト🙅‍♀️ ✅ private set にする!

❌事故2:Lines が List のまま公開されてる

  • public List<OrderLine> Lines { get; } だと外から追加できちゃう😇 ✅ IReadOnlyListAsReadOnly() でガード!

❌事故3:ルールがUI/Serviceに散ってる

  • 画面Aでは守れるけど画面Bで崩壊💥 ✅ 最終防衛線は Entity(Order)

9. まとめ:Entity不変条件チェックリスト ✅🧾🔒✨

  • 状態が絡むルールは Entityのメソッドにある?🏠
  • set が公開されてなくて、外から壊せない?🔒
  • コレクションは IReadOnlyList で守ってる?📦
  • 失敗は Result で返して、理由(Code/Message)も持ってる?⚠️🧾
  • 「状態×操作」の組み合わせをテストしてる?🧪📊

次の第18章は、今日やった「状態」をさらに強くするために、**状態機械(State Machine)**を表で整理して、抜け漏れを消していくよ〜!📊🔁✨