LESSON 30分

ストーリー

高橋アーキテクト
いよいよヘキサゴナルアーキテクチャの本格的な学習だ
あなた
ヘキサゴナル — 六角形。なぜ六角形なんですか?
高橋アーキテクト
六角形に特別な意味はないんだ。提唱者のAlistair Cockburnは”アプリケーションには複数の面がある”ことを表現するために六角形を選んだ。重要なのは形ではなく、“Port”と”Adapter”という2つの概念だ

ヘキサゴナルアーキテクチャの全体像

                    ┌─────────────────────┐
                    │    HTTP Adapter      │
                    │   (Controller)       │
                    └────────┬────────────┘

                    ┌────────▼────────────┐
                    │    Port (in)         │
                    │  (Use Case IF)       │
           ┌───────┼────────────────────┐ │
           │       │                    │ │
  CLI ─────┤  Port │   APPLICATION      │ │
  Adapter  │  (in) │                    │ │
           │       │   ┌────────────┐   │ │
           └───────┤   │  DOMAIN    │   │ │
                   │   │  MODEL     │   │ │
                   │   └────────────┘   │ │
                   │                    │ │
                   │    Port (out)      │ │──── DB Adapter
                   │  (Repository IF)   │ │    (PostgreSQL)
                   └────────────────────┘ │
                    │    Port (out)        │
                    │  (Notification IF)   │
                    └─────────┬───────────┘

                    ┌─────────▼───────────┐
                    │   Email Adapter      │
                    │   (SendGrid)         │
                    └─────────────────────┘

Port(ポート)とは

Portはアプリケーションの境界に定義されたインターフェースです。外部との接続点を抽象化します。

Driving Port(入力ポート / Primary Port)

外部からアプリケーションを駆動するためのインターフェースです。「アプリケーションに何ができるか」を定義します。

// domain/ports/in/CreateOrderUseCase.ts
// Driving Port: アプリケーションが提供する機能を定義
interface CreateOrderUseCase {
  execute(command: CreateOrderCommand): Promise<OrderId>;
}

// コマンドオブジェクト
interface CreateOrderCommand {
  customerId: string;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
}

Driven Port(出力ポート / Secondary Port)

アプリケーションが外部リソースを利用するためのインターフェースです。「アプリケーションが何を必要としているか」を定義します。

// domain/ports/out/OrderRepository.ts
// Driven Port: アプリケーションが必要とする外部機能を定義
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findByCustomerId(customerId: string): Promise<Order[]>;
}

// domain/ports/out/PaymentGateway.ts
interface PaymentGateway {
  charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>;
  refund(paymentId: string): Promise<RefundResult>;
}

// domain/ports/out/NotificationService.ts
interface NotificationService {
  notify(userId: string, message: string): Promise<void>;
}

Adapter(アダプター)とは

AdapterはPortの具体的な実装です。外部の技術的詳細をアプリケーションに接続します。

Driving Adapter(入力アダプター / Primary Adapter)

// adapters/in/http/OrderController.ts
// Driving Adapter: HTTPリクエストをUse Caseに変換
class OrderController {
  constructor(private createOrderUseCase: CreateOrderUseCase) {}

  async create(req: Request, res: Response): Promise<void> {
    const command: CreateOrderCommand = {
      customerId: req.body.customerId,
      items: req.body.items,
    };

    const orderId = await this.createOrderUseCase.execute(command);
    res.status(201).json({ orderId: orderId.value });
  }
}

// adapters/in/cli/CreateOrderCli.ts
// 同じUse Caseを別のAdapterから呼び出せる
class CreateOrderCli {
  constructor(private createOrderUseCase: CreateOrderUseCase) {}

  async run(args: string[]): Promise<void> {
    const command = this.parseArgs(args);
    const orderId = await this.createOrderUseCase.execute(command);
    console.log(`Order created: ${orderId.value}`);
  }
}

Driven Adapter(出力アダプター / Secondary Adapter)

// adapters/out/persistence/PrismaOrderRepository.ts
// Driven Adapter: PortをPrismaで実装
class PrismaOrderRepository implements OrderRepository {
  async findById(id: OrderId): Promise<Order | null> {
    const row = await prisma.order.findUnique({
      where: { id: id.value },
      include: { items: true },
    });
    return row ? Order.fromPersistence(row) : null;
  }

  async save(order: Order): Promise<void> {
    const data = order.toPersistence();
    await prisma.order.upsert({
      where: { id: data.id },
      create: data,
      update: data,
    });
  }

  async findByCustomerId(customerId: string): Promise<Order[]> {
    const rows = await prisma.order.findMany({
      where: { customerId },
      include: { items: true },
    });
    return rows.map(Order.fromPersistence);
  }
}

// adapters/out/payment/StripePaymentGateway.ts
class StripePaymentGateway implements PaymentGateway {
  async charge(amount: Money, method: PaymentMethod): Promise<PaymentResult> {
    const intent = await stripe.paymentIntents.create({
      amount: amount.toCents(),
      currency: amount.currency,
      payment_method: method.stripeId,
    });
    return PaymentResult.fromStripe(intent);
  }

  async refund(paymentId: string): Promise<RefundResult> {
    const refund = await stripe.refunds.create({ payment_intent: paymentId });
    return RefundResult.fromStripe(refund);
  }
}

PortとAdapterの関係

Driving Adapter ──→ Driving Port ──→ Application ──→ Driven Port ←── Driven Adapter

HTTP Controller        Use Case IF    Use Case Impl    Repository IF    Prisma Impl
CLI Command                                            Gateway IF       Stripe Impl
GraphQL Resolver                                       Notifier IF      SendGrid Impl
要素役割依存の方向
Driving Portアプリケーションが提供する機能Adapterが依存
Driving Adapter外部→アプリケーションの変換Portに依存
Driven Portアプリケーションが必要とする機能アプリケーションが依存
Driven Adapterアプリケーション→外部の変換Portに依存

なぜPortとAdapterに分けるのか

// テスト時: Driven Adapterをモックに差し替え
class InMemoryOrderRepository implements OrderRepository {
  private orders: Map<string, Order> = new Map();

  async findById(id: OrderId): Promise<Order | null> {
    return this.orders.get(id.value) ?? null;
  }

  async save(order: Order): Promise<void> {
    this.orders.set(order.id.value, order);
  }

  async findByCustomerId(customerId: string): Promise<Order[]> {
    return [...this.orders.values()].filter(o => o.customerId === customerId);
  }
}

// テストコード
describe('CreateOrderUseCase', () => {
  it('should create an order', async () => {
    const repo = new InMemoryOrderRepository(); // DBなしでテスト可能
    const useCase = new CreateOrderUseCaseImpl(repo);
    const orderId = await useCase.execute(command);
    expect(orderId).toBeDefined();
  });
});

まとめ

ポイント内容
Portアプリケーション境界のインターフェース
AdapterPortの具体的な実装
Driving (in)外部→アプリケーション(HTTP, CLI, GraphQL)
Driven (out)アプリケーション→外部(DB, API, Email)
利点テスト容易性、技術の差し替え、境界の明確化

チェックリスト

  • Driving PortとDriven Portの違いを説明できる
  • Driving AdapterとDriven Adapterの違いを説明できる
  • Portがインターフェースであり、Adapterが実装であることを理解した
  • テスト時にAdapterを差し替える利点を理解した

次のステップへ

次は「ドメインモデルの隔離」を学びます。ヘキサゴナルアーキテクチャの中心に位置するドメインモデルを、外部の影響から守る方法を理解しましょう。


推定読了時間: 30分