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

エラーハンドリング — ドメイン例外と HTTP ステータスの対応

エラーハンドリングの設計

DDDとクリーンアーキテクチャでは、各層に適した例外を定義し、適切にハンドリングすることが重要です。

例外階層設計の重要性

DDDアーキテクチャでは、例外の階層を明確に設計することで、エラーハンドリングを体系的に行えます。

なぜ例外階層が重要なのか

Bad: すべて一般的な例外 — すべて Exception を throw すると、エラーの種類を区別できず、ハンドリング時にメッセージ文字列で判定する必要があります。

Good: 階層的な例外設計

階層化のメリット:

  1. エラーの種類を型で判別: instanceof で適切なハンドリングが可能
  2. 集約ごとに例外を整理: ドメインの境界が明確になる
  3. 統一されたエラーコード: 機械的に判別可能なコードを一元管理
  4. HTTPステータスへのマッピングが容易: 例外の種類に応じた適切なステータスコードを返せる

ドメイン例外の設計

基底例外クラス

// app/Domain/Shared/Exception/DomainException.php
abstract class DomainException extends \Exception
{
public function __construct(
string $message,
public readonly string $errorCode,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

具体的なドメイン例外

// app/Domain/Order/Exception/OrderNotFoundException.php
final class OrderNotFoundException extends DomainException
{
public function __construct(OrderId $orderId)
{
parent::__construct(
message: "注文ID {$orderId->value()} が見つかりません",
errorCode: 'ORDER_NOT_FOUND',
);
}
}

// app/Domain/Order/Exception/InvalidOrderStateException.php
final class InvalidOrderStateException extends DomainException
{
public function __construct(string $message)
{
parent::__construct(
message: $message,
errorCode: 'INVALID_ORDER_STATE',
);
}
}

// app/Domain/Order/Exception/OrderAlreadyConfirmedException.php
final class OrderAlreadyConfirmedException extends DomainException
{
public function __construct(OrderId $orderId)
{
parent::__construct(
message: "注文ID {$orderId->value()} は既に確定されています",
errorCode: 'ORDER_ALREADY_CONFIRMED',
);
}
}

// app/Domain/Inventory/Exception/InsufficientStockException.php
final class InsufficientStockException extends DomainException
{
public function __construct(
ProductId $productId,
int $requested,
int $available,
) {
parent::__construct(
message: "商品ID {$productId->value()} の在庫が不足しています(要求: {$requested}, 在庫: {$available})",
errorCode: 'INSUFFICIENT_STOCK',
);
}
}

エンティティでの使用

// app/Domain/Order/Order.php
final class Order
{
public function confirm(): void
{
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException(
'この注文は確定できません。現在の状態: ' . $this->status->value
);
}

if (empty($this->orderLines)) {
throw new InvalidOrderStateException(
'注文明細が空の状態では確定できません'
);
}

$this->status = OrderStatus::CONFIRMED;
$this->recordEvent(new OrderConfirmed($this->id, /* ... */));
}

public function addItem(/* ... */): void
{
if ($this->status !== OrderStatus::DRAFT) {
throw new InvalidOrderStateException(
'下書き状態でのみ商品を追加できます'
);
}

// ...
}
}

アプリケーション層での例外処理

UseCaseでの例外使用

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

/**
* @throws OrderNotFoundException
* @throws InsufficientStockException
* @throws InvalidOrderStateException
*/
public function execute(ConfirmOrderCommand $command): void
{
DB::transaction(function () use ($command) {
$orderId = new OrderId($command->orderId);

// 注文が見つからない場合の例外
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);

// 在庫チェック
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId());

if ($inventory === null || !$inventory->hasStock($line->quantity())) {
throw new InsufficientStockException(
$line->productId(),
$line->quantity(),
$inventory?->stock() ?? 0,
);
}

$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory);
}

// 注文確定(ドメインルール違反時は InvalidOrderStateException)
$order->confirm();

$this->orderRepository->save($order);
});
}
}

プレゼンテーション層での例外ハンドリング

例外ハンドリングの設定(bootstrap/app.php)

Laravel 11 以降の例外ハンドリング

Laravel 11 で app/Exceptions/Handler.php は廃止されました。例外ハンドリングは bootstrap/app.php->withExceptions() で設定します。例外の種類ごとに $exceptions->render() で JSON レスポンスへの変換を登録します。参考: Laravel 11 Error Handling

// bootstrap/app.php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; // HTTP_NOT_FOUND などの定数
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Validation\ValidationException;
use App\Domain\Shared\Exception\DomainException;
use App\Domain\Order\Exception\OrderNotFoundException;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\Order\Exception\OrderAlreadyConfirmedException;
use App\Domain\Inventory\Exception\InsufficientStockException;

