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

第13章:不変条件(Invariants)を決める🔒✨

〜“壊れない状態”を入口で守って、事故を激減させよ〜🧯💖


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

この章のゴールはこれだよ〜😊🫶

  • 「この注文は“正常”?」を判断するルール(不変条件)を言語化できる📘✨
  • 状態ごとの必須項目(Paidなら支払い情報が必須!など)を表にできる📊✅
  • コードで“不変条件チェック”を組み込める(最小構成でOK)🧱🔒
  • AIに“不変条件の候補”と“破ると困る理由”を出させて、仕様を強くできる🤖💡

2) 不変条件ってなに?(超ざっくり)🍙

013 Invariants

**不変条件(Invariant)=「そのオブジェクトが“正常”でいるために、いつでも守られてほしいルール」**だよ🔒✨ たとえば学食注文なら…

  • Paid(支払い済)なら「支払い日時が入ってる」「金額が入ってる」
  • PickedUp(受取済)なら「受取日時が入ってる」
  • Refunded(返金済)なら「返金日時と返金額が入ってる」

こういうのが “壊れないための最低条件” だよ〜🧸💕

ちなみに昔の「Code Contracts」みたいな“専用ツールで契約チェック”は、今の .NET(.NET 5+)ではサポートされない前提なので、**現代は「自前のチェック + NRT(nullable)+ テスト」**が現実的だよ🛡️✨ (Microsoft Learn)


ここ、めっちゃ混ざりやすいので超大事〜〜⚠️😵‍💫

  • ガード条件(第10章): 「このイベント、今この状態でやっていい?」(遷移の可否)🚦 例:Cooking になったら Cancel できない🍳🚫

  • 不変条件(第13章): 「この状態の注文って、データとして“成立”してる?」(整合性)🧾✅ 例:Paid なのに PaidAt が null は成立しない💥


4) 手順:不変条件の作り方(初心者向けテンプレ)🧩✨

やることはシンプルだよ😊

Step A:注文が持つデータ項目を“棚卸し”🧺

例(最低限)👇

  • OrderId
  • Items(注文明細)
  • TotalAmount
  • SubmittedAt
  • PaymentMethod / PaidAt
  • KitchenTicketId
  • ReadyAt / PickedUpAt
  • CancelledAt / CancelReason
  • RefundedAt / RefundAmount

Step B:「状態ごとの必須項目チェック表」を作る📊✅

ここが本章のメイン!💖 (例:学食モバイル注文)

状態ItemsTotalAmountSubmittedAtPaidAt/支払情報KitchenTicketIdReadyAtPickedUpAtCancelReasonRefund情報
Draft(下書き)📝
Submitted(注文)📨
Paid(支払)💳
Cooking(調理中)🍳
Ready(受取可)📣
PickedUp(受取済)🎁
Cancelled(取消)🚫✅(場合による)(任意)
Refunded(返金)💸✅(場合による)✅(理由)

✅=必須 / —=不要 or 任意 ※「Cancelled の Items 必須か?」みたいに迷うところが仕様の決めどころだよ〜🤝✨

Step C:「破ると何が困る?」を書いて強化する🧠💥

例:Paid なのに PaidAt が無い → 「いつ支払ったか追跡できない」「返金や監査ログで詰む」😇📉


5) どこでチェックする?おすすめの置き場所🏠✨

初心者向けの結論だけ言うね😊

  • 状態が変わった直後(遷移が成功した直後)に必ずチェック🔁✅
  • 保存(DB/ファイル)する直前にもチェック💾🔒
  • APIで外へ返す前にもチェック(壊れたデータを外に出さない)🌐🛡️

6) 実装(最小構成):状態ごとの Invariant を switch で守る🧱🔒

※「第15〜17章の実装」に合流しやすい形(enum + メソッド)で書くよ😊

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;

public enum OrderState
{
Draft,
Submitted,
Paid,
Cooking,
Ready,
PickedUp,
Cancelled,
Refunded
}

