ユースケース層の設計 — Command / Query 分離と DTO
ユースケース層とは
前章でレイヤー構成を学びました。本章では、アプリケーション層(ユースケース層) の詳細な設計について解説します。
ユースケース層は以下の責務を持ちます。
- ドメインオブジェクトの調整:ビジネスフローを実行
- トランザクション管理:整合性の境界を制御
- 入出力の変換:外部とドメインの橋渡し
Command / Query 分離(CQS原則)
ユースケースは、書き込み(Command) と 読み取り(Query) を分離して設計します。
CQSとCQRSの違い
Bertrand Meyerが提唱した設計原則で、メソッドレベルでの分離を指します。
- Command: 状態を変更するが、値を返さない(void)
- Query: 値を返すが、状態を変更しない(副作用なし)
原則: 「質問することで答えを変えてはならない」(Asking a question should not change the answer)
Greg Youngが提唱したアーキテクチャパターンで、モデルレベルでの分離を指します。
- 書き込みモデル(Command Model): 状態変更に最適化
- 読み込みモデル(Query Model): データ取得に最適化
特徴:
- 書き込みと読み込みで別々のデータストアを使うこともある
- 複雑なシステムに適用されることが多い
- より高度な分離戦略
本書での立場: 本書では、CQS原則に基づいて、UseCaseレベルで書き込みと読み取りを分離します。フルCQRSほど複雑な分離は行いませんが、UseCaseを明確に分けることで、コードの意図を明確にします。
| 種類 | 目的 | 戻り値 | 例 |
|---|---|---|---|
| Command | 状態を変更する | void または 識別子 | CreateOrderUseCase, ConfirmOrderUseCase |
| Query | データを取得する | DTO | GetOrderUseCase, ListOrdersUseCase |
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は、層間でデータを運ぶための専用オブジェクトです。ビジネスロジックを持たず、純粋にデータを保持します。
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 サービスは表示用 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専用サービスを使うことで、パフォーマンス向上(必要なカラムだけ取得)、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はドメインオブジェクトを調整するだけです。
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 | プレゼンテーション層に必要な形式で |
同じ操作を何度実行しても結果が変わらない性質です。更新系・削除系で値を返さない(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専用サービス | パフォーマンスが必要な読み取りに使用 |
| 設計指針 | 単一責任、ドメインロジックを持たない |
次のチャプターでは、プレゼンテーション層の設計について詳しく見ていきます。