メインコンテンツまでスキップ

ユースケース層の設計 — Command / Query 分離と DTO

ユースケース層とは

前章でレイヤー構成を学びました。本章では、アプリケーション層(ユースケース層) の詳細な設計について解説します。

ユースケース層は以下の責務を持ちます。

  1. ドメインオブジェクトの調整:ビジネスフローを実行
  2. トランザクション管理:整合性の境界を制御
  3. 入出力の変換:外部とドメインの橋渡し

Command / Query 分離(CQS原則)

ユースケースは、書き込み(Command)読み取り(Query) を分離して設計します。

CQSとCQRSの違い

CQS(Command Query Separation)とは?

Bertrand Meyerが提唱した設計原則で、メソッドレベルでの分離を指します。

  • Command: 状態を変更するが、値を返さない(void)
  • Query: 値を返すが、状態を変更しない(副作用なし)

原則: 「質問することで答えを変えてはならない」(Asking a question should not change the answer)

CQRS(Command Query Responsibility Segregation)とは?

Greg Youngが提唱したアーキテクチャパターンで、モデルレベルでの分離を指します。

  • 書き込みモデル(Command Model): 状態変更に最適化
  • 読み込みモデル(Query Model): データ取得に最適化

特徴:

  • 書き込みと読み込みで別々のデータストアを使うこともある
  • 複雑なシステムに適用されることが多い
  • より高度な分離戦略

本書での立場: 本書では、CQS原則に基づいて、UseCaseレベルで書き込みと読み取りを分離します。フルCQRSほど複雑な分離は行いませんが、UseCaseを明確に分けることで、コードの意図を明確にします。

種類目的戻り値
Command状態を変更するvoid または 識別子CreateOrderUseCase, ConfirmOrderUseCase
Queryデータを取得するDTOGetOrderUseCase, ListOrdersUseCase

Commandパターン

Commandパターンの設計意図

Commandパターンは、「リクエスト」をオブジェクトとしてカプセル化するデザインパターンです。ユースケース層では、以下の目的でCommandを使用します。

なぜCommandオブジェクトを使うのか?

1. 型安全性の向上

// NG: プリミティブ型の引数が多い(順序を間違えやすい)
$useCase->execute('東京都', '渋谷区', '1-1-1', $items);

// OK: Commandオブジェクトで意図を明確に
$command = new CreateOrderCommand(
prefecture: '東京都',
city: '渋谷区',
street: '1-1-1',
items: $items,
);
$useCase->execute($command);

2. バリデーションの明確化

  • Commandオブジェクト生成時点でデータの妥当性を保証
  • UseCaseは妥当なデータを受け取ることが保証される

3. リファクタリングの容易性

  • 引数の追加・削除がCommandクラスの変更のみで完結
  • UseCaseのシグネチャは変わらない(破壊的変更が少ない)

4. テストの容易性

  • テストデータの準備が明確(Commandを作るだけ)
  • ファクトリメソッドでテスト用のCommandを簡単に生成

Commandクラスの設計

Commandは、ユースケースへの入力データを表現します。

// app/Application/UseCase/Order/CreateOrderCommand.php

// finalクラス: このクラスは継承できない(意図しない拡張を防ぐ)
final class CreateOrderCommand
{
/**
* PHPDocで配列の構造を明示(IDEの補完やPHPStanの型チェックに活用)
* @param array<array{productId: int, quantity: int, unitPrice: int}> $items
*/
public function __construct(
// PHP 8.0+: コンストラクタプロモーション
// 引数の宣言と同時にプロパティを定義できる
// readonly: 一度設定したら変更不可(PHP 8.1+)
public readonly string $prefecture,
public readonly string $city,
public readonly string $street,
public readonly array $items,
) {}

/**
* 配列からCommandを生成するファクトリメソッド
* HTTPリクエストのデータ(配列)からCommandに変換する際に便利
*/
public static function fromArray(array $data): self
{
return new self(
$data['shipping']['prefecture'],
$data['shipping']['city'],
$data['shipping']['street'],
$data['items'],
);
}
}

