Skip to main content

第74章:Factory入門:生成が複雑なら任せる🏭

まず、2026年2月時点の軽い近況メモだけ📌 npmの typescript パッケージ上での最新は 5.9.3 です(この時点の安定版の目安として見てOK)(NPM) それとは別に、**TypeScript 6.0(ブリッジ)→ TypeScript 7.0(ネイティブ系の新ツールチェーン)**へ進む流れが公式ブログで語られています(Microsoft for Developers) なので「今ふつうに書くTS」は引き続き安定しつつ、裏では速度やツールチェーンが大きく進化中…って感じです🚀


この章でやること🎯🏭

Factoryを一言でいうと、

ドメインの“生成(はじまり)”を担当する専門家」🏭✨

です。

注文(Order)みたいな大きめのドメインオブジェクトは、作る瞬間にやることが増えがち👇

  • IDを作る🆔
  • 初期状態を決める🚦(Draft?Confirmed?)
  • 明細(Line)をVOで作る🧾
  • 合計金額を整合させる💴
  • 作成時刻を入れる⏰
  • 「空注文は禁止」などのルールを守る🔒

これを アプリ層(UseCase)にベタ書きすると、すぐに太って事故ります😵‍💫


生成がつらくなる “あるある” 😇⚠️

次のどれかが出てきたら、Factoryの出番です🏭✨

  • new Order(...) に渡す引数が多い(5個超えたら黄色信号🚥)
  • UseCaseで「ID生成」「初期値セット」「合計計算」などが散ってる
  • 生成パターンが増えてきた(通常注文 / 事前注文 / 無料注文…)
  • テストで「注文を作るだけで疲れる」🧪💦
  • 生成手順がコピペされ始めた📋

悪い例:UseCaseが“生成の手順書”になってる😵‍💫🧾

「PlaceOrder」の中で、生成に必要なことを全部やってるパターン👇 (※雰囲気の例。こうなりがちって話!)

// app/placeOrder.ts(イメージ)
const id = crypto.randomUUID();
const status = "Confirmed";
const lines = input.items.map(i => ({
menuItemId: i.menuItemId,
quantity: i.quantity,
unitPrice: i.unitPrice,
subtotal: i.quantity * i.unitPrice,
}));
const total = lines.reduce((sum, x) => sum + x.subtotal, 0);

const order = new Order({
id,
status,
lines,
total,
createdAt: new Date(),
});

await orderRepo.save(order);

問題点はこれ👇😵‍💫

  • ルールが散らばる(空注文禁止とか、合計計算の正しさとか)
  • 型が弱い(menuItemId/quantity/price が全部ただの number/string)
  • 生成の“正しい形”が どこにも固定されない🔧

正しい方向:Factoryに「生成の責務」を集める🏭✨

Factoryがやることはシンプル👇

生成の複雑さを1か所に集める初期状態・初期値・生成ルールを守る ✅ UseCaseは「やりたいことの流れ」だけを書く🎬


実装例:OrderFactoryを作る🏭☕🧾

1) ドメイン側:最低限の土台(例)🧱✨

ドメイン例外(ルール違反はここで止める)🧯

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

OrderStatus(typo防止に satisfies も使えるよ🧠)

... satisfies ... は「型を変えずに、型制約だけチェックできる」演算子です(typescriptlang.org)

// domain/order/orderStatus.ts
export type OrderStatus = "Draft" | "Confirmed" | "Paid" | "Fulfilled" | "Cancelled";

export const OrderStatuses = {
Draft: "Draft",
Confirmed: "Confirmed",
Paid: "Paid",
Fulfilled: "Fulfilled",
Cancelled: "Cancelled",
} as const satisfies Record<string, OrderStatus>;

Value Objectたち(最小だけ)💎

// domain/order/orderId.ts
import { DomainError } from "../shared/domainError";

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

