Error Handling — Mapping Domain Exceptions to HTTP Statuses
Designing error handling
In DDD and clean architecture, it is important to define exceptions suited to each layer and handle them appropriately.
The importance of designing the exception hierarchy
In a DDD architecture, by clearly designing the exception hierarchy, you can handle errors systematically.
Why an exception hierarchy is important
Bad: everything is a generic exception — if you throw Exception for everything, you cannot distinguish the type of error, and you have to judge by the message string when handling it.
Good: a hierarchical exception design
Benefits of hierarchy:
- Distinguish error types by type: appropriate handling is possible with
instanceof - Organize exceptions per aggregate: the domain boundaries become clear
- Unified error codes: centrally manage machine-distinguishable codes
- Easy mapping to HTTP statuses: return an appropriate status code based on the exception type
Designing domain exceptions
The base exception class
// 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);
}
}
Concrete domain exceptions
// app/Domain/Order/Exception/OrderNotFoundException.php
final class OrderNotFoundException extends DomainException
{
public function __construct(OrderId $orderId)
{
parent::__construct(
message: "Order ID {$orderId->value()} not found",
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: "Order ID {$orderId->value()} is already confirmed",
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: "Insufficient stock for product ID {$productId->value()} (requested: {$requested}, available: {$available})",
errorCode: 'INSUFFICIENT_STOCK',
);
}
}
Usage in the entity
// app/Domain/Order/Order.php
final class Order
{
public function confirm(): void
{
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException(
'This order cannot be confirmed. Current status: ' . $this->status->value
);
}
if (empty($this->orderLines)) {
throw new InvalidOrderStateException(
'Cannot confirm an order with no line items'
);
}
$this->status = OrderStatus::CONFIRMED;
$this->recordEvent(new OrderConfirmed($this->id, /* ... */));
}
public function addItem(/* ... */): void
{
if ($this->status !== OrderStatus::DRAFT) {
throw new InvalidOrderStateException(
'Items can only be added while in draft state'
);
}
// ...
}
}
Exception handling in the application layer
Using exceptions in the 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);
// Exception when the order is not found
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// Stock check
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);
}
// Confirm the order (InvalidOrderStateException on a domain-rule violation)
$order->confirm();
$this->orderRepository->save($order);
});
}
}
Exception handling in the presentation layer
Configuring exception handling (bootstrap/app.php)
In Laravel 11, app/Exceptions/Handler.php was removed. Exception handling is configured in bootstrap/app.php via ->withExceptions(). For each exception type, register a conversion to a JSON response with $exceptions->render(). Reference: 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; // constants such as 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(...) are omitted
->withExceptions(function (Exceptions $exceptions) {
// Domain exception → convert to an error code and an appropriate HTTP status
$exceptions->render(function (DomainException $e, Request $request) {
// For requests that do not expect JSON, leave it to the normal rendering (return 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);
});
// Validation exception → unify the error format across the API ('error' / 'code')
$exceptions->render(function (ValidationException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return response()->json([
'error' => 'Validation error',
'code' => 'VALIDATION_ERROR',
'details' => $e->errors(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
});
// Authentication exception (401)
$exceptions->render(function (AuthenticationException $e, Request $request) {
return $request->expectsJson()
? response()->json(['error' => 'Authentication required', 'code' => 'AUTHENTICATION_REQUIRED'], Response::HTTP_UNAUTHORIZED)
: null;
});
// Authorization exception (403)
$exceptions->render(function (AuthorizationException $e, Request $request) {
return $request->expectsJson()
? response()->json(['error' => 'You do not have permission to perform this action', 'code' => 'AUTHORIZATION_DENIED'], Response::HTTP_FORBIDDEN)
: null;
});
// Other unexpected exceptions are left to Laravel's standard handling and return 500 in production
})->create();
The HTTP status code mapping table
In a REST API, it is important to return an appropriate HTTP status code depending on the type of exception.
| HTTP status | Meaning | When to use | Exception example |
|---|---|---|---|
| 400 Bad Request | The request is invalid | The parameter format is wrong | A general validation error |
| 401 Unauthorized | Authentication required | An endpoint that requires login | AuthenticationException |
| 403 Forbidden | No permission | No access permission to the resource | AuthorizationException |
| 404 Not Found | The resource does not exist | Data with the specified ID is not found | OrderNotFoundException |
| 409 Conflict | A state conflict | The resource's state conflicts | InsufficientStockExceptionOptimisticLockException |
| 422 Unprocessable Entity | A business-rule violation | The input is correct, but the business logic rejects it | InvalidOrderStateExceptionValidationException |
| 500 Internal Server Error | An internal server error | An unexpected error | Exception (an unexpected exception) |
- 400 Bad Request: the request format itself is invalid (broken JSON, missing required parameters, etc.)
- 422 Unprocessable Entity: the request format is correct, but the content violates a business rule (e.g. trying to cancel a shipped order)
Laravel's ValidationException returns 422.
- 404 Not Found: the resource does not exist (the order ID is not found)
- 409 Conflict: the resource exists, but its state conflicts (insufficient stock, optimistic-lock failure)
Example: tried to confirm an order, but stock was insufficient → 409 Conflict
Mapping domain exceptions to HTTP status codes
| Exception | HTTP status | Meaning |
|---|---|---|
OrderNotFoundException | 404 Not Found | The resource does not exist |
InvalidOrderStateException | 422 Unprocessable Entity | A business-rule violation |
InsufficientStockException | 409 Conflict | A state conflict |
OptimisticLockException | 409 Conflict | A concurrent-update conflict |
ValidationException | 422 Unprocessable Entity | An input-format error |
AuthenticationException | 401 Unauthorized | Authentication required |
AuthorizationException | 403 Forbidden | No permission |
The error response format
A unified error response
// Basic format
{
"error": "An error message (human-readable)",
"code": "ERROR_CODE"
}
// Validation error
{
"error": "Validation error",
"code": "VALIDATION_ERROR",
"details": {
"shipping.prefecture": ["Prefecture is required"],
"items": ["An order requires at least one item"]
}
}
// Insufficient-stock error
{
"error": "Insufficient stock for product ID 101 (requested: 5, available: 2)",
"code": "INSUFFICIENT_STOCK"
}
The Result type pattern (optional)
There is also a way to express errors with a Result type instead of exceptions.
// 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;
}
}
// Usage in the 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(
"Order ID {$orderId->value()} not found",
'ORDER_NOT_FOUND'
);
}
if (!$order->canBeConfirmed()) {
return Result::failure(
'This order cannot be confirmed',
'INVALID_ORDER_STATE'
);
}
// ...
return Result::success();
}
}
// Usage in the 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' => 'Order confirmed']);
}
- Exceptions: PHP's standard error handling. Suited to unexpected errors.
- Result type: a functional-programming approach. Handles expected errors explicitly.
This book adopts an exception-based approach, but you can adopt the Result type depending on your project's policy.
A logging strategy
What you should log
In error handling, appropriate logging is essential for early detection and root-cause analysis of problems.
[Logging Levels]
DEBUG Detailed diagnostic information (development only)
INFO Normal operation information
WARNING Expected errors (business-rule violations, etc.)
ERROR Unexpected errors (system errors, etc.)
CRITICAL Critical errors that lead to a system outage
Choosing between log levels
| Log level | When to use | Example |
|---|---|---|
| WARNING | Business-rule violations (expected errors) | Order-cancellation failure, insufficient stock |
| ERROR | System errors (unexpected errors) | DB connection failure, external API call failure |
| CRITICAL | Errors that make the service unable to continue | The DB is completely down, disk space exhausted |
Logging domain exceptions
Logging is also registered inside bootstrap/app.php's ->withExceptions() using $exceptions->report().
// Inside withExceptions in bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
// ... registration of render() ...
// Log domain exceptions at the warning level (expected errors)
$exceptions->report(function (DomainException $e) {
Log::warning('Domain exception occurred', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'code' => $e->errorCode,
'user_id' => auth()->id(), // user context
'url' => request()->url(), // request URL
'trace' => $e->getTraceAsString(), // stack trace (in production the payload grows large, so control it with the log level or retention period)
]);
// Returning false stops the subsequent standard reporting for this exception
return false;
});
// Other unexpected exceptions are recorded in Laravel's standard error log
})->create();
Information that should be included in the log
Required information:
- Exception class name: identifies the type of error
- Error message: describes what happened
- Error code: a machine-distinguishable code
- Stack trace: identifies where the error occurred
Context information (recommended):
- User ID: who encountered the error
- Request URL: at which endpoint the error occurred
- Request parameters: with what input the error occurred (exclude sensitive information)
- Timestamp: when the error occurred
Do not include sensitive information in logs
// BAD: do not log passwords or tokens
Log::error('Login failed', [
'password' => $request->password, // ✗ sensitive information
'api_token' => $user->api_token, // ✗ sensitive information
]);
// GOOD: exclude sensitive information
Log::error('Login failed', [
'email' => $request->email, // ✓ only the information needed for identification
'ip_address' => $request->ip(),
]);
Personal information included in logs (email addresses, IP addresses, user IDs, etc.) is subject to the Act on the Protection of Personal Information in Japan, and to GDPR for services aimed at the EU. Set up retention periods, restrict access permissions, and establish deletion policies operationally. When in doubt, ask yourself "can I not investigate without this information?" and keep it to a minimum as a principle.
Log aggregation and monitoring
In production, we recommend using tools to aggregate and monitor logs.
Examples of log aggregation tools:
- AWS CloudWatch Logs: the standard choice in an AWS environment
- Datadog: a feature-rich monitoring service
- Sentry: specialized in error tracking
The concrete setup for sending logs from an ECS-containerized app to CloudWatch Logs (the awslogs driver, and IAM role permission design) is covered in Practical AWS Guide Chapter 8: ECS Part 1.
// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'sentry'], // configure multiple log destinations
],
'sentry' => [
'driver' => 'sentry',
],
],
Summary
| Point | Description |
|---|---|
| Exception hierarchy | Organize exceptions per aggregate and make them distinguishable by type |
| Domain exceptions | Dedicated exception classes that express business-rule violations |
| Error codes | Attach machine-distinguishable codes |
| HTTP statuses | An appropriate status code based on the exception type (404, 409, 422, etc.) |
| Unified response | A consistent format including error and code |
| Logging | An appropriate log level based on the exception type (WARNING, ERROR) |
| Protecting sensitive information | Do not include passwords or tokens in logs |
Reference resources
- Laravel Exception Handling - the Laravel official documentation
- Laravel Logging - the Laravel official documentation
- HTTP Status Codes - MDN - details of HTTP status codes
In the next chapter, we will take a detailed look at testing strategy in a DDD architecture.