エラーハンドリング — ドメイン例外と HTTP ステータスの対応
エラーハンドリングの設計
DDDとクリーンアーキテクチャでは、各層に適した例外を定義し、適切にハンドリングすることが重要です。
例外階層設計の重要性
DDDアーキテクチャでは、例外の階層を明確に設計することで、エラーハンドリングを体系的に行えます。
なぜ例外階層が重要なのか
Bad: すべて一般的な例外 — すべて Exception を throw すると、エラーの種類を区別できず、ハンドリング時にメッセージ文字列で判定する必要があります。
Good: 階層的な例外設計
階層化のメリット:
- エラーの種類を型で判別:
instanceofで適切なハンドリングが可能 - 集約ごとに例外を整理: ドメインの境界が明確になる
- 統一されたエラーコード: 機械的に判別可能なコードを一元管理
- 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 で 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 | 状態の競合 | リソースの状態が競合している | InsufficientStockExceptionOptimisticLockException |
| 422 Unprocessable Entity | ビジネスルール違反 | 入力は正しいがビジネスロジックで拒否 | InvalidOrderStateExceptionValidationException |
| 500 Internal Server Error | サーバー内部エラー | 予期しないエラー | Exception(予期しない例外) |
- 400 Bad Request: リクエストの形式自体が不正(JSONが壊れている、必須パラメータがないなど)
- 422 Unprocessable Entity: リクエストの形式は正しいが、内容がビジネスルールに反する(発送済み注文をキャンセルしようとするなど)
LaravelのValidationExceptionは422を返します。
- 404 Not Found: リソースが存在しない(注文IDが見つからない)
- 409 Conflict: リソースは存在するが、状態が競合している(在庫不足、楽観的ロック失敗)
例: 注文を確定しようとしたが、在庫が不足していた → 409 Conflict
ドメイン例外とHTTPステータスコードのマッピング
| 例外 | HTTPステータス | 意味 |
|---|---|---|
OrderNotFoundException | 404 Not Found | リソースが存在しない |
InvalidOrderStateException | 422 Unprocessable Entity | ビジネスルール違反 |
InsufficientStockException | 409 Conflict | 状態の競合 |
OptimisticLockException | 409 Conflict | 同時更新の競合 |
ValidationException | 422 Unprocessable Entity | 入力形式エラー |
AuthenticationException | 401 Unauthorized | 認証が必要 |
AuthorizationException | 403 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' => '注文を確定しました']);
}
- 例外: 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) |
| 機密情報の保護 | パスワードやトークンをログに含めない |
参考リソース
- Laravel Exception Handling - Laravel公式ドキュメント
- Laravel Logging - Laravel公式ドキュメント
- HTTP Status Codes - MDN - HTTPステータスコードの詳細
次のチャプターでは、DDDアーキテクチャにおけるテスト戦略について詳しく見ていきます。