static fromString(value: string): OrderId {
if (value.trim().length === 0) throw new DomainError("OrderId is empty");
return new OrderId(value);
}
}
// domain/order/quantity.ts
import { DomainError } from "../shared/domainError";

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

static of(value: number): Quantity {
if (!Number.isInteger(value)) throw new DomainError("Quantity must be integer");
if (value <= 0) throw new DomainError("Quantity must be >= 1");
return new Quantity(value);
}
}
// domain/order/money.ts
import { DomainError } from "../shared/domainError";

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

static yenOf(value: number): Money {
if (!Number.isInteger(value)) throw new DomainError("Money must be integer yen");
if (value < 0) throw new DomainError("Money must be >= 0");
return new Money(value);
}

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

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

注文明細(OrderLine)🧾

// domain/order/orderLine.ts
import { Money } from "./money";
import { Quantity } from "./quantity";
import { DomainError } from "../shared/domainError";

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

static create(params: { menuItemId: string; quantity: Quantity; unitPrice: Money }): OrderLine {
if (params.menuItemId.trim().length === 0) throw new DomainError("menuItemId is empty");
return new OrderLine(params.menuItemId, params.quantity, params.unitPrice);
}

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

Order集約(生成は “正しい初期状態” を守る)🏯🛡️

// domain/order/order.ts
import { DomainError } from "../shared/domainError";
import { Money } from "./money";
import { OrderId } from "./orderId";
import { OrderLine } from "./orderLine";
import { OrderStatus, OrderStatuses } from "./orderStatus";

type OrderProps = {
id: OrderId;
status: OrderStatus;
lines: readonly OrderLine[];
total: Money;
createdAt: Date;
};

export class Order {
private constructor(private readonly props: OrderProps) {}

// ✅ Factoryから呼ぶ「新規注文の生成口」
static createConfirmed(params: { id: OrderId; lines: readonly OrderLine[]; now: Date }): Order {
if (params.lines.length === 0) throw new DomainError("Order must have at least 1 line");

const total = params.lines
.map(l => l.subtotal())
.reduce((acc, x) => acc.add(x), Money.yenOf(0));

return new Order({
id: params.id,
status: OrderStatuses.Confirmed,
lines: [...params.lines], // 外から渡された配列をそのまま持たない✨
total,
createdAt: params.now,
});
}

// 参照だけ(外に安全に出す)
get id(): OrderId { return this.props.id; }
get status(): OrderStatus { return this.props.status; }
get total(): Money { return this.props.total; }
get createdAt(): Date { return this.props.createdAt; }
get lines(): readonly OrderLine[] { return this.props.lines; }
}

2) Factory本体:生成手順をここに集める🏭✨

ここがこの章の主役だよ〜!🎉

// domain/order/orderFactory.ts
import { Order } from "./order";
import { OrderId } from "./orderId";
import { OrderLine } from "./orderLine";
import { Money } from "./money";
import { Quantity } from "./quantity";

export interface OrderIdGenerator {
next(): OrderId;
}

export interface Clock {
now(): Date;
}

// UseCaseから渡されやすい形(プリミティブ)で受けて、ドメインに変換する🏭
export type PlaceOrderInput = {
items: Array<{
menuItemId: string;
quantity: number;
unitPriceYen: number;
}>;
};

export class OrderFactory {
constructor(
private readonly idGen: OrderIdGenerator,
private readonly clock: Clock,
) {}

createConfirmedOrder(input: PlaceOrderInput): Order {
const id = this.idGen.next();
const now = this.clock.now();

const lines = input.items.map(i =>
OrderLine.create({
menuItemId: i.menuItemId,
quantity: Quantity.of(i.quantity),
unitPrice: Money.yenOf(i.unitPriceYen),
})
);

return Order.createConfirmed({ id, lines, now });
}
}

✅ これで「生成の正しい手順」が OrderFactoryに固定されました🏭✨ UseCaseは “やることの流れ” だけに戻せます🎬


