Skip to main content

第52章:Aggregate Root:外部の入口は1つ🚪👑

  • 不変条件(守るべきルール) が壊れない🎉

1) 今日のゴール🎯✨

この章のゴールはこれ!

  • 🧠 「なんで入口を1つにするの?」が腹落ちする
  • 🏗️ TypeScriptで「外から壊せないOrder集約」を実装できる
  • 🧪 テストで「不正操作ができない」ことを確認できる

2) 入口が1つじゃないと何が起きるの?😱🧨

例:Order(注文)が集約だとして…

  • Orderには、明細(OrderLine)がぶら下がってるよね🧾
  • ここで、外部がOrderLineを直接いじれると…

💥事故のパターン(ありがち)

  • 🧾 明細を勝手に追加 → 合計金額の整合が崩れる
  • 🚦 支払い済みなのに、明細を勝手に変更 → 仕様違反
  • 🔒 「同じ商品は1行にまとめる」ルールを無視して二重行が増える

つまり… 「集約で守るはずのルール」が、入口が複数あるせいで守れなくなるの🥲


3) “やっちゃダメ設計”の例🚫😂

「Orderの中身を外に丸見えで渡しちゃう」パターンね。

// ❌ ダメな例:外部が lines を直接いじれる
class OrderBad {
public lines: { menuItemId: string; quantity: number; unitPrice: number }[] = [];
}

// 外部コード
const order = new OrderBad();
order.lines.push({ menuItemId: "coffee", quantity: 1, unitPrice: 500 }); // 勝手に追加できる😱
order.lines[0].quantity = 999; // 勝手に変更できる😱

これだと、Orderが「守りたいルール」を持ってても、横から殴られて終わりです🫠


4) Aggregate Rootのコツ🧠🔑(ここが本題!)

✅ ルール1:外部に公開する“窓口”はRootだけ🚪👑

  • 外から触れるのは Order のメソッドだけ
  • OrderLine は外から生成・変更させない(or させにくくする)

✅ ルール2:配列やオブジェクトを“生で返さない”🧤

  • lines をそのまま返すと、外で push() される
  • 返すなら readonlyコピー を返す

✅ ルール3:変更は「意図のあるメソッド」でだけ許可🕹️

  • setStatus() とか line.quantity = ... を禁止寄りにして
  • addItem() / changeQuantity() / removeItem() / pay() みたいにする✨

5) 実装してみよう:Order集約(Rootが門番する)🏯🛡️

ここでは Order = Aggregate RootOrderLine = 集約内部の値(VO寄り) で作るよ🧡 (OrderLineをEntityにする場合でも「外部から直接触らせない」は同じ!)

5-1) 下ごしらえ:ドメイン用のエラー🧯

export class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = "DomainError";
}
}

5-2) 最小VOたち(Quantity / Money)💎

import { DomainError } from "./DomainError";

export class Quantity {
private constructor(private readonly value: number) {}

static of(value: number): Quantity {
if (!Number.isInteger(value)) throw new DomainError("数量は整数でね🧾");
if (value <= 0) throw new DomainError("数量は1以上だよ🧾");
if (value > 99) throw new DomainError("数量が多すぎるよ🧾");
return new Quantity(value);
}

get asNumber(): number {
return this.value;
}
}

export class Money {
private constructor(private readonly yen: number) {}

static yen(value: number): Money {
if (!Number.isInteger(value)) throw new DomainError("金額は整数(円)でね💴");
if (value < 0) throw new DomainError("金額がマイナスはNG💴");
return new Money(value);
}

add(other: Money): Money {
return Money.yen(this.yen + other.yen);
}

multiply(n: number): Money {
return Money.yen(this.yen * n);
}

get asYen(): number {
return this.yen;
}
}

5-3) OrderLine(集約内部の“変更不可パーツ”)🧾🧊

ポイント:外から quantity を書き換えられないようにするよ✨

import { Money, Quantity } from "./ValueObjects";

