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

プレゼンテーション層の設計 — FormRequest・薄い Controller・API Resource

プレゼンテーション層とは

前章でユースケース層を学びました。本章では、HTTPリクエスト/レスポンスを処理するプレゼンテーション層の設計について解説します。

プレゼンテーション層は以下の責務を持ちます。

  1. 入力のバリデーション:リクエストの形式チェック
  2. Commandへの変換:リクエストをアプリケーション層の入力形式に
  3. レスポンスの構築:DTOをHTTPレスポンスに変換
  4. 認証・認可:アクセス制御

Controller は薄く保ち(Thin Controller)、ビジネスロジックは UseCase に委譲します。

FormRequestによるバリデーション

基本的なFormRequest

// app/Http/Requests/Order/CreateOrderRequest.php
final class CreateOrderRequest extends FormRequest
{
public function authorize(): bool
{
// 認可チェック(必要に応じて実装)
return true;
}

public function rules(): array
{
return [
'shipping' => ['required', 'array'],
'shipping.prefecture' => ['required', 'string', 'max:255'],
'shipping.city' => ['required', 'string', 'max:255'],
'shipping.street' => ['required', 'string', 'max:255'],
'items' => ['required', 'array', 'min:1'],
'items.*.productId' => ['required', 'integer', 'min:1'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
'items.*.unitPrice' => ['required', 'integer', 'min:0'],
];
}

public function messages(): array
{
return [
'items.required' => '注文には1つ以上の商品が必要です',
'items.min' => '注文には1つ以上の商品が必要です',
'items.*.quantity.max' => '1商品の最大数量は100個です',
];
}

/**
* Commandへの変換メソッド
*/
public function toCommand(): CreateOrderCommand
{
return new CreateOrderCommand(
$this->input('shipping.prefecture'),
$this->input('shipping.city'),
$this->input('shipping.street'),
$this->input('items'),
);
}
}

シンプルな追加バリデーション

// app/Http/Requests/Order/ConfirmOrderRequest.php
final class ConfirmOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'order_id' => [
'required',
'integer',
'exists:orders,id', // DBに存在するか(参照整合性)
],
];
}

public function toCommand(): ConfirmOrderCommand
{
return new ConfirmOrderCommand($this->input('order_id'));
}
}
FormRequest でドメインルールを判定してはいけない

「この注文は既に確定されているか?」のような状態チェックを FormRequest の withValidator() で実施するのはアンチパターンです。理由は次のセクションで解説しますが、要点は ビジネスルールはドメイン層に集約する という原則に従うこと。FormRequest は形式チェックのみに徹してください。

FormRequestとドメイン層のバリデーション役割分担

バリデーションは、FormRequestドメイン層の2段階で行います。それぞれの責務を明確に理解することが重要です。

FormRequestとドメインバリデーションの役割分担

FormRequest(プレゼンテーション層)の責務:

  • 形式的なバリデーション: データの「形」が正しいか
  • HTTPリクエストが正しい構造かをチェック
  • 早期リターン(無効なリクエストを早い段階で弾く)

ドメイン層の責務:

  • ビジネスルールのバリデーション: 操作が「許可」されるか
  • 現在の状態で操作が実行可能かをチェック
  • ドメイン知識に基づく判定

判断基準:

  • 「HTTPリクエストとして妥当か?」→ FormRequest
  • 「ビジネス的に許可されるか?」→ ドメイン層

具体例で理解する役割分担

チェック内容担当層理由
必須チェックFormRequest'name' => 'required'リクエストの形式的な要件
型チェックFormRequest'price' => 'integer'データ型の妥当性
範囲チェックFormRequest'quantity' => 'min:1|max:100'一般的な制約(UI層で判断可能)
存在チェックFormRequest'product_id' => 'exists:products'参照整合性(DB層の制約)
状態遷移チェックDomain$order->confirm() で例外ビジネスルール(ドメイン知識)
在庫チェックDomain$inventory->decrease() で例外ビジネスルール(リアルタイム判定)
「一般的な制約」と「ビジネス制約」の境界

'quantity' => 'min:1|max:100' のような一般的な制約は FormRequest で扱うのが自然です。一方、「商品ごとに上限数量が異なる」「ユーザーランクによって制限が変わる」のような ドメイン知識を伴う制約 はドメイン層で判定してください。迷ったら「この制約はビジネスルールか、それともリクエスト形式の要件か?」を自問しましょう。

実装例:

