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

第27章:状態機械②:ガード条件(条件付き遷移)を整理する🧠🚦

今日のゴール🎯

  • 「この状態なら次へ進める」だけじゃなくて、“条件が揃ってる時だけ進める” をキレイに整理できるようになる✨
  • ガード条件(Guard Condition)を 散らさずバグらず読みやすく まとめる型を身につける💪😊

(ちなみに今の最新だと、.NET 10.0.2 / SDK 10.0.102 が出てて、C# 14 が使えるよ〜🆕✨) (Microsoft)


まず「ガード条件」ってなに?🚧

状態遷移って、前章のイメージだとこんな感じだよね👇

  • Created → Paid → Shipped
  • Created/Paid → Cancelled

ここに「条件」を足したのがガード条件!✨

たとえば「Paid なら Shipped OK」じゃなくて、 **「Paid かつ、住所OK かつ、在庫確保済み なら Shipped OK」**みたいなやつ😊


まずダメ例😇:ガード条件が散ると、バグる💥

ありがちな事故あるある💣

  • UIでチェック
  • APIでもチェック
  • Application Serviceでもチェック
  • Domainでもまたチェック

その結果…

  • A画面からは発送できないのに、B画面からは発送できちゃう😵
  • “キャンセル可能”の条件が場所ごとにズレてる😇
  • 仕様変更(例:キャンセル期限追加)で修正漏れが出る💀

ダメコード例(散ってるやつ)😇

// ApplicationService と Controller とか、いろんな場所に if が散っていく…😇
if (order.State != OrderState.Paid) throw new InvalidOperationException("未払いです");
if (!order.AddressConfirmed) throw new InvalidOperationException("住所未確定です");
if (!order.StockReserved) throw new InvalidOperationException("在庫未確保です");

// どこか別ルートでは AddressConfirmed を忘れてたりする…💥
order.State = OrderState.Shipped;

良い例😎:ガード条件は「遷移の横」に置く🧠✨

Guard Conditions

コツは超シンプル👇

✅ ルール

「遷移(From→To)」と「ガード条件」を同じ場所に並べる📌 → すると、読む人が “この遷移は何が必要?” を一瞬で追える👀✨


ガード条件の集め方🧺✨(チェックリスト付き)

前章で作った状態遷移表に、右側へ「条件」列を足すイメージだよ🧾💕

ガード条件はだいたい4種類に分かれるよ🧠

  1. 状態系:今の状態が合ってる?(Paid からしか Ship できない等)
  2. データ系:必要な情報が揃ってる?(住所/在庫/金額/本人確認など)
  3. 時間系:期限はOK?(30分以内キャンセル、発送締め時刻など)
  4. 外部依存系:外部がOK返した?(決済/配送API/在庫サービスなど)

⭐ポイント: 外部依存系は “事前に確認して、その結果を持ってから” ガードに入れるのがキレイ✨ (ドメインがいきなり外部APIを叩くと、テスト地獄になりやすいよ〜😵)


例:ミニEC(注文)の遷移+ガード条件表🛒🚚

FromTriggerToガード条件(例)
CreatedPayPaid支払い承認済み(PaymentAuthorized)
PaidShipShipped住所確定 & 在庫確保 & 発送ラベル作成済み
CreatedCancelCancelled未発送(=CreatedならOK)
PaidCancelCancelled未発送 & キャンセル期限内(例:支払いから30分以内)

こうやって表にすると、仕様が一気に見えるよね😊✨


手を動かす(C#)⌨️✨:ガード条件を「遷移の横」に固定する

ここでは「自作ミニ状態機械」を作って、ガード条件を整理する形を体に入れるよ〜💪😆

1) ドメインの状態とトリガー📦

public enum OrderState
{
Created,
Paid,
Shipped,
Cancelled
}

public enum OrderTrigger
{
Pay,
Ship,
Cancel
}

2) Order(必要最小限の情報だけ)🧾

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

// ガード条件に使う “材料” たち🧠
public bool PaymentAuthorized { get; private set; }
public bool AddressConfirmed { get; private set; }
public bool StockReserved { get; private set; }
public bool ShippingLabelCreated { get; private set; }

public DateTimeOffset CreatedAtUtc { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset? PaidAtUtc { get; private set; }

// Application層で外部確認した結果を反映するイメージ✨
public void MarkPaymentAuthorized() => PaymentAuthorized = true;
public void ConfirmAddress() => AddressConfirmed = true;
public void ReserveStock() => StockReserved = true;
public void CreateShippingLabel() => ShippingLabelCreated = true;

internal void SetState(OrderState state) => State = state;
internal void MarkPaidNow() => PaidAtUtc = DateTimeOffset.UtcNow;
}

3) Result(失敗理由を返す)🚧

public readonly record struct Result(bool IsSuccess, string? Error)
{
public static Result Ok() => new(true, null);
public static Result Fail(string error) => new(false, error);
}

4) 「遷移」と「ガード」を並べて固定する🧠🚦

