Skip to main content

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:

  1. Distinguish error types by type: appropriate handling is possible with instanceof
  2. Organize exceptions per aggregate: the domain boundaries become clear
  3. Unified error codes: centrally manage machine-distinguishable codes
  4. 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)

Exception handling in Laravel 11 and later

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 statusMeaningWhen to useException example
400 Bad RequestThe request is invalidThe parameter format is wrongA general validation error
401 UnauthorizedAuthentication requiredAn endpoint that requires loginAuthenticationException
403 ForbiddenNo permissionNo access permission to the resourceAuthorizationException
404 Not FoundThe resource does not existData with the specified ID is not foundOrderNotFoundException
409 ConflictA state conflictThe resource's state conflictsInsufficientStockException
OptimisticLockException
422 Unprocessable EntityA business-rule violationThe input is correct, but the business logic rejects itInvalidOrderStateException
ValidationException
500 Internal Server ErrorAn internal server errorAn unexpected errorException (an unexpected exception)
Choosing between 400 and 422
  • 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.

Choosing between 404 and 409
  • 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

ExceptionHTTP statusMeaning
OrderNotFoundException404 Not FoundThe resource does not exist
InvalidOrderStateException422 Unprocessable EntityA business-rule violation
InsufficientStockException409 ConflictA state conflict
OptimisticLockException409 ConflictA concurrent-update conflict
ValidationException422 Unprocessable EntityAn input-format error
AuthenticationException401 UnauthorizedAuthentication required
AuthorizationException403 ForbiddenNo 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 vs the Result type
  • 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 levelWhen to useExample
WARNINGBusiness-rule violations (expected errors)Order-cancellation failure, insufficient stock
ERRORSystem errors (unexpected errors)DB connection failure, external API call failure
CRITICALErrors that make the service unable to continueThe 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(),
]);
Handling personal information in logs

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

PointDescription
Exception hierarchyOrganize exceptions per aggregate and make them distinguishable by type
Domain exceptionsDedicated exception classes that express business-rule violations
Error codesAttach machine-distinguishable codes
HTTP statusesAn appropriate status code based on the exception type (404, 409, 422, etc.)
Unified responseA consistent format including error and code
LoggingAn appropriate log level based on the exception type (WARNING, ERROR)
Protecting sensitive informationDo not include passwords or tokens in logs

Reference resources

In the next chapter, we will take a detailed look at testing strategy in a DDD architecture.