// FormRequest: 形式的なバリデーション
class ConfirmOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'order_id' => ['required', 'integer', 'exists:orders,id'],
// ↑ リクエストとして正しいか?(形式的)
];
}
}
// UseCase: ドメインバリデーションの実行
class ConfirmOrderUseCase
{
public function execute(ConfirmOrderCommand $command): void
{
$order = $this->orderRepository->findById(new OrderId($command->orderId));

// ↓ ビジネス的に許可されるか?(ビジネスルール)
$order->confirm(); // DRAFT状態でないと例外

$this->orderRepository->save($order);
}
}
// ドメインエンティティ: ビジネスルールの実装
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;
}
}

なぜ2段階に分けるのか?

  1. 早期リターン: 無効なリクエストを早い段階で弾く(パフォーマンス向上)
  2. 責務の明確化: 形式チェックとビジネスルールを分離
  3. エラーメッセージの最適化:
    • FormRequest: HTTPステータス422(Unprocessable Entity)でユーザーフレンドリーなメッセージ
    • ドメイン: ビジネスルール違反として明確なエラー(HTTPステータス400など)

Controllerの設計

薄いController(Thin Controller)の設計思想

Controllerは入出力の変換のみを担当し、ビジネスロジックを持ちません。

なぜControllerを薄く保つべきか?

「薄いController(Thin Controller)」は、MVCパターンにおける重要な設計原則です。Controllerにビジネスロジックを書くと、以下の問題が発生します。

1. テストが困難になる

// NG: Controllerにビジネスロジック
public function confirm(Request $request, int $id): JsonResponse
{
$order = Order::find($id);

// ビジネスロジックがControllerに
if ($order->status !== 'draft') {
return response()->json(['error' => '確定できません'], 400);
}
if ($order->orderLines->isEmpty()) {
return response()->json(['error' => '明細が空です'], 400);
}

$order->status = 'confirmed';
$order->save();

return response()->json(['message' => '確定しました']);
}

// 問題点:
// - ビジネスロジックのテストにHTTPリクエストが必要
// - Controllerのテストが肥大化
// - 同じロジックを別のController(管理画面など)で再利用できない
// OK: UseCaseに委譲
public function confirm(int $id): JsonResponse
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json(['message' => '確定しました']);
}

// 利点:
// - ビジネスロジックはUseCaseでテスト
// - Controllerは「HTTPリクエスト→UseCaseへの変換」のみテスト
// - UseCaseは他のController(CLI、バッチなど)からも利用可能

2. 再利用性が低い 悪い例 — ビジネスロジックが Controller に重複(同じロジックを 3 回実装):

良い例 — UseCase に集約(ビジネスロジックは 1 箇所):

3. HTTPに依存したビジネスロジック

  • ビジネスロジックがHTTPリクエスト形式に依存
  • GraphQL、gRPCなど別のプロトコルへの対応が困難
  • CLI、バッチ処理での再利用が不可能

Controllerの責務(これだけ!)

  1. リクエストのバリデーション(形式チェック)
  2. リクエストをCommandに変換
  3. UseCaseの呼び出し
  4. 結果をHTTPレスポンスに変換
  5. 認証・認可の確認

Controllerが持ってはいけないもの

  • ビジネスルール(if文での状態チェックなど)
  • データベース操作
  • 外部API呼び出し
  • ドメイン知識

薄いControllerの実装例

// app/Http/Controllers/Api/OrderController.php
final class OrderController extends Controller
{
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
private readonly ConfirmOrderUseCase $confirmOrderUseCase,
private readonly GetOrderUseCase $getOrderUseCase,
private readonly ListOrdersUseCase $listOrdersUseCase,
) {}

/**
* 注文作成
*
* @return JsonResponse 201 Created
*/
public function store(CreateOrderRequest $request): JsonResponse
{
$orderId = $this->createOrderUseCase->execute($request->toCommand());

return response()->json([
'id' => $orderId->value(),
'message' => '注文を作成しました',
], Response::HTTP_CREATED);
}

/**
* 注文確定
*/
public function confirm(int $id): JsonResponse
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));

return response()->json([
'message' => '注文を確定しました',
]);
}

/**
* 注文詳細取得
*/
public function show(int $id): OrderResource
{
$orderDto = $this->getOrderUseCase->execute($id);

return new OrderResource($orderDto);
}