return Application::configure(basePath: dirname(__DIR__))
// ->withRouting(...) / ->withMiddleware(...) は省略
->withExceptions(function (Exceptions $exceptions) {
// ドメイン例外 → エラーコードと適切な HTTP ステータスに変換
$exceptions->render(function (DomainException $e, Request $request) {
// JSON を期待しないリクエストは通常のレンダリングに任せる(null を返す)
if (! $request->expectsJson()) {
return null;
}

$status = match (true) {
$e instanceof OrderNotFoundException => Response::HTTP_NOT_FOUND, // 404
$e instanceof InsufficientStockException => Response::HTTP_CONFLICT, // 409
$e instanceof OrderAlreadyConfirmedException => Response::HTTP_CONFLICT, // 409
$e instanceof InvalidOrderStateException => Response::HTTP_UNPROCESSABLE_ENTITY, // 422
default => Response::HTTP_BAD_REQUEST, // 400
};

return response()->json([
'error' => $e->getMessage(),
'code' => $e->errorCode,
], $status);
});

// バリデーション例外 → API 全体でエラー形式を統一('error' / 'code')
$exceptions->render(function (ValidationException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return response()->json([
'error' => 'バリデーションエラー',
'code' => 'VALIDATION_ERROR',
'details' => $e->errors(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
});

// 認証例外(401)
$exceptions->render(function (AuthenticationException $e, Request $request) {
return $request->expectsJson()
? response()->json(['error' => '認証が必要です', 'code' => 'AUTHENTICATION_REQUIRED'], Response::HTTP_UNAUTHORIZED)
: null;
});

// 認可例外(403)
$exceptions->render(function (AuthorizationException $e, Request $request) {
return $request->expectsJson()
? response()->json(['error' => 'この操作を行う権限がありません', 'code' => 'AUTHORIZATION_DENIED'], Response::HTTP_FORBIDDEN)
: null;
});

// 上記以外の予期しない例外は Laravel 標準の処理に委ね、本番では 500 を返す
})->create();

HTTPステータスコードの対応表

REST APIでは、例外の種類に応じて適切なHTTPステータスコードを返すことが重要です。

HTTPステータス意味使用する場面例外の例
400 Bad Requestリクエストが不正パラメータの形式が間違っている一般的なバリデーションエラー
401 Unauthorized認証が必要ログインが必要なエンドポイントAuthenticationException
403 Forbidden権限がないリソースへのアクセス権限がないAuthorizationException
404 Not Foundリソースが存在しない指定されたIDのデータが見つからないOrderNotFoundException
409 Conflict状態の競合リソースの状態が競合しているInsufficientStockException
OptimisticLockException
422 Unprocessable Entityビジネスルール違反入力は正しいがビジネスロジックで拒否InvalidOrderStateException
ValidationException
500 Internal Server Errorサーバー内部エラー予期しないエラーException(予期しない例外)
400 vs 422 の使い分け
  • 400 Bad Request: リクエストの形式自体が不正(JSONが壊れている、必須パラメータがないなど)
  • 422 Unprocessable Entity: リクエストの形式は正しいが、内容がビジネスルールに反する(発送済み注文をキャンセルしようとするなど)

LaravelのValidationExceptionは422を返します。

404 vs 409 の使い分け
  • 404 Not Found: リソースが存在しない(注文IDが見つからない)
  • 409 Conflict: リソースは存在するが、状態が競合している(在庫不足、楽観的ロック失敗)

例: 注文を確定しようとしたが、在庫が不足していた → 409 Conflict

ドメイン例外とHTTPステータスコードのマッピング

例外HTTPステータス意味
OrderNotFoundException404 Not Foundリソースが存在しない
InvalidOrderStateException422 Unprocessable Entityビジネスルール違反
InsufficientStockException409 Conflict状態の競合
OptimisticLockException409 Conflict同時更新の競合
ValidationException422 Unprocessable Entity入力形式エラー
AuthenticationException401 Unauthorized認証が必要
AuthorizationException403 Forbidden権限がない

エラーレスポンスの形式

統一されたエラーレスポンス

// 基本形式
{
"error": "エラーメッセージ(人間が読める形式)",
"code": "ERROR_CODE"
}

// バリデーションエラー
{
"error": "バリデーションエラー",
"code": "VALIDATION_ERROR",
"details": {
"shipping.prefecture": ["都道府県は必須です"],
"items": ["注文には1つ以上の商品が必要です"]
}
}

// 在庫不足エラー
{
"error": "商品ID 101 の在庫が不足しています(要求: 5, 在庫: 2)",
"code": "INSUFFICIENT_STOCK"
}

Result型パターン(オプション)

例外を使わずに、Result型でエラーを表現する方法もあります。

// app/Domain/Shared/Result.php
final class Result
{
private function __construct(
public readonly bool $isSuccess,
public readonly mixed $value,
public readonly ?string $error,
public readonly ?string $errorCode,
) {}

public static function success(mixed $value = null): self
{
return new self(true, $value, null, null);
}

public static function failure(string $error, string $errorCode): self
{
return new self(false, null, $error, $errorCode);
}

public function isFailure(): bool
{
return !$this->isSuccess;
}
}
// UseCaseでの使用
final class ConfirmOrderUseCase
{
public function execute(ConfirmOrderCommand $command): Result
{
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId);

if ($order === null) {
return Result::failure(
"注文ID {$orderId->value()} が見つかりません",
'ORDER_NOT_FOUND'
);
}

if (!$order->canBeConfirmed()) {
return Result::failure(
'この注文は確定できません',
'INVALID_ORDER_STATE'
);
}

// ...

return Result::success();
}
}

// Controllerでの使用
public function confirm(int $id): JsonResponse
{
$result = $this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));

if ($result->isFailure()) {
return response()->json([
'error' => $result->error,
'code' => $result->errorCode,
], $this->getStatusCode($result->errorCode));
}

return response()->json(['message' => '注文を確定しました']);
}
例外 vs Result型
  • 例外: PHPの標準的なエラー処理。予期しないエラーに適している。
  • Result型: 関数型プログラミングのアプローチ。予期されるエラーを明示的に扱う。

