LESSON 30分

ストーリー

高橋アーキテクト
クリーンアーキテクチャの要はUse Case層だ
高橋アーキテクト
Entityがビジネスルールを持ち、Adapterが外部と繋がる。では、その間をつなぐUse Caseは何をするのか? それは”アプリケーション固有のビジネスフロー”を記述することだ

Use Caseの責任

Use Caseはアプリケーション固有のビジネスフローを定義します。Entityのビジネスルールを適切な順序で呼び出し、外部リソースとの連携を調整します。

// 「注文を作成する」というUse Case
class CreateOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private inventoryChecker: InventoryChecker,
    private paymentGateway: PaymentGateway,
    private notifier: NotificationSender
  ) {}

  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    // 1. 在庫を確認する
    for (const item of input.items) {
      const available = await this.inventoryChecker.checkAvailability(
        item.productId, item.quantity
      );
      if (!available) {
        throw new BusinessError(`商品${item.productId}の在庫が不足しています`);
      }
    }

    // 2. 注文エンティティを作成する(ビジネスルールはEntity内)
    const order = Order.create(input.customerId, input.items);

    // 3. 決済を行う
    const paymentResult = await this.paymentGateway.charge(
      order.totalAmount, input.paymentMethod
    );
    if (!paymentResult.isSuccess) {
      throw new BusinessError('決済に失敗しました');
    }

    // 4. 注文を保存する
    await this.orderRepo.save(order);

    // 5. 通知を送る
    await this.notifier.sendOrderConfirmation(order);

    // 6. 結果を返す
    return {
      orderId: order.id.value,
      totalAmount: order.totalAmount.amount,
      status: order.status,
    };
  }
}

Input / Output の設計

Use Caseの入力と出力は、専用の型で定義します。

Input(入力)

// use-cases/dto/CreateOrderInput.ts
interface CreateOrderInput {
  readonly customerId: string;
  readonly items: ReadonlyArray<{
    readonly productId: string;
    readonly quantity: number;
  }>;
  readonly paymentMethod: {
    readonly type: 'credit_card' | 'bank_transfer';
    readonly token: string;
  };
  readonly shippingAddress: string;
}

Output(出力)

// use-cases/dto/CreateOrderOutput.ts
interface CreateOrderOutput {
  readonly orderId: string;
  readonly totalAmount: number;
  readonly status: string;
  readonly estimatedDelivery: string;
}

なぜ専用の型を使うのか

// NG: Expressのリクエスト型を直接受け取る
class CreateOrderUseCase {
  execute(req: express.Request): Promise<Order> { /* ... */ }
  // Use CaseがExpressに依存してしまう
}

// NG: ドメインエンティティをそのまま返す
class CreateOrderUseCase {
  execute(input: CreateOrderInput): Promise<Order> { /* ... */ }
  // 外側の層にドメインモデルの内部が露出する
}

// OK: 専用のInput/Outputを使う
class CreateOrderUseCase {
  execute(input: CreateOrderInput): Promise<CreateOrderOutput> { /* ... */ }
  // フレームワーク非依存、ドメイン非露出
}

Use Case設計のパターン

Command Use Case(状態変更)

// 注文をキャンセルする
class CancelOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private paymentGateway: PaymentGateway
  ) {}

  async execute(input: CancelOrderInput): Promise<void> {
    const order = await this.orderRepo.findById(
      OrderId.fromString(input.orderId)
    );
    if (!order) throw new NotFoundError('注文が見つかりません');

    order.cancel();

    await this.paymentGateway.refund(input.orderId, order.totalAmount);
    await this.orderRepo.save(order);
  }
}

Query Use Case(データ取得)

// 注文の詳細を取得する
class GetOrderDetailUseCase {
  constructor(private orderRepo: OrderRepository) {}

  async execute(input: GetOrderDetailInput): Promise<OrderDetailOutput> {
    const order = await this.orderRepo.findById(
      OrderId.fromString(input.orderId)
    );
    if (!order) throw new NotFoundError('注文が見つかりません');

    return {
      orderId: order.id.value,
      customerId: order.customerId,
      items: order.items.map(item => ({
        productId: item.productId,
        productName: item.productName,
        quantity: item.quantity,
        unitPrice: item.unitPrice.amount,
      })),
      totalAmount: order.totalAmount.amount,
      status: order.status,
    };
  }
}

Use Caseのテスト

describe('CreateOrderUseCase', () => {
  let useCase: CreateOrderUseCase;
  let orderRepo: InMemoryOrderRepository;
  let inventoryChecker: StubInventoryChecker;
  let paymentGateway: StubPaymentGateway;
  let notifier: SpyNotificationSender;

  beforeEach(() => {
    orderRepo = new InMemoryOrderRepository();
    inventoryChecker = new StubInventoryChecker();
    paymentGateway = new StubPaymentGateway();
    notifier = new SpyNotificationSender();
    useCase = new CreateOrderUseCase(
      orderRepo, inventoryChecker, paymentGateway, notifier
    );
  });

  it('正常に注文を作成できる', async () => {
    inventoryChecker.setAvailable(true);
    paymentGateway.setSuccess(true);

    const output = await useCase.execute({
      customerId: 'customer-1',
      items: [{ productId: 'prod-1', quantity: 2 }],
      paymentMethod: { type: 'credit_card', token: 'tok_123' },
      shippingAddress: '東京都...',
    });

    expect(output.orderId).toBeDefined();
    expect(output.status).toBe('PENDING');
    expect(notifier.wasCalled()).toBe(true);
  });

  it('在庫不足の場合はエラー', async () => {
    inventoryChecker.setAvailable(false);

    await expect(useCase.execute({
      customerId: 'customer-1',
      items: [{ productId: 'prod-1', quantity: 100 }],
      paymentMethod: { type: 'credit_card', token: 'tok_123' },
      shippingAddress: '東京都...',
    })).rejects.toThrow('在庫が不足しています');
  });
});

Use Case設計の注意点

原則説明
1 Use Case = 1 クラス1つのクラスが1つのユースケースを担当
フレームワーク非依存Express, Prismaなどをimportしない
薄いUse CaseビジネスルールはEntityに委譲する
Input/Output型専用の型を使い、ドメインモデルを直接返さない
テスタブル全依存がインターフェース経由で差し替え可能

まとめ

ポイント内容
Use Caseの責任アプリケーション固有のビジネスフロー
Input/Output専用の型で定義し、フレームワーク・ドメイン非依存
Command/Query状態変更と取得を分離
テストStub/Spy/InMemoryで全依存を差し替え

チェックリスト

  • Use Caseの責任を説明できる
  • Input/Outputの型を適切に設計できる
  • Command Use CaseとQuery Use Caseの違いを理解した
  • テスト用のStub/Spyを使ったテストが書ける

次のステップへ

次は「依存性注入(DI)の実践」を学びます。Use CaseにPortの実装を注入する具体的な方法を身につけましょう。


推定読了時間: 30分