// app/Application/UseCase/Order/ConfirmOrderCommand.php
final class ConfirmOrderCommand
{
public function __construct(
public readonly int $orderId,
) {}
}

// app/Application/UseCase/Order/CancelOrderCommand.php
final class CancelOrderCommand
{
public function __construct(
public readonly int $orderId,
public readonly string $reason,
) {}
}

Commandを処理するUseCase

// app/Application/UseCase/Order/CreateOrderUseCase.php

final class CreateOrderUseCase
{
public function __construct(
// 依存性の注入(DI): LaravelのServiceContainerが自動で実装クラスを注入
// インターフェースに依存することで、実装の詳細(Eloquent等)から独立
private readonly OrderRepositoryInterface $orderRepository,
) {}

/**
* 注文作成のユースケースを実行する
* @return OrderId 作成された注文のID(作成系は識別子を返すのが一般的)
*/
public function execute(CreateOrderCommand $command): OrderId
{
// Step 1: 新しい注文IDを発番
// リポジトリに次のIDを生成させる(DB採番やUUID生成など、実装は隠蔽)
$orderId = $this->orderRepository->nextIdentity();

// Step 2: 配送先住所の値オブジェクトを生成
// Commandのデータ(プリミティブ型)→ ドメインの値オブジェクトに変換
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street,
);

// Step 3: 注文エンティティを新規作成
// ファクトリメソッドで初期状態(DRAFT)の注文を生成
$order = Order::create($orderId, $shippingAddress);

// Step 4: 商品を注文に追加
// UseCaseはドメインオブジェクトを「調整」するだけ
// ビジネスルール(確定後は追加不可等)はOrder.addItem()内で判定
foreach ($command->items as $item) {
$order->addItem(
$this->orderRepository->nextLineIdentity(),
new ProductId($item['productId']),
$item['quantity'],
new Money($item['unitPrice'], 'JPY'),
);
}

// Step 5: 永続化
// リポジトリ内でトランザクション処理(1集約のみの更新なのでRepository内で完結)
$this->orderRepository->save($order);

return $orderId;
}
}

// app/Application/UseCase/Order/ConfirmOrderUseCase.php

final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}

/**
* 注文確定のユースケース
* 複数集約(Order + Inventory)を更新するため、UseCase内でトランザクション管理
*
* @throws OrderNotFoundException 注文が見つからない場合
* @throws InsufficientStockException 在庫が不足している場合
*/
public function execute(ConfirmOrderCommand $command): void
{
// DB::transaction(): 複数集約を更新するためUseCase内でトランザクション管理
// クロージャ内で例外が発生すると自動的にロールバックされる
DB::transaction(function () use ($command) {
// use ($command): クロージャ外の変数を参照するためのPHP構文

// Step 1: 注文を取得
// null合体演算子(??)とthrow式(PHP 8.0+)で簡潔にnullチェック
$order = $this->orderRepository->findById(new OrderId($command->orderId))
?? throw new OrderNotFoundException("注文ID {$command->orderId} が見つかりません");

// Step 2: 在庫チェック・引当(Inventory集約を更新)
// Order集約とInventory集約の両方を同一トランザクションで更新
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId())
?? throw new InsufficientStockException('在庫情報が見つかりません');

// 在庫を減らす(ビジネスルールはInventoryエンティティ内で判定)
$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory);
}

// Step 3: 注文を確定(Order集約を更新)
// ビジネスルール(明細が空なら確定不可等)はOrder.confirm()内で判定
$order->confirm();
$this->orderRepository->save($order);
});
// トランザクション成功時のみここに到達
}
}

Queryパターン

DTOの設計

Queryは、プレゼンテーション層が必要とする形式のDTOを返します。

DTO(Data Transfer Object)とは?

DTOは、層間でデータを運ぶための専用オブジェクトです。ビジネスロジックを持たず、純粋にデータを保持します。

DTOが必要な理由:

1. 層の独立性を保つ

