Skip to main content

第43章:Entityの不変条件:どこで守る?🔒

  1. “複数のEntityにまたがるルール” →(次の章で出る)Aggregate Root 側に寄せる🏯

この章のゴール🎯

  • Entityが自分のルールを自分で守る設計にできるようになる🙆‍♀️
  • 「setterで自由に書き換え」みたいな事故を防げるようになる🚫
  • TypeScriptで、**“破れない作り”**をコードで体感する💪🧡

まず結論🍓

Entityの不変条件(絶対守るルール)は、基本こう守るよ👇

  1. 生成時のルールconstructor / static create() で守る🏭✨
  2. 状態に応じたルール → Entityの メソッド内 で守る🛡️
  3. 外から直接いじれないprivate / #private で封鎖する🔐
  4. 更新は「意図のあるメソッド」だけconfirm() / addItem() みたいにする🎮
  5. “複数のEntityにまたがるルール” →(次の章で出る)Aggregate Root 側に寄せる🏯

「不変条件」ってなに?🧠✨

絶対に破っちゃダメなルールだよ〜!💡 例:カフェ注文なら☕🧾

  • 支払い済みの注文は、明細を変更できない💳🚫
  • 明細が0件の注文は、確定できない🧾❌
  • 確定前しかキャンセルできない(方針次第)🧯

ポイントはこれ👇 「守る場所」がバラけると、いつか絶対破られる😇💥 だから、Entityが自分で守るのが基本なの。


2026/02時点の“最新寄り”メモ📌🆕

  • TypeScriptは 5.9 系が現行の安定版として参照できるよ(公式Release Notes)🟦✨ (TypeScript)
  • Node.js には組み込みテストランナーがあって、Node 20で stable 扱いになってるよ🧪✨ (Node.js)
  • ただし --watch は公式API docs上は “Experimental” 表記もあるので、watch前提の説明は控えめが安全🙈 (Node.js)
  • テストツールなら Vitest も継続的に更新されてる(4.0.xやbetaが動いてる)⚡ (GitHub)
  • Nodeの現行・LTSの状況は定期的に変わるので、運用やCIではLTS中心が無難だよ🧡 (endoflife.date)

ありがちな事故パターン😵‍💫💥

❌ ダメ例:setterで自由に書き換え

  • どこからでも order.status = "Paid" とかできちゃう
  • その瞬間、ルールが崩壊する💣

✅ いい例:状態変更はメソッドだけ

  • pay() の中で「支払い可能か?」をチェック
  • OKなら状態を進める
  • NGなら例外(もしくはResult)で止める🧯

実装して体感しよ☕🧍🔒(Order Entity例)

ここでは「Entityの不変条件を守る型」を作るよ✨ (VOは前章までの知識で、最低限だけ添えるね💎)

// domain/errors.ts
export class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = "DomainError";
}
}
// domain/order/OrderId.ts
import { DomainError } from "../errors";

export class OrderId {
private constructor(private readonly value: string) {}

static create(value: string): OrderId {
if (!value || value.trim().length === 0) {
throw new DomainError("OrderId は空にできません");
}
return new OrderId(value);
}

equals(other: OrderId): boolean {
return this.value === other.value;
}

toString(): string {
return this.value;
}
}

✅ Entity本体:不変条件は「中」で守る🛡️

// domain/order/Order.ts
import { DomainError } from "../errors";
import { OrderId } from "./OrderId";

export type OrderStatus = "Draft" | "Confirmed" | "Paid" | "Cancelled";

export type OrderLine = Readonly<{
menuItemId: string;
quantity: number;
unitPriceYen: number;
}>;

export class Order {
// ✅ ランタイムでも守りたいなら #private もアリ(TSでもOK)
#status: OrderStatus;
#lines: OrderLine[];

private constructor(
private readonly id: OrderId,
status: OrderStatus,
lines: OrderLine[],
) {
this.#status = status;
this.#lines = lines;

// ✅ 生成時点で守るルール(初期整合性)
this.assertValidLines(lines);
}

// ✅ 生成の入口を1つにする(ルールを散らさない)
static createNew(id: OrderId): Order {
return new Order(id, "Draft", []);
}

getId(): OrderId {
return this.id;
}

getStatus(): OrderStatus {
return this.#status;
}

// ✅ 外に配列を渡すと「外からpush」されるのでコピーして返す
getLines(): ReadonlyArray<OrderLine> {
return [...this.#lines];
}

// --------------------------
// ✅ ここから “振る舞い” API
// --------------------------

addItem(menuItemId: string, quantity: number, unitPriceYen: number): void {
this.assertModifiable("明細追加");
this.assertPositiveInt(quantity, "quantity");
this.assertPositiveInt(unitPriceYen, "unitPriceYen");

const line: OrderLine = { menuItemId, quantity, unitPriceYen };
this.#lines = [...this.#lines, line];
}

confirm(): void {
this.assertModifiable("注文確定");

if (this.#lines.length === 0) {
throw new DomainError("明細が0件の注文は確定できません");
}
this.#status = "Confirmed";
}

pay(): void {
if (this.#status !== "Confirmed") {
throw new DomainError("支払いは Confirmed の注文だけ可能です");
}
this.#status = "Paid";
}

cancel(): void {
// 例:Draft / Confirmed ならOK、PaidならNG とする
if (this.#status === "Paid") {
throw new DomainError("支払い済みの注文はキャンセルできません");
}
this.#status = "Cancelled";
}

// --------------------------
// ✅ ガード(不変条件を守る関所)
// --------------------------

private assertModifiable(action: string): void {
if (this.#status === "Paid") {
throw new DomainError(`${action} は支払い済みの注文ではできません`);
}
if (this.#status === "Cancelled") {
throw new DomainError(`${action} はキャンセル済みの注文ではできません`);
}
}

private assertValidLines(lines: OrderLine[]): void {
for (const l of lines) {
this.assertPositiveInt(l.quantity, "quantity");
this.assertPositiveInt(l.unitPriceYen, "unitPriceYen");
}
}

private assertPositiveInt(value: number, name: string): void {
if (!Number.isInteger(value) || value <= 0) {
throw new DomainError(`${name} は正の整数である必要があります`);
}
}
}

ここが「Entityが自衛できる」ポイント🛡️✨

