LESSON 30分

ストーリー

高橋アーキテクト
ドメインモデルの隔離ができたら、次はPortの設計だ
高橋アーキテクト
Portは”契約”のようなものだ。アプリケーションの内側と外側が、この契約を通じてやり取りする。良い契約は、双方が独立して進化できるようにする

Driving Port(入力ポート)の設計

Driving Portは「アプリケーションが提供する機能」を定義します。Use Case単位でインターフェースを切ります。

設計原則: 1 Use Case = 1 Port

// domain/ports/in/CreateOrderUseCase.ts
interface CreateOrderUseCase {
  execute(command: CreateOrderCommand): Promise<OrderId>;
}

interface CreateOrderCommand {
  customerId: string;
  items: Array<{ productId: string; quantity: number }>;
  shippingAddress: string;
}

// domain/ports/in/CancelOrderUseCase.ts
interface CancelOrderUseCase {
  execute(command: CancelOrderCommand): Promise<void>;
}

interface CancelOrderCommand {
  orderId: string;
  reason: string;
}

// domain/ports/in/GetOrderUseCase.ts
interface GetOrderUseCase {
  execute(query: GetOrderQuery): Promise<OrderDto>;
}

interface GetOrderQuery {
  orderId: string;
}

なぜUse Case単位なのか

// NG: 1つのPortに複数のUse Caseを詰め込む
interface OrderUseCase {
  create(command: CreateOrderCommand): Promise<OrderId>;
  cancel(command: CancelOrderCommand): Promise<void>;
  get(query: GetOrderQuery): Promise<OrderDto>;
  list(query: ListOrdersQuery): Promise<OrderDto[]>;
  update(command: UpdateOrderCommand): Promise<void>;
}
// インターフェース分離の原則(ISP)に違反

// OK: Use Case単位で分離
interface CreateOrderUseCase {
  execute(command: CreateOrderCommand): Promise<OrderId>;
}
// 各Adapterは必要なUse Caseだけに依存できる

Driven Port(出力ポート)の設計

Driven Portは「アプリケーションが必要とする外部機能」を定義します。

Repository Port

// domain/ports/out/OrderRepository.ts
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  delete(id: OrderId): Promise<void>;
  findByCustomerId(customerId: string): Promise<Order[]>;
  existsById(id: OrderId): Promise<boolean>;
}

設計のポイント

// NG: ORMの都合がPortに漏れている
interface OrderRepository {
  findOne(where: { id?: string; status?: string }): Promise<Order>;
  createQueryBuilder(): QueryBuilder; // ORMの具体的なAPIが漏れている
}

// OK: ドメインの言葉でインターフェースを定義
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findPendingOrders(): Promise<Order[]>;        // ドメインの概念
  findByDateRange(from: Date, to: Date): Promise<Order[]>;
}

外部サービスPort

// domain/ports/out/PaymentGateway.ts
// 決済サービスへのPort -- 特定のサービス名を使わない
interface PaymentGateway {
  charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
  refund(paymentId: string, amount: Money): Promise<RefundResult>;
}

// NG: 特定のサービスに依存した命名
interface StripeGateway {
  createPaymentIntent(params: StripeIntentParams): Promise<StripeIntent>;
}

// domain/ports/out/InventoryChecker.ts
interface InventoryChecker {
  checkAvailability(productId: string, quantity: number): Promise<boolean>;
  reserve(productId: string, quantity: number): Promise<ReservationId>;
  release(reservationId: ReservationId): Promise<void>;
}

// domain/ports/out/NotificationSender.ts
interface NotificationSender {
  sendOrderConfirmation(order: Order): Promise<void>;
  sendShippingNotification(order: Order, trackingNumber: string): Promise<void>;
}

Command / Query パターン

Driving Portの入力にはCommandオブジェクトやQueryオブジェクトを使います。

// Command: 状態を変更する操作
interface CreateOrderCommand {
  readonly customerId: string;
  readonly items: ReadonlyArray<{ productId: string; quantity: number }>;
  readonly shippingAddress: string;
}

// Query: 状態を読み取る操作
interface GetOrderQuery {
  readonly orderId: string;
}

interface ListOrdersQuery {
  readonly customerId: string;
  readonly status?: OrderStatus;
  readonly page: number;
  readonly limit: number;
}

// DTO: Use Caseが返す結果
interface OrderDto {
  id: string;
  customerId: string;
  status: string;
  totalAmount: number;
  currency: string;
  items: OrderItemDto[];
  createdAt: string;
}

Portの配置

src/
└── domain/
    └── ports/
        ├── in/                          # Driving Ports
        │   ├── CreateOrderUseCase.ts
        │   ├── CancelOrderUseCase.ts
        │   ├── GetOrderUseCase.ts
        │   └── ListOrdersUseCase.ts
        └── out/                         # Driven Ports
            ├── OrderRepository.ts
            ├── PaymentGateway.ts
            ├── InventoryChecker.ts
            └── NotificationSender.ts

まとめ

ポイント内容
Driving Port1 Use Case = 1 Port、ISP準拠
Driven Portドメインの言葉で定義、技術詳細を含めない
Command/Query入力はCommandまたはQueryオブジェクト
DTO出力はドメインモデルではなくDTOで返す
配置domain/ports/{in,out}/ に配置

チェックリスト

  • Driving Portを1 Use Case単位で設計できる
  • Driven Portをドメインの言葉で定義できる
  • Command/Queryパターンを理解した
  • Portに技術詳細を含めてはいけない理由を説明できる

次のステップへ

次は「Adapter(実装)の設計」を学びます。Portに対する具体的な実装を、どのように設計するかを身につけましょう。


推定読了時間: 30分