// NG: ドメインエンティティを直接返す
public function execute(int $orderId): Order
{
return $this->orderRepository->findById(new OrderId($orderId));
}

// 問題点:
// - プレゼンテーション層がドメインエンティティに依存
// - エンティティの内部構造変更がAPIレスポンスに影響
// - エンティティのすべてのメソッドが外部に公開される
// OK: DTOに変換して返す
public function execute(int $orderId): OrderDto
{
$order = $this->orderRepository->findById(new OrderId($orderId));
return OrderDto::fromEntity($order);
}

// 利点:
// - ドメインとプレゼンテーションの境界が明確
// - APIレスポンスに必要なデータだけを公開
// - エンティティの変更がAPIに影響しにくい

2. APIレスポンスに最適な形式

// DTOは「表示用」に最適化
final class OrderDto
{
public function __construct(
public readonly int $id, // プリミティブ型
public readonly string $status, // Enumを文字列に変換
public readonly string $shippingAddress, // 値オブジェクトを文字列に変換
public readonly int $totalAmount, // Moneyをintに変換
public readonly string $createdAt, // DateTimeをフォーマット済み文字列に
) {}
}

// エンティティは「ドメイン」に最適化
final class Order
{
public function __construct(
private OrderId $id, // 値オブジェクト
private OrderStatus $status, // Enum
private ShippingAddress $shippingAddress, // 値オブジェクト
private Money $totalAmount, // 値オブジェクト
private DateTimeImmutable $createdAt, // DateTime
) {}
}

3. パフォーマンスの最適化

  • 一覧表示には軽量なDTO(OrderListItemDto
  • 詳細表示には完全なDTO(OrderDto
  • 必要なデータだけを取得・変換

4. APIのバージョニング

// v1用のDTO
final class OrderDtoV1 { /* ... */ }

// v2用のDTO(フィールド追加)
final class OrderDtoV2 { /* ... */ }

// ドメインエンティティは変更なし
// DTOの変換ロジックだけを変える

DTOの実装

Queryは、プレゼンテーション層が必要とする形式のDTOを返します。

// app/Application/UseCase/Order/OrderDto.php
final class OrderDto
{
/**
* @param OrderLineDto[] $orderLines
*/
public function __construct(
public readonly int $id,
public readonly string $status,
public readonly string $shippingAddress,
public readonly array $orderLines,
public readonly int $totalAmount,
public readonly string $createdAt,
) {}

public static function fromEntity(Order $order): self
{
return new self(
$order->id()->value(),
$order->status()->value,
$order->shippingAddress()->fullAddress(),
array_map(
fn($line) => OrderLineDto::fromEntity($line),
$order->orderLines()
),
$order->totalAmount()->amount(),
$order->createdAt()->format('Y-m-d H:i:s'),
);
}
}

// app/Application/UseCase/Order/OrderLineDto.php
final class OrderLineDto
{
public function __construct(
public readonly int $id,
public readonly int $productId,
public readonly int $quantity,
public readonly int $unitPrice,
public readonly int $subtotal,
) {}

public static function fromEntity(OrderLine $line): self
{
return new self(
$line->id()->value(),
$line->productId()->value(),
$line->quantity(),
$line->unitPrice()->amount(),
$line->subtotal()->amount(),
);
}
}

// app/Application/UseCase/Order/OrderListItemDto.php
// 一覧用の軽量DTO
final class OrderListItemDto
{
public function __construct(
public readonly int $id,
public readonly string $status,
public readonly int $totalAmount,
public readonly string $createdAt,
) {}
}

Queryを処理するUseCase

// app/Application/UseCase/Order/GetOrderUseCase.php
final class GetOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}

/**
* @throws OrderNotFoundException
*/
public function execute(int $orderId): OrderDto
{
$order = $this->orderRepository->findById(new OrderId($orderId))
?? throw new OrderNotFoundException("注文ID {$orderId} が見つかりません");

return OrderDto::fromEntity($order);
}
}