export class OrderLine {
private constructor(
public readonly menuItemId: string,
public readonly unitPrice: Money,
public readonly quantity: Quantity
) {}

static create(menuItemId: string, unitPrice: Money, quantity: Quantity): OrderLine {
return new OrderLine(menuItemId, unitPrice, quantity);
}

withQuantity(quantity: Quantity): OrderLine {
// ✅ “変更”じゃなくて“新しいのを作る”🧊
return new OrderLine(this.menuItemId, this.unitPrice, quantity);
}

subtotal(): Money {
return this.unitPrice.multiply(this.quantity.asNumber);
}
}

5-4) Order(Aggregate Root)🚪👑

ここが超重要!🌟

  • #linesprivate(外から触れない)
  • linesReadonlyArray として返す
  • 明細変更は Orderのメソッド経由 だけ
import { DomainError } from "./DomainError";
import { Money, Quantity } from "./ValueObjects";
import { OrderLine } from "./OrderLine";

type OrderStatus = "draft" | "confirmed" | "paid" | "cancelled";

export class Order {
readonly id: string;
#status: OrderStatus;
#lines: OrderLine[];

private constructor(id: string) {
this.id = id;
this.#status = "draft";
this.#lines = [];
}

static start(id: string): Order {
return new Order(id);
}

// ✅ 外部に公開するのは「読み取り用」だけ
get status(): OrderStatus {
return this.#status;
}

// ✅ 配列は「readonly」として渡す(pushできない!)
get lines(): ReadonlyArray<OrderLine> {
return this.#lines.slice(); // コピーを返すのが安心🧤
}

total(): Money {
return this.#lines.reduce((acc, line) => acc.add(line.subtotal()), Money.yen(0));
}

addItem(menuItemId: string, unitPriceYen: number, quantityValue: number): void {
this.#assertEditable();

const unitPrice = Money.yen(unitPriceYen);
const quantity = Quantity.of(quantityValue);

const idx = this.#lines.findIndex(l => l.menuItemId === menuItemId);

if (idx >= 0) {
// ✅ 同じ商品は1行にまとめる(例ルール)
const current = this.#lines[idx];
const newQty = Quantity.of(current.quantity.asNumber + quantity.asNumber);
this.#lines[idx] = current.withQuantity(newQty);
return;
}

this.#lines.push(OrderLine.create(menuItemId, unitPrice, quantity));
}

changeQuantity(menuItemId: string, quantityValue: number): void {
this.#assertEditable();

const quantity = Quantity.of(quantityValue);
const idx = this.#lines.findIndex(l => l.menuItemId === menuItemId);
if (idx < 0) throw new DomainError("その商品は明細にないよ🧾");

this.#lines[idx] = this.#lines[idx].withQuantity(quantity);
}

removeItem(menuItemId: string): void {
this.#assertEditable();

const before = this.#lines.length;
this.#lines = this.#lines.filter(l => l.menuItemId !== menuItemId);
if (this.#lines.length === before) throw new DomainError("その商品は明細にないよ🧾");
}

confirm(): void {
if (this.#status !== "draft") throw new DomainError("確定できるのは下書きだけだよ🚦");
if (this.#lines.length === 0) throw new DomainError("明細が空だと確定できないよ🧾");
this.#status = "confirmed";
}

pay(): void {
if (this.#status !== "confirmed") throw new DomainError("支払いできるのは確定後だけ💳");
this.#status = "paid";
}

cancel(): void {
if (this.#status === "paid") throw new DomainError("支払い後はキャンセル不可だよ🔒");
this.#status = "cancelled";
}

#assertEditable(): void {
if (this.#status === "paid") throw new DomainError("支払い後は明細を変更できないよ🔒");
if (this.#status === "cancelled") throw new DomainError("キャンセル後は触れないよ🧯");
}
}

6) “入口が1つ”って、実際どこが嬉しいの?🎁✨

✅ 不変条件が1箇所で守れる🔒

「支払い後は明細変更不可」みたいなルールを Orderの中だけで守ればOKになる👍

✅ 変更のルールが読みやすい📖

外から見えるのが

  • addItem
  • changeQuantity
  • confirm
  • pay

みたいな「意図のあるメソッド」だけになるから、 コードが仕様書っぽく読めるの🧡

✅ テストが強くなる🧪

「入口が1つ」=「テストすべき場所が少ない」🎉


7) テスト(Vitestで確認する想定)🧪✨

最近のVitestは v4.0 が出ていて、v4.1 はベータが動いてるよ〜!(vitest.dev) (この教材の方針でも、ドメインはサクッと速いテストが相性いい😊)

7-1) “支払い後は変更不可”をテストする🔒

import { describe, it, expect } from "vitest";
import { Order } from "../domain/Order";

describe("Order Aggregate Root", () => {
it("支払い後は明細変更できない🔒", () => {
const order = Order.start("order-1");
order.addItem("coffee", 500, 1);
order.confirm();
order.pay();

expect(() => order.addItem("tea", 400, 1)).toThrow();
expect(() => order.changeQuantity("coffee", 2)).toThrow();
expect(() => order.removeItem("coffee")).toThrow();
});

it("同じ商品は1行にまとまる🧾", () => {
const order = Order.start("order-2");
order.addItem("coffee", 500, 1);
order.addItem("coffee", 500, 2);

expect(order.lines.length).toBe(1);
expect(order.lines[0].quantity.asNumber).toBe(3);
});
});

8) よくある落とし穴あるある😂⚠️

落とし穴1:get lines() で生配列を返す🍋

  • return this.#lines; ← これやると外から壊される💥
  • slice() / readonly / DTO化 が安全🧤

落とし穴2:OrderLineを外から new できちゃう🍋

  • constructorprivate にして create() 経由にするのが強い✨

落とし穴3:「Rootなのにsetter祭り」🍋

  • setStatus() とか setLines() があると、結局入口が増える😵‍💫
  • ✅ “意図のあるメソッド” だけを公開しよ〜!

9) ミニ演習🎮✨(手を動かすと一気に身につく!)

演習A:割引を入れてみよ🏷️

仕様:

  • applyDiscountPercent(%) をOrderに追加(0〜30%)
  • 支払い後は適用できない🔒
  • 合計の整合性が崩れないようにする

ヒント💡

  • 「割引後合計」をOrderが計算して返す
  • 明細のunitPriceを直接いじるより「計算の結果」を返す方が安全なこと多いよ😊

演習B:外からOrderLineを壊せないことを確認👀

  • order.lines.push(...) を書いてみて(型エラーになるのが理想✨)
  • もしpushできたら、lines の返し方を見直そう🧤

10) AIの使い方(この章向け)🤖💞

使い方1:公開メソッドの候補を出させる🧠

お願い例👇

  • 「Orderが守る不変条件はこれで、外部から触りたい操作はこれ。公開メソッド案を出して、危ないAPIを指摘して」

使い方2:“漏れてる入口”を検出させる🔍

お願い例👇

  • 「このOrder実装で、外部から集約内部を破壊できる可能性がある箇所を列挙して。特に配列・参照渡し・setterに注目して」

11) 2026のTypeScript周りの最新メモ🗞️✨

  • npmの typescriptLatest が 5.9.3 と表示されてるよ(npm)
  • そして TypeScript 6.0 は“橋渡し版”で、TypeScript 7.0(ネイティブ実装)へ繋ぐという方針が公式に語られてるよ(Microsoft for Developers)
  • TypeScript 7のネイティブプレビュー(Go移植)も案内されていて、大規模コードでの高速化が期待されてる✨(公式ブログでも触れられてるよ)(Microsoft Developer)

この章の実装(入口を1つにする)は、TypeScriptのバージョンが進んでもずっと強い設計だから安心してOK🧡


まとめ🎀✨(超だいじ!)

  • 🏯 集約は「ルールを守る城」
  • 🚪👑 外部の入口は「Rootだけ」にする
  • 🧤 配列・参照を生で渡さない(readonly / copy)
  • 🧊 内部パーツは不変に寄せるとさらに安全
  • 🧪 テストは「仕様違反が起きない」を守る

次の第53章は「集約境界の決め方(不変条件ドリブン)🔒」だよね! 第52章で作った“城の入口”があると、境界の議論がめちゃやりやすくなるよ〜!🎉