3) infra側:ID生成(例:UUID)🆔✨

Node.jsの crypto.randomUUID() は RFC 4122 v4 のUUIDを生成します(nodejs.org)

// infra/order/cryptoOrderIdGenerator.ts
import { randomUUID } from "node:crypto";
import { OrderId } from "../../domain/order/orderId";
import { OrderIdGenerator } from "../../domain/order/orderFactory";

export class CryptoOrderIdGenerator implements OrderIdGenerator {
next(): OrderId {
return OrderId.fromString(randomUUID());
}
}

4) UseCase:スッキリ版🎬✨

// app/placeOrder.ts
import { OrderFactory, PlaceOrderInput } from "../domain/order/orderFactory";

export class PlaceOrder {
constructor(
private readonly orderFactory: OrderFactory,
private readonly orderRepo: { save(order: unknown): Promise<void> },
) {}

async execute(input: PlaceOrderInput): Promise<void> {
const order = this.orderFactory.createConfirmedOrder(input);
await this.orderRepo.save(order);
}
}

UseCaseが「手順書」じゃなくて「物語」になった〜!🎉📖✨


テスト:Factoryはめっちゃテストしやすい🧪💖

「ID」と「現在時刻」を差し替えできるから、テストが安定します✨

// test/orderFactory.test.ts
import { describe, it, expect } from "vitest";
import { OrderFactory } from "../src/domain/order/orderFactory";
import { OrderId } from "../src/domain/order/orderId";

describe("OrderFactory", () => {
it("注文をConfirmedで生成し、合計を正しく計算する💴", () => {
const factory = new OrderFactory(
{ next: () => OrderId.fromString("order-001") },
{ now: () => new Date("2026-02-07T10:00:00Z") },
);

const order = factory.createConfirmedOrder({
items: [
{ menuItemId: "latte", quantity: 2, unitPriceYen: 500 }, // 1000
{ menuItemId: "cookie", quantity: 1, unitPriceYen: 300 }, // 300
],
});

expect(order.id.value).toBe("order-001");
expect(order.status).toBe("Confirmed");
expect(order.total.yen).toBe(1300);
});

it("空の注文は作れない🚫", () => {
const factory = new OrderFactory(
{ next: () => OrderId.fromString("order-002") },
{ now: () => new Date("2026-02-07T10:00:00Z") },
);

expect(() => factory.createConfirmedOrder({ items: [] })).toThrow();
});
});

よくある落とし穴まとめ😂⚠️

  • Factoryが何でも屋になる → 「生成だけ」に集中!保存(Repository)は混ぜない🙅‍♀️
  • UseCaseがFactoryを使わず、直接newし始める → 「生成口」をFactoryに寄せる🏭✨
  • 配列やオブジェクトをそのまま保持して外から書き換えられるlines: [...lines] みたいにコピーして守る🛡️
  • VOにしないで number/string のまま突っ込む → FactoryでVO化して、ルールを閉じ込める💎

AIに手伝ってもらうなら(使えるプロンプト集)🤖💬✨

  • 「PlaceOrderのUseCaseが太いので、生成に関する処理だけをFactoryに抽出して。生成手順 / 依存 / テスト観点 も出して」
  • 「このOrder生成で、守るべき不変条件をリスト化して。どこで守るべきかも提案して」
  • 「OrderFactoryが肥大化しないための責務境界チェックリストを10個作って」

まとめ🎁✨

  • Factoryは「生成が複雑になった時の避難先」じゃなくて、 「生成こそ仕様」だから最初から置く価値がある🏭✨
  • UseCaseは“流れ”、Factoryは“生成”、ドメインは“ルール”💖
  • IDや時刻みたいな「ゆらぐもの」は差し替えできる形にするとテストが強くなる🧪🛡️

次の章(第75章)で、このFactoryを static factoryFactory class でどう使い分けるか、もっと気持ちよく整理していこ〜!⚖️✨