// app/Application/UseCase/Order/ListOrdersUseCase.php
final class ListOrdersUseCase
{
public function __construct(
private readonly OrderQueryServiceInterface $orderQueryService,
) {}

/**
* @return OrderListItemDto[]
*/
public function execute(ListOrdersQuery $query): array
{
return $this->orderQueryService->findAll(
status: $query->status,
limit: $query->limit,
offset: $query->offset,
);
}
}

// app/Application/UseCase/Order/ListOrdersQuery.php
final class ListOrdersQuery
{
public function __construct(
public readonly ?string $status = null,
public readonly int $limit = 20,
public readonly int $offset = 0,
) {}
}

Query専用のサービス

複雑な検索や一覧取得には、Query専用のサービスを用意すると効率的です。

Query サービスのインターフェースはアプリケーション層に置く

Query サービスは表示用 DTO(OrderListItemDto)を返します。この DTO はアプリケーション層の型なので、インターフェースをドメイン層に置くと「ドメイン層 → アプリケーション層」という依存が生まれ、第10章の依存性逆転の原則(ドメインは外側を知らない)に反します。読み取り(CQRS の read side)はアプリケーションの関心事なので、インターフェースはアプリケーション層に置き、実装だけをインフラ層に置きます。

// app/Application/Query/Order/OrderQueryServiceInterface.php
interface OrderQueryServiceInterface
{
/**
* @return OrderListItemDto[]
*/
public function findAll(?string $status, int $limit, int $offset): array;

public function countByStatus(string $status): int;
}

// app/Infrastructure/QueryService/EloquentOrderQueryService.php
final class EloquentOrderQueryService implements OrderQueryServiceInterface
{
/**
* @return OrderListItemDto[]
*/
public function findAll(?string $status, int $limit, int $offset): array
{
$query = OrderModel::query();

if ($status !== null) {
$query->where('status', $status);
}

return $query
->orderByDesc('created_at')
->offset($offset)
->limit($limit)
// 必要なカラムのみ取得(SELECT * を避けてパフォーマンスを向上)
->select('id', 'status', 'total_amount', 'created_at')
->get()
->map(fn($model) => new OrderListItemDto(
$model->id,
$model->status,
$model->total_amount,
$model->created_at->format('Y-m-d H:i:s'),
))
->toArray();
}

public function countByStatus(string $status): int
{
return OrderModel::where('status', $status)->count();
}
}
なぜQuery専用サービスを使うのか?

一覧取得や検索では、集約全体を復元する必要がないことが多いです。Query専用サービスを使うことで、パフォーマンス向上(必要なカラムだけ取得)、N+1問題の回避、複雑なJOINクエリへの対応が可能になります。

UseCaseの設計指針

1. 単一責任

1つのUseCaseは1つの操作だけを担当します。

// NG: 複数の操作を1つのUseCaseに
final class OrderUseCase
{
public function create(...) { }
public function confirm(...) { }
public function cancel(...) { }
}

// OK: 操作ごとにUseCaseを分ける
final class CreateOrderUseCase { }
final class ConfirmOrderUseCase { }
final class CancelOrderUseCase { }

2. 入力は必ずCommand/Query経由

プリミティブ型を直接受け取らず、専用のオブジェクトを使います。

// NG: プリミティブ型を直接受け取る
public function execute(string $prefecture, string $city, array $items): OrderId

// OK: Commandを受け取る
public function execute(CreateOrderCommand $command): OrderId

3. ドメインロジックをUseCaseに書かない

UseCaseはドメインオブジェクトを調整するだけです。

なぜUseCaseにビジネスロジックを書いてはいけないのか?

1. ビジネスルールの重複を招く

// 悪い例: 同じルールが複数のUseCaseに散在
class ConfirmOrderUseCase {
public function execute($command): void {
// ルール: DRAFT状態のみ確定可能
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('確定できません');
}
// ...
}
}

class BulkConfirmOrdersUseCase {
public function execute($command): void {
// 同じルールを再度記述(重複!)
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('確定できません');
}
// ...
}
}

