ストーリー
ドメインモデルの隔離とは
ドメインモデルは、ビジネスルールを表現するコードです。これを外部の技術的詳細(DB、HTTP、フレームワーク)から完全に隔離します。
隔離されていない例(悪い例)
// NG: ドメインモデルがPrisma/ORMに依存
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
class Order {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
status: string;
@Column('decimal')
totalAmount: number;
// ORMのデコレータがドメインモデルに侵入している
// テストするにはTypeORMの接続が必要
}
隔離された例(良い例)
// domain/entities/Order.ts
// 外部依存が一切ない純粋なドメインモデル
class Order {
private constructor(
private readonly _id: OrderId,
private readonly _customerId: string,
private _items: OrderItem[],
private _status: OrderStatus,
private readonly _createdAt: Date
) {}
static create(customerId: string, items: OrderItem[]): Order {
if (items.length === 0) {
throw new Error('注文には最低1つの商品が必要です');
}
return new Order(
OrderId.generate(),
customerId,
items,
OrderStatus.PENDING,
new Date()
);
}
get id(): OrderId { return this._id; }
get customerId(): string { return this._customerId; }
get status(): OrderStatus { return this._status; }
get totalAmount(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('JPY')
);
}
confirm(): void {
if (this._status !== OrderStatus.PENDING) {
throw new Error('確認できるのは保留中の注文のみです');
}
this._status = OrderStatus.CONFIRMED;
}
cancel(): void {
if (this._status === OrderStatus.SHIPPED) {
throw new Error('出荷済みの注文はキャンセルできません');
}
this._status = OrderStatus.CANCELLED;
}
addItem(item: OrderItem): void {
if (this._status !== OrderStatus.PENDING) {
throw new Error('保留中の注文にのみ商品を追加できます');
}
this._items.push(item);
}
}
Value Object(値オブジェクト)の隔離
// domain/value-objects/Money.ts
// 金額を表す値オブジェクト -- 外部依存なし
class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: string
) {
if (_amount < 0) {
throw new Error('金額は0以上でなければなりません');
}
}
static of(amount: number, currency: string): Money {
return new Money(amount, currency);
}
static zero(currency: string): Money {
return new Money(0, currency);
}
get amount(): number { return this._amount; }
get currency(): string { return this._currency; }
add(other: Money): Money {
if (this._currency !== other._currency) {
throw new Error('異なる通貨の加算はできません');
}
return new Money(this._amount + other._amount, this._currency);
}
multiply(quantity: number): Money {
return new Money(this._amount * quantity, this._currency);
}
equals(other: Money): boolean {
return this._amount === other._amount && this._currency === other._currency;
}
toCents(): number {
return Math.round(this._amount * 100);
}
}
// domain/value-objects/OrderId.ts
class OrderId {
private constructor(private readonly _value: string) {
if (!_value || _value.trim() === '') {
throw new Error('OrderIdは空にできません');
}
}
static generate(): OrderId {
return new OrderId(crypto.randomUUID());
}
static fromString(value: string): OrderId {
return new OrderId(value);
}
get value(): string { return this._value; }
equals(other: OrderId): boolean {
return this._value === other._value;
}
}
永続化との分離
ドメインモデルとデータベースのスキーマを分離するために、変換メソッドを用意します。
// domain/entities/Order.ts(追加メソッド)
class Order {
// ... 上記のコード
// 永続化用の変換(ドメイン → プリミティブ)
toPersistence(): OrderPersistence {
return {
id: this._id.value,
customerId: this._customerId,
items: this._items.map(item => item.toPersistence()),
status: this._status,
totalAmount: this.totalAmount.amount,
currency: this.totalAmount.currency,
createdAt: this._createdAt,
};
}
// 復元用の変換(プリミティブ → ドメイン)
static fromPersistence(data: OrderPersistence): Order {
return new Order(
OrderId.fromString(data.id),
data.customerId,
data.items.map(OrderItem.fromPersistence),
data.status as OrderStatus,
data.createdAt,
);
}
}
// 永続化用の型定義(ドメインモデルとは別)
interface OrderPersistence {
id: string;
customerId: string;
items: OrderItemPersistence[];
status: string;
totalAmount: number;
currency: string;
createdAt: Date;
}
隔離のルール
| ルール | 説明 |
|---|---|
| 外部ライブラリの排除 | ドメイン層にimportするのはTypeScript標準ライブラリのみ |
| フレームワーク非依存 | デコレータ、アノテーションを使わない |
| ORM非依存 | Prisma, TypeORMなどの型を使わない |
| 自己検証 | バリデーションはドメインモデル内で行う |
| 不変条件の保護 | privateコンストラクタとファクトリメソッドで生成を制御 |
ディレクトリ構造
src/
└── domain/
├── entities/
│ ├── Order.ts # エンティティ(ID + ビジネスロジック)
│ └── OrderItem.ts
├── value-objects/
│ ├── OrderId.ts # 値オブジェクト(不変、自己検証)
│ ├── Money.ts
│ └── OrderStatus.ts
└── ports/
├── in/ # Driving Ports
│ └── CreateOrderUseCase.ts
└── out/ # Driven Ports
├── OrderRepository.ts
└── PaymentGateway.ts
まとめ
| ポイント | 内容 |
|---|---|
| 隔離の意味 | ドメインモデルから外部依存を排除すること |
| Entity | ID + ビジネスロジック、ファクトリメソッドで生成 |
| Value Object | 不変、自己検証、等値比較 |
| 永続化の分離 | toPersistence / fromPersistenceで変換 |
| ルール | 外部ライブラリ、フレームワーク、ORMに依存しない |
チェックリスト
- ドメインモデルの隔離が意味することを説明できる
- 隔離されたEntityを設計できる
- Value Objectを正しく実装できる
- 永続化との分離方法を理解した
- 隔離のルール5項目を把握した
次のステップへ
次は「Port(インターフェース)の設計」を学びます。Driving PortとDriven Portの具体的な設計パターンを身につけましょう。
推定読了時間: 30分