public sealed class OrderStateMachine
{
private readonly Order _order;
private readonly Func<DateTimeOffset> _utcNow;

public OrderStateMachine(Order order, Func<DateTimeOffset>? utcNow = null)
{
_order = order;
_utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
}

private sealed record Transition(
OrderState From,
OrderTrigger Trigger,
OrderState To,
Func<Order, bool> Guard,
Func<Order, string> GuardFailMessage,
Action<Order>? OnSuccess = null
);

// ✅ ここが本体:遷移の横にガードがいる✨
private static readonly Transition[] Transitions =
[
new(
From: OrderState.Created,
Trigger: OrderTrigger.Pay,
To: OrderState.Paid,
Guard: CanPay,
GuardFailMessage: _ => "支払い承認がまだだよ💳😵",
OnSuccess: o => o.MarkPaidNow()
),

new(
From: OrderState.Paid,
Trigger: OrderTrigger.Ship,
To: OrderState.Shipped,
Guard: CanShip,
GuardFailMessage: BuildShipFailMessage
),

new(
From: OrderState.Created,
Trigger: OrderTrigger.Cancel,
To: OrderState.Cancelled,
Guard: _ => true,
GuardFailMessage: _ => "Created のキャンセルは常にOKだよ👌"
),

new(
From: OrderState.Paid,
Trigger: OrderTrigger.Cancel,
To: OrderState.Cancelled,
Guard: o => CanCancelAfterPaid(o),
GuardFailMessage: _ => "支払い後キャンセルは “支払いから30分以内” だけだよ⏰😵"
)
];

public Result Fire(OrderTrigger trigger)
{
var transition = Transitions.FirstOrDefault(t => t.From == _order.State && t.Trigger == trigger);
if (transition is null)
return Result.Fail($"この操作はできないよ🙅‍♀️(State={_order.State}, Trigger={trigger})");

if (!transition.Guard(_order))
return Result.Fail(transition.GuardFailMessage(_order));

_order.SetState(transition.To);
transition.OnSuccess?.Invoke(_order);

return Result.Ok();
}

// ===== ガード条件(散らさない!)=====

private static bool CanPay(Order o)
=> o.PaymentAuthorized;

private static bool CanShip(Order o)
=> o.AddressConfirmed
&& o.StockReserved
&& o.ShippingLabelCreated;

private bool CanCancelAfterPaid(Order o)
{
if (o.PaidAtUtc is null) return false;
return (_utcNow() - o.PaidAtUtc.Value) <= TimeSpan.FromMinutes(30);
}

private static string BuildShipFailMessage(Order o)
{
var missing = new List<string>();
if (!o.AddressConfirmed) missing.Add("住所確定");
if (!o.StockReserved) missing.Add("在庫確保");
if (!o.ShippingLabelCreated) missing.Add("発送ラベル");

return missing.Count == 0
? "発送できない理由があるよ😵(条件を確認してね)"
: $"発送まだだよ🚚💦 足りないもの:{string.Join(" / ", missing)}";
}
}

5) 動かしてみる(超ミニ)🧪

var order = new Order();
var sm = new OrderStateMachine(order);

// いきなり発送してみる😇
Console.WriteLine(sm.Fire(OrderTrigger.Ship).Error);

// 支払い承認 → 支払い → 発送準備
order.MarkPaymentAuthorized();
Console.WriteLine(sm.Fire(OrderTrigger.Pay).IsSuccess); // True

order.ConfirmAddress();
order.ReserveStock();
order.CreateShippingLabel();

Console.WriteLine(sm.Fire(OrderTrigger.Ship).IsSuccess); // True
Console.WriteLine(order.State); // Shipped

ここが超大事📌:ガード条件の置き場所ルール🧭

  • 遷移テーブル(または Configure)に “ガード条件” を隣接させる
  • ガードの中身は、CanShip / CanCancel… みたいに「名前つき関数」にして読みやすく✨
  • 外部確認が必要なら、Application層で済ませてから Orderにフラグや材料を渡す(テストしやすい😊)

便利ライブラリもあるよ(参考)📚✨

「状態機械をいい感じに書きたい!」ってなったら、Stateless が定番だよ〜😊

  • PermitIf(...) みたいに ガード付き遷移が素直に書ける✨ (NuGet)

(でも今章は “整理の型” が目的だから、まず自作で感覚つかむのが勝ち🥇✨)


ミニ演習📝✨

  1. キャンセル条件を強化してみよう💪

    • 「Paid でもキャンセル可能。ただし 発送ラベル作成前まで」を追加してね🧾
  2. Ship の条件を追加してみよう🚚

    • 「配送先が国内ならOK」みたいな条件を足して、メッセージも出し分けてみてね🇯🇵✨
  3. “条件列”を増やす練習🧠

    • 遷移表の右側に「データ系 / 時間系 / 外部依存系」って分類メモをつけてみよ😊

AI活用プロンプト例🤖✨(Copilot / Codex向け)

(そのまま貼ってOKだよ〜😊)

注文(Order)の状態遷移を設計しています。
状態:Created, Paid, Shipped, Cancelled
遷移:Pay, Ship, Cancel
「ガード条件」を洗い出して、表(From/Trigger/To/Guard)にしてください。
初心者にも分かるように、Guardは短い自然言語で書いてください。
C#で状態機械を自作しています。遷移テーブルに Guard と GuardFailMessage を並べたいです。
CanShip/CanCancel などの命名を含めて、読みやすい設計案を出してください。
ガード条件が仕様変更で増えます。
「ガードの分類(状態/データ/時間/外部)」の観点で、漏れを検出するチェックリストを作ってください。
OrderStateMachine の各ガード条件について、最低限の単体テスト観点(Given/When/Then)を列挙してください。

まとめ(覚える1行)📌✨

ガード条件は “遷移の横” に置く。散らすとバグる。 🧠🚦💥

次章(第28章)では、ここで作った状態機械をさらに強化して、 **「許可されない遷移を “型” で潰す」**方向へ進むよ〜🧱✨