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

第06章:ドメインモデル超入門(Entity/VO)🏠📚

この章のゴール🎯✨

  • 「Entity(エンティティ)🆔」と「Value Object(値オブジェクト)💎」の違いを、ミニECの例で説明できる
  • 「イベントが生まれる場所(=ドメインの中心)🔥」が、どんな“モデル”なのかイメージできる
  • 「金額💰」「住所🏠」みたいな“値”を、いい感じに安全に扱えるようになる(=バグ減る!🙌)

6.1 まずは“登場人物”をそろえよう🎭🛒

ミニECでよく出てくるのはこんな子たち👇

  • 注文(Order)🧾
  • 注文ID(OrderId)🆔
  • 注文の明細(LineItem)📦
  • 金額(Money)💰
  • 住所(Address)🏠
  • 数量(Quantity)🔢

ここで大事なのは… **「IDで追いかける存在」なのか、「値そのものが大事な存在」**なのか、ってところだよ〜🙂✨


6.2 Entity と Value Object の見分け方🆔💎(超重要!)

Entity(同一性)と Value Object(値そのもの)の違い

Entity(エンティティ)🆔

  • 同一性(Identity)がある

    • たとえば「注文」は、内容が少し変わっても同じ注文だよね?🧾➡️🧾
  • つまり、基本は IDで同一判定することが多い👍

Value Object(値オブジェクト)💎

  • 同一性はなく、値がすべて

    • 「1000円」は、どこで出てきても“1000円”だよね💰
  • 基本は **値で同一判定(等価)**する👍

  • そしてだいたい 不変(immutable) にするのが気持ちいい✨(勝手に変わらない安心感🫶)


6.3 “VOにすると嬉しい”の正体💖

VOにすると、何がうれしいの?っていうと👇

  • バグが減る(文字列のまま雑に扱わない)

  • ルール(不変条件)を閉じ込められる🔒

    • 「金額は0以上」みたいなルールを、VOの中に入れられる
  • 読みやすい(型で意味が見える)👀✨

  • イベントにも乗せやすい📣(必要な値を安全に渡せる)


6.4 実装してみよう(ミニEC)🧩✨

フォルダ例🗂️

  • src/domain/order/ … 注文まわり🧾
  • src/domain/shared/ … 共有VO(Moneyなど)💰

6.5 Value Object:Money 💰(浮動小数の罠を避ける🕳️)

お金は number の小数で持つと事故りやすいよ〜😵‍💫 なので、最小単位(円なら円、ドルならセント)で整数管理が定番👏

// src/domain/shared/Money.ts
export class Money {
private constructor(private readonly yenAmount: number) {
if (!Number.isInteger(yenAmount)) {
throw new Error("Moneyは整数(円単位)で持ってね🪙");
}
if (yenAmount < 0) {
throw new Error("Moneyは0以上だよ💰✨");
}
}

static yen(amount: number): Money {
return new Money(amount);
}

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

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

multiply(quantity: number): Money {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error("数量は1以上の整数だよ🔢");
}
return new Money(this.yenAmount * quantity);
}

equals(other: Money): boolean {
return this.yenAmount === other.yenAmount;
}

toString(): string {
return `${this.yenAmount.toLocaleString("ja-JP")}`;
}

// イベントpayload用に“素の値”へ変換したいときに便利🧳
toJSON(): { yen: number } {
return { yen: this.yenAmount };
}
}

💡ポイント

  • constructorをprivateにして、Money.yen() からしか作れないようにしてるよ🔒
  • “変な状態”を入口で弾くのがコツ✅

6.6 Value Object:Address 🏠(住所は「ひとまとまりの値」)

住所って、city とか postalCode とかパーツはあるけど… ドメイン的には “住所”という1つの値だよね🏠✨

// src/domain/shared/Address.ts
export class Address {
constructor(
public readonly postalCode: string,
public readonly prefecture: string,
public readonly city: string,
public readonly line1: string,
public readonly line2?: string
) {
if (!postalCode.match(/^\d{3}-\d{4}$/)) {
throw new Error("郵便番号は 123-4567 形式だよ📮");
}
if (!prefecture || !city || !line1) {
throw new Error("住所の必須項目が空だよ🏠💦");
}
}

equals(other: Address): boolean {
return (
this.postalCode === other.postalCode &&
this.prefecture === other.prefecture &&
this.city === other.city &&
this.line1 === other.line1 &&
this.line2 === other.line2
);
}

toString(): string {
return `${this.postalCode} ${this.prefecture}${this.city}${this.line1}${this.line2 ?? ""}`;
}

toJSON(): {
postalCode: string;
prefecture: string;
city: string;
line1: string;
line2?: string;
} {
return {
postalCode: this.postalCode,
prefecture: this.prefecture,
city: this.city,
line1: this.line1,
line2: this.line2,
};
}
}

6.7 Entity:Order 🧾(IDで追いかける存在)

注文は「同じ注文」として追跡したいから Entity だよ〜🆔✨

まず、注文IDも“意味のある値”だから、雑な string のままにしないのが気持ちいい🫶 (OrderId を VO っぽく扱う感じ✨)

// src/domain/order/OrderId.ts
export type OrderId = string & { readonly __brand: "OrderId" };