/**
* 注文一覧取得
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = new ListOrdersQuery(
status: $request->query('status'),
limit: (int) $request->query('limit', 20),
offset: (int) $request->query('offset', 0),
);

$orders = $this->listOrdersUseCase->execute($query);

return OrderListResource::collection($orders);
}
}

ルーティング

// routes/api.php
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
Route::get('/{id}', [OrderController::class, 'show']);
Route::post('/{id}/confirm', [OrderController::class, 'confirm']);
Route::post('/{id}/cancel', [OrderController::class, 'cancel']);
});

API Resourceによるレスポンス構築

詳細用Resource

// app/Http/Resources/OrderResource.php
final class OrderResource extends JsonResource
{
/**
* @param OrderDto $resource
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'status' => $this->resource->status,
'shippingAddress' => $this->resource->shippingAddress,
'orderLines' => array_map(
fn($line) => [
'id' => $line->id,
'productId' => $line->productId,
'quantity' => $line->quantity,
'unitPrice' => $line->unitPrice,
'subtotal' => $line->subtotal,
],
$this->resource->orderLines
),
'totalAmount' => $this->resource->totalAmount,
'createdAt' => $this->resource->createdAt,
];
}
}

一覧用Resource

// app/Http/Resources/OrderListResource.php
final class OrderListResource extends JsonResource
{
/**
* @param OrderListItemDto $resource
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'status' => $this->resource->status,
'totalAmount' => $this->resource->totalAmount,
'createdAt' => $this->resource->createdAt,
];
}
}

レスポンスの例

// GET /api/orders/1
{
"data": {
"id": 1,
"status": "confirmed",
"shippingAddress": "東京都渋谷区1-1-1",
"orderLines": [
{
"id": 1,
"productId": 101,
"quantity": 2,
"unitPrice": 1000,
"subtotal": 2000
}
],
"totalAmount": 2000,
"createdAt": "2024-01-15 10:30:00"
}
}

// GET /api/orders
{
"data": [
{
"id": 1,
"status": "confirmed",
"totalAmount": 2000,
"createdAt": "2024-01-15 10:30:00"
},
{
"id": 2,
"status": "draft",
"totalAmount": 5000,
"createdAt": "2024-01-16 14:20:00"
}
]
}

認証・認可

ミドルウェアによる認証

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
// ...
});
});

Policyによる認可

// app/Policies/OrderPolicy.php
final class OrderPolicy
{
/**
* 注文を閲覧できるか(認可: WHO)
*/
public function view(User $user, Order $order): bool
{
// 自分の注文のみ閲覧可能
return $user->id === $order->user_id;
}

/**
* 注文を確定できるか(認可: WHO)
*
* 注意: 「DRAFT状態のみ確定可能」のような状態遷移ルールは
* ドメイン層(Order::confirm())で判定する。Policyは所有者チェックのみ。
*/
public function confirm(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}

/**
* 注文をキャンセルできるか(認可: WHO)
*
* 注意: 「発送済みはキャンセル不可」などの状態ルールは
* ドメイン層(Order::cancel())で判定する。Policyは所有者チェックのみ。
*/
public function cancel(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}
// Controller内での使用
public function show(int $id): OrderResource
{
$order = Order::findOrFail($id);

$this->authorize('view', $order);

$orderDto = $this->getOrderUseCase->execute($id);
return new OrderResource($orderDto);
}

認可とドメインルールの違い

種類担当
認可Policy「この注文を操作できるのは所有者のみ」
ドメインルールEntity「確定済みの注文には商品を追加できない」

ディレクトリ構成

app/Http/
├── Controllers/
│ └── Api/
│ ├── OrderController.php
│ └── UserController.php

├── Requests/
│ └── Order/
│ ├── CreateOrderRequest.php
│ ├── ConfirmOrderRequest.php
│ └── CancelOrderRequest.php

├── Resources/
│ └── Order/
│ ├── OrderResource.php
│ └── OrderListResource.php

└── Middleware/
└── ...

まとめ

ポイント説明
FormRequest入力の形式バリデーションを担当
薄いController入出力の変換のみ、ビジネスロジックを持たない
API Resourceレスポンスの構築を担当
Policy認可(誰が何をできるか)を担当
役割分担形式チェック→FormRequest、ビジネスルール→Domain

参考資料

Laravelのプレゼンテーション層機能についてさらに学びたい方は、公式ドキュメントを参照してください。

Laravel公式ドキュメント

次のチャプターでは、リポジトリパターンについて詳しく見ていきます。