本書では例外を使ったアプローチを採用していますが、プロジェクトの方針に応じてResult型を採用できます。

ロギング戦略

何をログに残すべきか

エラーハンドリングにおいて、適切なロギングは問題の早期発見と原因究明に不可欠です。

[Logging Levels]
DEBUG 詳細な診断情報(開発時のみ)
INFO 通常の動作情報
WARNING 予期されるエラー(ビジネスルール違反など)
ERROR 予期しないエラー(システムエラーなど)
CRITICAL システムダウンにつながる重大なエラー

ログレベルの使い分け

ログレベル使用する場面
WARNINGビジネスルール違反(予期されるエラー)注文キャンセル失敗、在庫不足
ERRORシステムエラー(予期しないエラー)DB接続失敗、外部API呼び出し失敗
CRITICALサービス継続不可能なエラーDBが完全にダウン、ディスク容量不足

ドメイン例外のロギング

ロギングも bootstrap/app.php->withExceptions() 内で $exceptions->report() を使って登録します。

// bootstrap/app.php の withExceptions 内
->withExceptions(function (Exceptions $exceptions) {
// ... render() の登録 ...

// ドメイン例外は警告レベルでログ(予期されるエラー)
$exceptions->report(function (DomainException $e) {
Log::warning('Domain exception occurred', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'code' => $e->errorCode,
'user_id' => auth()->id(), // ユーザーコンテキスト
'url' => request()->url(), // リクエストURL
'trace' => $e->getTraceAsString(), // スタックトレース(本番ではペイロードが大きくなるため、ログレベルや保持期間で制御)
]);

// false を返すと、この例外の以降の標準レポート処理を停止する
return false;
});

// 上記以外の予期しない例外は Laravel 標準のエラーログに記録される
})->create();

ログに含めるべき情報

必須情報:

  • 例外クラス名: エラーの種類を特定
  • エラーメッセージ: 何が起きたかを記述
  • エラーコード: 機械的に判別可能なコード
  • スタックトレース: エラーの発生箇所を特定

コンテキスト情報(推奨):

  • ユーザーID: 誰がエラーに遭遇したか
  • リクエストURL: どのエンドポイントでエラーが発生したか
  • リクエストパラメータ: どのような入力でエラーになったか(機密情報は除外)
  • タイムスタンプ: いつエラーが発生したか

機密情報をログに含めない

// BAD: パスワードやトークンをログに残さない
Log::error('Login failed', [
'password' => $request->password, // ✗ 機密情報
'api_token' => $user->api_token, // ✗ 機密情報
]);

// GOOD: 機密情報を除外
Log::error('Login failed', [
'email' => $request->email, // ✓ 識別に必要な情報のみ
'ip_address' => $request->ip(),
]);
個人情報のログ取り扱い

ログに含まれる個人情報(メールアドレス、IPアドレス、ユーザーID など)は、日本では 個人情報保護法、EU向けサービスでは GDPR の対象になります。保持期間の設定、アクセス権限の制限、削除ポリシーなどを運用で整備してください。迷う場合は「この情報がないと調査できないか?」を自問し、最小限に留めるのが原則です。

ログの集約と監視

本番環境では、ログを集約・監視するツールの利用を推奨します。

ログ集約ツールの例:

  • AWS CloudWatch Logs: AWS環境での標準的な選択肢
  • Datadog: 多機能なモニタリングサービス
  • Sentry: エラートラッキングに特化

ECSでコンテナ化したアプリのログをCloudWatch Logsに送る具体的な設定 (awslogsドライバ、IAMロールの権限設計) は、AWS実践ガイド 第8章: ECS 前編 で扱っています。

// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'sentry'], // 複数のログ出力先を設定
],

'sentry' => [
'driver' => 'sentry',
],
],

まとめ

ポイント説明
例外階層集約ごとに例外を整理し、型で判別可能にする
ドメイン例外ビジネスルール違反を表現する専用の例外クラス
エラーコード機械的に判別可能なコードを付与
HTTPステータス例外の種類に応じた適切なステータスコード(404, 409, 422など)
統一されたレスポンスerror, code を含む一貫した形式
ロギング例外の種類に応じた適切なログレベル(WARNING, ERROR)
機密情報の保護パスワードやトークンをログに含めない

参考リソース

次のチャプターでは、DDDアーキテクチャにおけるテスト戦略について詳しく見ていきます。