export const OrderId = {
new(): OrderId {
// Node/ブラウザの crypto.randomUUID() を使うよ🧬
return crypto.randomUUID() as OrderId;
},

from(value: string): OrderId {
if (!value || value.length < 10) {
throw new Error("OrderIdが短すぎるよ🆔💦");
}
return value as OrderId;
},
} as const;

次に明細(LineItem)📦 これは「値の組み合わせ」なので VO にしやすいよ👍

// src/domain/order/LineItem.ts
import { Money } from "../shared/Money";

export class LineItem {
constructor(
public readonly productId: string,
public readonly unitPrice: Money,
public readonly quantity: number
) {
if (!productId) throw new Error("productIdが空だよ📦");
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error("quantityは1以上の整数だよ🔢");
}
}

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

equals(other: LineItem): boolean {
return (
this.productId === other.productId &&
this.unitPrice.equals(other.unitPrice) &&
this.quantity === other.quantity
);
}

toJSON(): { productId: string; unitPriceYen: number; quantity: number } {
return {
productId: this.productId,
unitPriceYen: this.unitPrice.yen,
quantity: this.quantity,
};
}
}

最後に注文本体🧾✨

// src/domain/order/Order.ts
import { OrderId } from "./OrderId";
import { LineItem } from "./LineItem";
import { Money } from "../shared/Money";

type OrderStatus = "Created" | "Paid" | "Shipped";

export class Order {
private status: OrderStatus = "Created";
private readonly items: LineItem[] = [];

constructor(public readonly id: OrderId) {}

addItem(item: LineItem): void {
if (this.status !== "Created") {
throw new Error("支払い後は明細を追加できないよ💳❌");
}
this.items.push(item);
}

total(): Money {
return this.items.reduce((sum, item) => sum.add(item.subtotal()), Money.yen(0));
}

pay(): void {
if (this.status !== "Created") {
throw new Error("二重支払いはできないよ💳💦");
}
if (this.items.length === 0) {
throw new Error("明細ゼロは支払えないよ🧾💦");
}
this.status = "Paid";
// 🔥イベントは次の章以降で「ここで生む」ようになるよ(今は準備だけ)
}

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

// Entityの同一判定はだいたいIDでOK🆔✨
equals(other: Order): boolean {
return this.id === other.id;
}

snapshot(): {
id: string;
status: OrderStatus;
items: ReturnType<LineItem["toJSON"]>[];
totalYen: number;
} {
return {
id: this.id,
status: this.status,
items: this.items.map((x) => x.toJSON()),
totalYen: this.total().yen,
};
}
}

6.8 “Entity/VO”の見分けクイズ🧠💡

次のうち、Entityっぽいのはどれ? VOっぽいのはどれ?(理由もセットで✨)

  1. Order(注文)🧾
  2. Money(金額)💰
  3. Address(住所)🏠
  4. Coupon(クーポン)🎫
  5. EmailAddress(メールアドレス)📧
  6. User(ユーザー)👤

✅ ヒント

  • 「IDで追いかける?」→ Entity 🆔
  • 「値が同じなら同じ?」→ VO 💎

6.9 演習:VOにすると何が嬉しい?📝💖

演習1:VO候補を3つ出す🧺✨

ミニECで VO にしたら良さそうなものを3つ書こう👇 例:Money / Address / EmailAddress / Quantity / PhoneNumber 📞 など

演習2:「文字列のまま」の危険を1つ書く⚠️

例:住所が空でも通っちゃう、金額がマイナスでも通っちゃう、など😵‍💫

演習3:Quantity を VO にしてみよう🔢💎

  • ルール:1以上の整数
  • メソッド:add()toNumber() くらいでOK🙆‍♀️

6.10 テストで安心しよう🧪✨(ミニ例)

Moneyの挙動が壊れてないかチェック✅

// src/domain/shared/Money.test.ts
import { describe, it, expect } from "vitest";
import { Money } from "./Money";

describe("Money", () => {
it("足し算できる💰", () => {
const a = Money.yen(100);
const b = Money.yen(250);
expect(a.add(b).yen).toBe(350);
});

it("マイナスは作れない🚫", () => {
expect(() => Money.yen(-1)).toThrow();
});
});

6.11 AI活用(この章で便利な使い方)🤖🫶

① VO候補を洗い出す🧺

  • 「ミニECで Value Object にした方が良い概念を10個、理由つきで出して。金額・住所以外もお願い🙏」

② ルールを “チェック条件” に落とす🔒

  • 「“数量は1以上の整数”を、TypeScriptのconstructorで守る実装例を3パターン出して🔢✨」

③ Entity/VOの境界で迷ったとき🧭

  • 「この概念は Entity と Value Object のどっちが良い? ‘同一性’ の観点で判断して理由を教えて🙂」

6.12 よくある落とし穴まとめ🕳️💥

  • string が何でも入っちゃって、意味が消える(IDも住所も全部string…)🫥
  • VOが可変で、どこかで勝手に値が変わる😱
  • 金額を小数で持って誤差が出る(0.1 + 0.2系)🫠
  • ルールがあちこちに散らばって、直し忘れる🌀

参考(2026年の“今”の周辺情報)📌