2. テストが困難になる

  • ビジネスルールのテストが、UseCaseテストに混在
  • 単純なビジネスルールのテストにDB・リポジトリが必要になる
  • テストの実行速度が遅くなる

3. ビジネスルールの発見が困難

  • 「注文確定の条件は?」を知りたいとき、複数のUseCaseを調べる必要がある
  • エンティティに集約されていれば、一箇所を見れば済む

4. ドメイン知識の表現力が低下

// 悪い例: 手続き的な記述(「何をするか」だけ)
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('確定できません');
}
$order->setStatus(OrderStatus::CONFIRMED);

// 良い例: ドメインの言葉で表現(「何を意図するか」)
$order->confirm(); // ← ビジネスの意図が明確

良い設計: 責務の分離

// NG: UseCaseにビジネスロジック
public function execute(ConfirmOrderCommand $command): void
{
$order = $this->orderRepository->findById(...);

// ビジネスロジックがUseCaseに漏れている
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('確定できません');
}
if (count($order->orderLines()) === 0) {
throw new DomainException('明細が空です');
}

$order->setStatus(OrderStatus::CONFIRMED);
$this->orderRepository->save($order);
}

// OK: ビジネスロジックはエンティティに
public function execute(ConfirmOrderCommand $command): void
{
$order = $this->orderRepository->findById(...);

// Orderエンティティがビジネスルールを持つ
$order->confirm(); // ← ビジネスルールはエンティティ内部

$this->orderRepository->save($order);
}
// ドメイン層: Order.php
final class Order
{
public function confirm(): void
{
// ビジネスルールをエンティティ内部に集約
if ($this->status !== OrderStatus::DRAFT) {
throw new DomainException('下書き状態の注文のみ確定できます');
}
if (count($this->orderLines) === 0) {
throw new DomainException('明細が空の注文は確定できません');
}

$this->status = OrderStatus::CONFIRMED;
$this->confirmedAt = new DateTimeImmutable();
}
}

UseCaseの責務

  • ドメインオブジェクトの取得
  • ドメインオブジェクトのメソッド呼び出し(調整)
  • トランザクション管理
  • 永続化の指示

Entityの責務

  • ビジネスルールの判定
  • 状態の変更
  • 不変条件の保護

4. 戻り値の設計

操作種別戻り値理由
作成系識別子(ID)作成されたリソースを特定するため
更新系void冪等性の担保
削除系void冪等性の担保
取得系DTOプレゼンテーション層に必要な形式で
冪等性(Idempotency)とは?

同じ操作を何度実行しても結果が変わらない性質です。更新系・削除系で値を返さない(void)のは、途中でエラーが発生してクライアントがリトライしても、安全に再実行できるようにするためです。戻り値に状態変更の情報を含めてしまうと、クライアントが「どの結果を信じるべきか」判断できなくなります。

ディレクトリ構成

app/Application/
├── UseCase/
│ └── Order/
│ ├── Create/
│ │ ├── CreateOrderCommand.php
│ │ └── CreateOrderUseCase.php
│ ├── Confirm/
│ │ ├── ConfirmOrderCommand.php
│ │ └── ConfirmOrderUseCase.php
│ ├── Cancel/
│ │ ├── CancelOrderCommand.php
│ │ └── CancelOrderUseCase.php
│ ├── Get/
│ │ ├── GetOrderUseCase.php
│ │ └── OrderDto.php
│ └── List/
│ ├── ListOrdersQuery.php
│ ├── ListOrdersUseCase.php
│ └── OrderListItemDto.php

└── Listener/
├── SendOrderConfirmationEmail.php
└── DecreaseInventoryOnOrderConfirmed.php

まとめ

ポイント説明
UseCaseの責務ドメインオブジェクトの調整、トランザクション管理
Command/Query分離書き込みと読み取りを明確に分ける
DTOの活用プレゼンテーション層に適した形式でデータを返す
Query専用サービスパフォーマンスが必要な読み取りに使用
設計指針単一責任、ドメインロジックを持たない

次のチャプターでは、プレゼンテーション層の設計について詳しく見ていきます。