public sealed class Order
{
public Guid OrderId { get; } = Guid.NewGuid();
public OrderState State { get; private set; } = OrderState.Draft;

public List<string> Items { get; } = new();
public decimal TotalAmount { get; private set; }

public DateTimeOffset? SubmittedAt { get; private set; }
public string? PaymentMethod { get; private set; }
public DateTimeOffset? PaidAt { get; private set; }

public string? KitchenTicketId { get; private set; }
public DateTimeOffset? ReadyAt { get; private set; }
public DateTimeOffset? PickedUpAt { get; private set; }

public string? CancelReason { get; private set; }
public DateTimeOffset? CancelledAt { get; private set; }

public decimal? RefundAmount { get; private set; }
public DateTimeOffset? RefundedAt { get; private set; }

public void AddItem(string name, decimal price)
{
if (State != OrderState.Draft) throw new InvalidOperationException("下書き以外では商品を追加できません🙅‍♀️");
Items.Add(name);
TotalAmount += price;

EnsureInvariants();
}

public void Submit()
{
if (State != OrderState.Draft) throw new InvalidOperationException("提出できるのは下書きだけだよ🙅‍♀️");
SubmittedAt = DateTimeOffset.Now;
State = OrderState.Submitted;

EnsureInvariants();
}

public void Pay(string paymentMethod)
{
if (State != OrderState.Submitted) throw new InvalidOperationException("支払いできるのは注文後だけだよ🙅‍♀️");
PaymentMethod = paymentMethod;
PaidAt = DateTimeOffset.Now;
State = OrderState.Paid;

EnsureInvariants();
}

public void StartCooking(string kitchenTicketId)
{
if (State != OrderState.Paid) throw new InvalidOperationException("調理開始できるのは支払い後だけだよ🙅‍♀️");
KitchenTicketId = kitchenTicketId;
State = OrderState.Cooking;

EnsureInvariants();
}

public void MarkReady()
{
if (State != OrderState.Cooking) throw new InvalidOperationException("受取可にできるのは調理中だけだよ🙅‍♀️");
ReadyAt = DateTimeOffset.Now;
State = OrderState.Ready;

EnsureInvariants();
}

public void PickUp()
{
if (State != OrderState.Ready) throw new InvalidOperationException("受け取りできるのは受取可だけだよ🙅‍♀️");
PickedUpAt = DateTimeOffset.Now;
State = OrderState.PickedUp;

EnsureInvariants();
}

public void Cancel(string reason)
{
// 例として:Submitted までならキャンセル可(ガードは第10章の復習)
if (State is not (OrderState.Draft or OrderState.Submitted))
throw new InvalidOperationException("今はキャンセルできないよ🙅‍♀️");

CancelReason = string.IsNullOrWhiteSpace(reason) ? "ユーザー都合" : reason;
CancelledAt = DateTimeOffset.Now;
State = OrderState.Cancelled;

EnsureInvariants();
}

public void Refund(decimal refundAmount)
{
if (State != OrderState.Cancelled) throw new InvalidOperationException("返金はキャンセル後だけだよ🙅‍♀️");
RefundAmount = refundAmount;
RefundedAt = DateTimeOffset.Now;
State = OrderState.Refunded;

EnsureInvariants();
}

private void EnsureInvariants()
{
// 0) 常に守る(状態に依存しない)ルール
if (OrderId == Guid.Empty) throw new InvalidOperationException("OrderId が壊れてるよ💥");
if (TotalAmount < 0) throw new InvalidOperationException("合計金額がマイナスはおかしいよ💥");

// Draft 以外では Items が空はダメ、など“方針”は仕様で決めてOK
if (State != OrderState.Draft && Items.Count == 0)
throw new InvalidOperationException("注文なのに商品が空だよ💥");

// 1) 状態ごとの不変条件
switch (State)
{
case OrderState.Draft:
// 下書きは SubmittedAt なしでOK、など
break;

case OrderState.Submitted:
Require(SubmittedAt is not null, "SubmittedAt が必須だよ💥");
break;

case OrderState.Paid:
Require(SubmittedAt is not null, "SubmittedAt が必須だよ💥");
Require(!string.IsNullOrWhiteSpace(PaymentMethod), "PaymentMethod が必須だよ💥");
Require(PaidAt is not null, "PaidAt が必須だよ💥");
break;

case OrderState.Cooking:
Require(PaidAt is not null, "PaidAt が必須だよ💥");
Require(!string.IsNullOrWhiteSpace(KitchenTicketId), "KitchenTicketId が必須だよ💥");
break;

case OrderState.Ready:
Require(!string.IsNullOrWhiteSpace(KitchenTicketId), "KitchenTicketId が必須だよ💥");
Require(ReadyAt is not null, "ReadyAt が必須だよ💥");
break;

case OrderState.PickedUp:
Require(ReadyAt is not null, "ReadyAt が必須だよ💥");
Require(PickedUpAt is not null, "PickedUpAt が必須だよ💥");
break;

case OrderState.Cancelled:
Require(CancelledAt is not null, "CancelledAt が必須だよ💥");
Require(!string.IsNullOrWhiteSpace(CancelReason), "CancelReason が必須だよ💥");
break;

case OrderState.Refunded:
Require(CancelledAt is not null, "CancelledAt が必須だよ💥");
Require(RefundedAt is not null, "RefundedAt が必須だよ💥");
Require(RefundAmount is not null && RefundAmount >= 0, "RefundAmount が必須だよ💥");
break;
}
}

private static void Require(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
}

ポイントはここだよ〜🧡

