第46章:状態遷移を閉じ込める(setStatus禁止)🚫🚦
この“表”があるだけで、設計が一気に強くなるよ💪✨ そしてこの表を コードに埋め込むのがこの章の主役!
この章はズバリ… 「状態(Status)を安全に変える仕組み」 を作る回だよ〜!🥳
DDDだと状態って「ただの文字」じゃなくて、ルールの塊なんだよね🧠💎
だから、setStatus() みたいに“誰でも自由に書き換えられる入口”があると、すぐ事故る💥
1) まず結論:setStatus がダメな理由🙅♀️💥
setStatus("Paid") みたいなのがあると…
- ✅ 禁止の状態遷移ができちゃう(例:Draft → Paid)😇
- ✅ 遷移に必要な処理が抜ける(例:支払い記録を残さずにPaidへ)🕳️
- ✅ ルールが散って「どこで守ってるの?」になる(バグの温床)🐛🔥
- ✅ 後から状態が増えた瞬間、全部が壊れやすい(変更に弱い)🧨
DDDの気持ちとしてはこう👇 「状態は“勝手に変えていいもの”じゃなくて、“決められた手順でしか変わらないもの”」 🛡️✨
2) 目標:状態を“メソッド”でしか変えられないようにする🎮🔒
理想の操作はこう!👇
order.confirm()✅(確定できる条件なら確定)order.pay()✅(支払いできる条件なら支払い)order.cancel()✅(キャンセルできる条件ならキャンセル)order.setStatus(...)❌(存在しない)
状態の変更は「set」じゃなくて、“意図”で表現するのがポイントだよ🫶✨ (= データよりメソッドの続きだね!第45章とつながってる〜😚)
3) まずは「許可/禁止の遷移表」を作る🧾🚦
例題(注文)なら、こんな感じが気持ちいい👇
- Draft(下書き) → Confirmed(確定) / Cancelled(取消)
- Confirmed(確定) → Paid(支払い済み) / Cancelled(取消)
- Paid(支払い済み) → Fulfilled(提供済み)
- Fulfilled(提供済み) → もう動かない
- Cancelled(取消) → もう動かない
この“表”があるだけで、設計が一気に強くなるよ💪✨ そしてこの表を コードに埋め込むのがこの章の主役!
4) TypeScriptで「遷移表をコード化」する(2026っぽく堅く)🧡🧱
ここでは TypeScript の型チェックを最大限使うよ〜!
特に satisfies は「型に合ってるか検査しつつ、推論は崩さない」ので、こういう表に相性よすぎ😳✨ (TypeScript)
あと、現時点の安定版は TypeScript 5.9 系の情報が公式に出てるよ〜(リリースノートあり) (TypeScript) さらに Visual Studio 2026 では TypeScript Native Preview みたいな動きもある(型チェックや言語サービスの高速化が話題)って流れ!🚀 (Microsoft Developer)
4-1) 状態(Status)を union で定義する🧩
export type OrderStatus =
| "Draft"
| "Confirmed"
| "Paid"
| "Fulfilled"
| "Cancelled";
4-2) 「遷移表」を as const + satisfies で固定する📌✨
import type { OrderStatus } from "./OrderStatus";
export const ORDER_TRANSITIONS = {
Draft: ["Confirmed", "Cancelled"],
Confirmed: ["Paid", "Cancelled"],
Paid: ["Fulfilled"],
Fulfilled: [],
Cancelled: [],
} as const satisfies Record<OrderStatus, readonly OrderStatus[]>;
これで何が嬉しいの?🥺✨
ORDER_TRANSITIONSのキーが 漏れると型エラーになる(状態追加に強い!)🧱- 配列の中身も 変なStatusを書いたら型エラーになる🧯
- 実行時だけじゃなく、コンパイル時に守れるのが最高💘
4-3) “遷移できるか”をチェックする関数を作る🔎🚦
import type { OrderStatus } from "./OrderStatus";
import { ORDER_TRANSITIONS } from "./OrderTransitions";
export class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = "DomainError";
}
}
export function assertCanTransition(
from: OrderStatus,
to: OrderStatus
): void {
const allowed = ORDER_TRANSITIONS[from];
const ok = allowed.includes(to);
if (!ok) {
throw new DomainError(`状態遷移できません: ${from} -> ${to}`);
}
}
ポイント💡 ここは“汎用の安全装置”として切り出しておくと、Entityが読みやすくなるよ〜😊✨
5) Entity(Order)側は「意図メソッド」だけを公開する🪪🧍✨
import type { OrderStatus } from "./OrderStatus";
import { assertCanTransition } from "./TransitionGuard";
export class Order {
// 外から status を直接書き換えられないように private にする
private _status: OrderStatus;
constructor(initialStatus: OrderStatus = "Draft") {
this._status = initialStatus;
}
// 読み取りはOK(表示とか判定用)
get status(): OrderStatus {
return this._status;
}
// ✅ 意図メソッドだけ公開する
confirm(): void {
this.transitionTo("Confirmed");
}
pay(): void {
this.transitionTo("Paid");
}
fulfill(): void {
this.transitionTo("Fulfilled");
}
cancel(): void {
this.transitionTo("Cancelled");
}
// 状態変更はここに閉じ込める(共通ガード)
private transitionTo(next: OrderStatus): void {
assertCanTransition(this._status, next);
this._status = next;
}
}
これで setStatus() が無くても、全部できる!🥳
しかも…
- 遷移ルールは
ORDER_TRANSITIONSに集約 ✅ - 変更の入口は
transitionToに集約 ✅ - 外部のコードは「意図」しか呼べない ✅
つまり、事故の入り口が消える🚪❌✨
6) テストで「遷移の成功/失敗」を固める🧪💖
テストは「状態遷移の表」を守れてるか確認するのが主目的! ここでは Vitest を例にするよ(最近もメジャーアップデートが継続してる流れがある)(Vitest)
import { describe, it, expect } from "vitest";
import { Order } from "./Order";
describe("Order 状態遷移", () => {
it("Draft -> Confirmed はOK", () => {
const order = new Order("Draft");
order.confirm();
expect(order.status).toBe("Confirmed");
});
it("Draft -> Paid はNG(いきなり支払いはできない)", () => {
const order = new Order("Draft");
expect(() => order.pay()).toThrowError();
});
it("Confirmed -> Paid はOK", () => {
const order = new Order("Confirmed");
order.pay();
expect(order.status).toBe("Paid");
});
it("Paid -> Fulfilled はOK", () => {
const order = new Order("Paid");
order.fulfill();
expect(order.status).toBe("Fulfilled");
});
it("Cancelled はもう動かない", () => {
const order = new Order("Cancelled");
expect(() => order.confirm()).toThrowError();
expect(() => order.pay()).toThrowError();
expect(() => order.fulfill()).toThrowError();
expect(() => order.cancel()).toThrowError();
});
});
テストのコツ💡
- ✅ OKルート(許可される遷移)
- ✅ NGルート(禁止される遷移) この2つがあれば、この章は勝ちです✌️😆✨
7) よくある失敗パターン(あるある)😂⚠️
❌ 失敗1:status を public にする
public status: OrderStatus;
→ 外から order.status = "Paid" ができちゃう😇
**終了〜〜〜!**💥
❌ 失敗2:setStatus を残す
setStatus(next: OrderStatus) { this._status = next; }
→ “裏口”があると、未来の自分が必ず使う🤣 **裏口は封鎖!**🚪🧱
❌ 失敗3:遷移表がコードのあちこちに散る
if (status === "Draft") ...がいろんな場所に増殖👾 → 状態追加で地獄になる🫠 **遷移表は1箇所へ!**📌✨
8) ここから一歩進めるアイデア(任意)🌱✨
✅ 例外メッセージを“人間向け”にする🧁
今は 状態遷移できません だけど、運用では
- ユーザー向け文言
- 開発者向けログ文言 を分けたくなるよね(第89章にもつながる〜!)😚✨
✅ State Pattern(状態クラス)に進化させる🏗️
状態ごとに DraftState, PaidState…みたいにクラス分けすると、さらに強い設計にもできるよ💪
(ただし最初は今章の“表+ガード”で十分!✨)
9) AI活用コーナー(この章に効く使い方)🤖🪄
9-1) 遷移表を壁打ちして漏れを発見する🧠
おすすめプロンプト👇
- 「注文の状態(Draft/Confirmed/Paid/Fulfilled/Cancelled)で、現実の業務としてあり得る遷移と、禁止したい遷移を表にして。禁止理由も一言で。」
9-2) テストケースを増やす🧪
- 「上の遷移表から、最低限必要な正常系テストと異常系テストを列挙して。網羅より“壊れやすい順”で優先度もつけて。」
9-3) 例外メッセージを整える🧁
- 「DomainError のメッセージを、ユーザー表示用とログ用に分ける案を出して。」
AIは“答えを出す人”じゃなくて、仕様の抜けを炙り出す相棒だよ〜🤝✨
10) ミニ演習(10〜20分)⏳🎀
演習A:状態を1つ増やしてみよう➕
"Refunded"(返金済み)を追加して、
- Paid → Refunded はOK
- Refunded →(他は全部NG)
にしてみてね!🧾✨
チェック✅
ORDER_TRANSITIONSのキー漏れで型エラー出た?(出たら勝ち!)😆- テストも1〜2本追加できた?🧪
演習B:cancel() を少し賢くする🧠
たとえば「Fulfilled はキャンセル不可」にしたいなら、 遷移表をいじるだけで済むようにできるよね?😚✨
まとめ(この章で手に入る武器)🎁✨
setStatusを消すと 状態が壊れない🚫🚦- 状態変更は「意図メソッド」で表現すると 読みやすい🎮
- 遷移ルールは “表” に集めると 変更に強い📌
- TypeScriptの型(
satisfiesなど)で 追加変更に強い設計になる🧱✨ (TypeScript)
次の第47章は「EntityとVOの境界(どこまでVO化?)」だね🧩✨ この章で状態が固くなったから、次は「部品の分け方」がめちゃ気持ちよく進むはず〜!🥳💖