LESSON 30分

ストーリー

高橋アーキテクト
PortとAdapterの概念は掴めたかな?
あなた
はい。でも、六角形の中心にあるドメインモデルは、具体的にどう書けばいいんでしょうか?
高橋アーキテクト
良い質問だ。ドメインモデルは”外部の都合に一切依存しない純粋なビジネスロジック”でなければならない。フレームワークのアノテーションもORMのデコレータも入れない。それが”隔離”の意味だ

ドメインモデルの隔離とは

ドメインモデルは、ビジネスルールを表現するコードです。これを外部の技術的詳細(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

まとめ

ポイント内容
隔離の意味ドメインモデルから外部依存を排除すること
EntityID + ビジネスロジック、ファクトリメソッドで生成
Value Object不変、自己検証、等値比較
永続化の分離toPersistence / fromPersistenceで変換
ルール外部ライブラリ、フレームワーク、ORMに依存しない

チェックリスト

  • ドメインモデルの隔離が意味することを説明できる
  • 隔離されたEntityを設計できる
  • Value Objectを正しく実装できる
  • 永続化との分離方法を理解した
  • 隔離のルール5項目を把握した

次のステップへ

次は「Port(インターフェース)の設計」を学びます。Driving PortとDriven Portの具体的な設計パターンを身につけましょう。


推定読了時間: 30分