  • 不変条件は「最後の砦」:状態が変わったあとに必ずチェック✅
  • これだけで「PaidなのにPaidAt無い」とかが即爆発する💥→事故が早期発見できる🎉

7) 演習(やってみよ〜🎮✨)

演習1:チェック表を完成させる📊✅

  • Cancelled / Refunded まわりの「必須項目」をチーム(自分)で決める🧠✨
  • “迷う欄”は、理由も1行で書く📝

演習2:わざと壊して、落ちるのを確認する💥😆

  • PaidAt = null; をどこかで混ぜてみる
  • EnsureInvariants() がちゃんと止めてくれるか確認✅

演習3:テストで守る🧪🛡️(ミニ)

  • 「PaidなのにPaymentMethod無しは例外」みたいな1〜3本でOK!

8) AI活用(この章のうまい使いどころ🤖✨)

そのままコピペで使えるよ〜🫶💖

  • 🧠 不変条件候補を出させる 「学食モバイル注文の状態(Draft/Submitted/Paid/Cooking/Ready/PickedUp/Cancelled/Refunded)ごとに、不変条件を5個ずつ提案して。破ると困る理由も1行で。」

  • 📊 チェック表を埋めさせる 「この状態一覧とデータ項目一覧で、状態×必須項目の表を作って。迷うところは“要判断”と理由も書いて。」

  • 💬 ユーザー向けの説明に直す(仕様書用) 「不変条件を、初心者にも分かる言葉で説明して。例も添えて。」


9) よくあるミス集(先に潰すよ〜🧯✨)

  • ガード条件と不変条件が混ざる → ガード=「やっていい?」 / 不変=「成立してる?」で分ける🧠
  • 不変条件が“お気持ちルール”で増えすぎる → まずは「壊れたら即困る」だけに絞る🔪✨
  • 状態追加したのにチェック更新し忘れるswitch(State) があると「追加漏れ」に気づきやすい👍

10) まとめ🎀✨

  • **不変条件は“注文が正常であるための最低条件”**🔒
  • まずは 「状態×必須項目チェック表」 を作る📊✅
  • 実装は 遷移の直後に EnsureInvariants() を呼べばOK🧱✨
  • ツール頼みより、自前チェック + NRT + テストが現代の堅実ルートだよ🛡️ (Microsoft Learn)

おまけで最新の足場だけ一言🪟✨:.NET 10 は LTS で、C# 14 / Visual Studio 2026 とセットで提供されてるよ〜(更新も継続的に出る)📦🔁 (Microsoft Learn)


次は「第14章:状態機械を“仕様書”として整える📘✨」で、**いま作った不変条件をREADMEに“読みやすく統合”**していくよ〜🫶💖