  • #status を直接触れない → 状態を勝手に変えられない🔒
  • confirm() / pay() の中でチェック → 状態遷移の不正をブロック🚫
  • getLines() がコピー → 外から配列を書き換えられない🧊
  • assertModifiable() みたいなガードで、ルールを1箇所に寄せる📍

テストで「破れない」を確認しよ🧪✨(Node組み込みテスト)

Nodeの組み込みテストランナーは Node 20 で stable 扱いになってるよ〜🧪✨ (Node.js)

// test/Order.test.ts
import test from "node:test";
import assert from "node:assert/strict";

import { OrderId } from "../domain/order/OrderId";
import { Order } from "../domain/order/Order";

test("明細0件では confirm() できない", () => {
const order = Order.createNew(OrderId.create("order-1"));
assert.throws(() => order.confirm());
});

test("Paid になったら addItem() できない", () => {
const order = Order.createNew(OrderId.create("order-2"));
order.addItem("coffee", 1, 500);
order.confirm();
order.pay();

assert.equal(order.getStatus(), "Paid");
assert.throws(() => order.addItem("cake", 1, 400));
});

test("pay() は Confirmed のときだけ", () => {
const order = Order.createNew(OrderId.create("order-3"));
order.addItem("coffee", 1, 500);

assert.throws(() => order.pay()); // DraftなのでNG
order.confirm();
assert.doesNotThrow(() => order.pay()); // ConfirmedならOK
});

--watch は公式ドキュメント上 “Experimental” 表記があるので、watch運用の話は一旦置いてOKだよ🙈 (Node.js)


「どのルールをEntityに入れる?」判断ミニチャート🗺️✨

Rule Placement Chart

✅ Entityに入れてOK

  • そのEntity単体で完結して守れる
  • 状態(status)と一緒に守るのが自然
  • 例:Paidなら変更不可quantityは正の整数

⚠️ Entity単体に入れない(次のAggregateでまとめる候補)

  • 明細合計と支払い金額の整合みたいに、複数要素をまとめて守りたい
  • 一緒に変更されるべき範囲が広い
  • 例:Order全体の合計は明細の合計と一致(OrderがRootになりそう)

🚫 Entityに入れない(アプリ層 or ドメインサービス)

  • DBの都合、APIの都合、UI都合
  • 外部連携やI/Oが絡む
  • 例:決済APIを呼ぶ在庫テーブルを見る

AI(Copilot/Codex)を“良い補助輪”にするコツ🤖🚲✨

「コード書いて」より、ルールを守る設計を固める質問が強いよ〜!

使えるプロンプト例🍓

  • 「Orderの状態遷移(Draft/Confirmed/Paid/Cancelled)で、禁止される操作を表にして」
  • 「上の禁止表を満たすように、assertModifiable() みたいなガード関数案を提案して」
  • 「confirm/pay/cancel の異常系テストケースを Given/When/Then で10個出して」
  • 「外部に漏れると危険な参照(配列・オブジェクト)を、TypeScriptで安全に返す方法を列挙して」

👉 AIが出したものは、そのまま採用じゃなくて **「ルールが1箇所に集まってる?」**だけ目でチェックしてね👀🔒


ミニ演習🎓✨(手を動かすやつ!)

演習1:ルール追加🧩

「Cancelled の注文は confirm/pay/addItem できない」を強化してみてね🚫

  • いまは assertModifiable() が止めてくれるけど、メッセージを整えるなど改善もできるよ🧡

演習2:仕様変更(ありがち)🌀

「Confirmed になったら明細変更できない」に変えてみよ!

  • assertModifiable() の条件に Confirmed を追加するだけでOK?
  • pay() はどうなる?🤔 こういう「仕様変更のしやすさ」がDDDの旨みだよ🍯✨

演習3:安全な公開🧊

getLines() が本当に安全か確認してね

  • 「返した配列をいじっても内部が変わらない」テストを書いてみよう🧪

まとめ🌸

  • Entityの不変条件は Entity自身が守るのが基本🔒
  • 入口は create()、更新は 意図のあるメソッドだけ🎮
  • private / #private とガード節で、破れない設計が作れる🛡️
  • テストで「破れない」を固定化すると、未来の自分が助かる🧪✨
  • 複数要素にまたがる大きい不変条件は、次の Aggregate Root でさらに強く守れる🏯💪

次の第44章では、この設計を土台にして Order(注文)Entityのたたき台を作って、モデルの芯を立てていくよ〜☕🧾✨