LESSON 30分

ストーリー

高橋アーキテクト
Portが”契約”なら、Adapterは”実行者”だ
高橋アーキテクト
HTTPリクエストをUse Caseの入力に変換するのがDriving Adapter。Use Caseの出力をデータベースに保存するのがDriven Adapter。どちらも、技術的な詳細をPortの裏側に隠す役割を持つ

Driving Adapter(入力アダプター)

外部のリクエストを、Use Caseが理解できるCommandやQueryに変換します。

HTTP Adapter(REST Controller)

// adapters/in/http/OrderController.ts
import express from 'express';

class OrderController {
  constructor(
    private createOrderUseCase: CreateOrderUseCase,
    private getOrderUseCase: GetOrderUseCase,
    private cancelOrderUseCase: CancelOrderUseCase
  ) {}

  async create(req: express.Request, res: express.Response): Promise<void> {
    try {
      // HTTPリクエスト → Command への変換
      const command: CreateOrderCommand = {
        customerId: req.body.customerId,
        items: req.body.items,
        shippingAddress: req.body.shippingAddress,
      };

      const orderId = await this.createOrderUseCase.execute(command);

      // Use Caseの結果 → HTTPレスポンス への変換
      res.status(201).json({ orderId: orderId.value });
    } catch (error) {
      if (error instanceof ValidationError) {
        res.status(400).json({ error: error.message });
      } else {
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  }

  async getById(req: express.Request, res: express.Response): Promise<void> {
    const query: GetOrderQuery = { orderId: req.params.id };
    const order = await this.getOrderUseCase.execute(query);

    if (!order) {
      res.status(404).json({ error: 'Order not found' });
      return;
    }
    res.json(order);
  }

  async cancel(req: express.Request, res: express.Response): Promise<void> {
    const command: CancelOrderCommand = {
      orderId: req.params.id,
      reason: req.body.reason,
    };
    await this.cancelOrderUseCase.execute(command);
    res.status(204).send();
  }
}

Adapterの責任

Driving Adapterが行うのは変換だけです。ビジネスロジックは含めません。

// NG: Adapterにビジネスロジックが入っている
class OrderController {
  async create(req: Request, res: Response) {
    // バリデーションや計算をControllerでやってはいけない
    if (req.body.items.length > 10) {
      return res.status(400).json({ error: 'Too many items' });
    }
    const total = req.body.items.reduce((s, i) => s + i.price * i.qty, 0);
    // ...
  }
}

// OK: Adapterは変換のみ、ロジックはUse Caseに委譲
class OrderController {
  async create(req: Request, res: Response) {
    const command = this.toCommand(req.body);  // 変換のみ
    const result = await this.createOrderUseCase.execute(command); // 委譲
    res.status(201).json(this.toResponse(result)); // 変換のみ
  }
}

Driven Adapter(出力アダプター)

Use Caseが必要とする外部機能を、具体的な技術で実装します。

Repository Adapter(Prisma)

// adapters/out/persistence/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';

class PrismaOrderRepository implements OrderRepository {
  constructor(private prisma: PrismaClient) {}

  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.prisma.order.findUnique({
      where: { id: id.value },
      include: { items: true },
    });

    if (!row) return null;

    // DB行 → ドメインモデル への変換
    return Order.fromPersistence({
      id: row.id,
      customerId: row.customerId,
      items: row.items.map(item => ({
        productId: item.productId,
        productName: item.productName,
        quantity: item.quantity,
        unitPrice: item.unitPrice,
        currency: item.currency,
      })),
      status: row.status,
      totalAmount: row.totalAmount,
      currency: row.currency,
      createdAt: row.createdAt,
    });
  }

  async save(order: Order): Promise<void> {
    // ドメインモデル → DB行 への変換
    const data = order.toPersistence();

    await this.prisma.order.upsert({
      where: { id: data.id },
      create: {
        id: data.id,
        customerId: data.customerId,
        status: data.status,
        totalAmount: data.totalAmount,
        currency: data.currency,
        createdAt: data.createdAt,
        items: {
          create: data.items.map(item => ({
            productId: item.productId,
            productName: item.productName,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
            currency: item.currency,
          })),
        },
      },
      update: {
        status: data.status,
        totalAmount: data.totalAmount,
      },
    });
  }

  async delete(id: OrderId): Promise<void> {
    await this.prisma.order.delete({ where: { id: id.value } });
  }

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

  async existsById(id: OrderId): Promise<boolean> {
    const count = await this.prisma.order.count({
      where: { id: id.value },
    });
    return count > 0;
  }
}

外部API Adapter

// adapters/out/payment/StripePaymentGateway.ts
import Stripe from 'stripe';

class StripePaymentGateway implements PaymentGateway {
  private stripe: Stripe;

  constructor(apiKey: string) {
    this.stripe = new Stripe(apiKey);
  }

  async charge(amount: Money, method: PaymentMethod): Promise<PaymentResult> {
    try {
      const intent = await this.stripe.paymentIntents.create({
        amount: amount.toCents(),
        currency: amount.currency.toLowerCase(),
        payment_method: method.externalId,
        confirm: true,
      });

      // Stripe固有のレスポンス → ドメインの型 への変換
      return PaymentResult.success(intent.id, amount);
    } catch (error) {
      return PaymentResult.failure(error.message);
    }
  }

  async refund(paymentId: string, amount: Money): Promise<RefundResult> {
    const refund = await this.stripe.refunds.create({
      payment_intent: paymentId,
      amount: amount.toCents(),
    });
    return RefundResult.of(refund.id, refund.status);
  }
}

テスト用 Adapter

// adapters/out/persistence/InMemoryOrderRepository.ts
class InMemoryOrderRepository implements OrderRepository {
  private store: Map<string, Order> = new Map();

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

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

  async delete(id: OrderId): Promise<void> {
    this.store.delete(id.value);
  }

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

  async existsById(id: OrderId): Promise<boolean> {
    return this.store.has(id.value);
  }

  // テスト用ヘルパー
  clear(): void { this.store.clear(); }
  size(): number { return this.store.size; }
}

ディレクトリ構造

src/
└── adapters/
    ├── in/                              # Driving Adapters
    │   ├── http/
    │   │   ├── OrderController.ts
    │   │   └── router.ts
    │   └── cli/
    │       └── CreateOrderCli.ts
    └── out/                             # Driven Adapters
        ├── persistence/
        │   ├── PrismaOrderRepository.ts
        │   └── InMemoryOrderRepository.ts  # テスト用
        ├── payment/
        │   └── StripePaymentGateway.ts
        └── notification/
            └── SendGridNotificationSender.ts

まとめ

ポイント内容
Driving AdapterHTTPリクエスト↔Commandの変換のみ
Driven Adapterドメインモデル↔外部技術の変換
責任の限定Adapterにビジネスロジックを含めない
テスト用AdapterInMemory実装で高速なテストを実現
変換の場所ドメイン↔外部の変換はAdapter内で行う

チェックリスト

  • Driving Adapterの責任(変換のみ)を説明できる
  • Driven Adapterの実装方法を理解した
  • テスト用InMemory Adapterの利点を説明できる
  • Adapterのディレクトリ構成を理解した

次のステップへ

次は演習です。ここまで学んだ知識を使って、実際にヘキサゴナルアーキテクチャでアプリケーションを構築してみましょう。


推定読了時間: 30分