Files
michaelschiemer/docs/exception-hierarchy-pattern.md
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

18 KiB

Exception Hierarchy Pattern Guide

Comprehensive guide for creating module-specific exception hierarchies in the Custom PHP Framework.

Overview

The framework uses a module-based exception hierarchy pattern that provides:

  • Type-safe exception handling with domain-specific exceptions
  • Rich error context with ErrorCode integration
  • Clean API using factory methods
  • Automatic HTTP status mapping and retry strategies
  • Structured logging with severity levels

Architecture

FrameworkException (Base)
    └── ModuleException (e.g., QueueException)
        ├── SpecificException1 (e.g., JobNotFoundException)
        ├── SpecificException2 (e.g., ChainNotFoundException)
        └── SpecificException3 (e.g., InvalidChainStateException)

Key Components

  1. FrameworkException: Base exception with ErrorCode and ExceptionContext
  2. ModuleException: Module-specific base (e.g., QueueException, DatabaseException)
  3. Specific Exceptions: Domain-specific exceptions with factory methods
  4. ErrorCode Enum: Category-specific error codes (e.g., QueueErrorCode, DatabaseErrorCode)

Creating a Module Exception Hierarchy

Step 1: Create Module Base Exception

<?php

declare(strict_types=1);

namespace App\Framework\YourModule\Exceptions;

use App\Framework\Exception\FrameworkException;

/**
 * Base exception for YourModule
 *
 * All YourModule-related exceptions should extend this class.
 */
class YourModuleException extends FrameworkException
{
}

Location: src/Framework/YourModule/Exceptions/YourModuleException.php

Naming Convention: {ModuleName}Exception

Step 2: Add Module-Specific Error Codes

Add error codes to the appropriate ErrorCode enum (or create a new one):

<?php

namespace App\Framework\Exception\Core;

enum YourModuleErrorCode: string implements ErrorCode
{
    case RESOURCE_NOT_FOUND = 'MODULE001';
    case INVALID_STATE = 'MODULE002';
    case OPERATION_FAILED = 'MODULE003';

    public function getValue(): string
    {
        return $this->value;
    }

    public function getCategory(): string
    {
        return 'MODULE';
    }

    public function getNumericCode(): int
    {
        return (int) substr($this->value, -3);
    }

    public function getSeverity(): ErrorSeverity
    {
        return match($this) {
            self::RESOURCE_NOT_FOUND => ErrorSeverity::ERROR,
            self::INVALID_STATE => ErrorSeverity::WARNING,
            self::OPERATION_FAILED => ErrorSeverity::CRITICAL,
        };
    }

    public function getDescription(): string
    {
        return match($this) {
            self::RESOURCE_NOT_FOUND => 'Resource not found in module',
            self::INVALID_STATE => 'Operation not allowed in current state',
            self::OPERATION_FAILED => 'Module operation failed',
        };
    }

    public function getRecoveryHint(): string
    {
        return match($this) {
            self::RESOURCE_NOT_FOUND => 'Verify resource ID and check if resource exists',
            self::INVALID_STATE => 'Check current state and verify operation prerequisites',
            self::OPERATION_FAILED => 'Review logs and retry operation',
        };
    }

    public function isRecoverable(): bool
    {
        return match($this) {
            self::OPERATION_FAILED => false,
            default => true,
        };
    }

    public function getRetryAfterSeconds(): ?int
    {
        return match($this) {
            self::OPERATION_FAILED => 60,
            default => null,
        };
    }
}

Location: src/Framework/Exception/Core/YourModuleErrorCode.php

Error Code Format: {CATEGORY}{NUMBER} (e.g., MODULE001, MODULE002)

Severity Levels:

  • CRITICAL: System failure, immediate action required
  • ERROR: Operation failed, attention needed
  • WARNING: Potential issue, monitor
  • INFO: Informational, no action needed
  • DEBUG: Debugging information

Step 3: Create Specific Exceptions

Create domain-specific exceptions with factory methods:

<?php

declare(strict_types=1);

namespace App\Framework\YourModule\Exceptions;

use App\Framework\Exception\Core\YourModuleErrorCode;
use App\Framework\Exception\ExceptionContext;

/**
 * Exception thrown when a resource is not found
 */
final class ResourceNotFoundException extends YourModuleException
{
    public static function byId(ResourceId $id): self
    {
        $context = ExceptionContext::forOperation('resource.lookup', 'ResourceService')
            ->withData([
                'resource_id' => $id->toString(),
                'search_type' => 'by_id',
            ]);

        return self::create(
            YourModuleErrorCode::RESOURCE_NOT_FOUND,
            "Resource with ID '{$id->toString()}' not found",
            $context
        );
    }

    public static function byName(string $name): self
    {
        $context = ExceptionContext::forOperation('resource.lookup', 'ResourceService')
            ->withData([
                'resource_name' => $name,
                'search_type' => 'by_name',
            ]);

        return self::create(
            YourModuleErrorCode::RESOURCE_NOT_FOUND,
            "Resource with name '{$name}' not found",
            $context
        );
    }
}

Location: src/Framework/YourModule/Exceptions/ResourceNotFoundException.php

Step 4: Use Exceptions in Code

Replace generic exceptions with domain-specific ones:

// ❌ Before: Generic RuntimeException
public function findResource(ResourceId $id): Resource
{
    $resource = $this->repository->find($id);

    if ($resource === null) {
        throw new \RuntimeException("Resource {$id->toString()} not found");
    }

    return $resource;
}

// ✅ After: Domain-specific exception with factory method
use App\Framework\YourModule\Exceptions\ResourceNotFoundException;

public function findResource(ResourceId $id): Resource
{
    $resource = $this->repository->find($id);

    if ($resource === null) {
        throw ResourceNotFoundException::byId($id);
    }

    return $resource;
}

Factory Method Patterns

Single Factory Method

For simple cases with one creation scenario:

final class ConfigurationMissingException extends YourModuleException
{
    public static function forKey(string $key): self
    {
        $context = ExceptionContext::forOperation('config.load', 'ConfigService')
            ->withData(['config_key' => $key]);

        return self::create(
            YourModuleErrorCode::CONFIG_MISSING,
            "Configuration key '{$key}' is missing",
            $context
        );
    }
}

Multiple Factory Methods

For different creation scenarios:

final class InvalidStateException extends YourModuleException
{
    public static function notReady(string $resourceId, string $currentState): self
    {
        $context = ExceptionContext::forOperation('resource.start', 'ResourceService')
            ->withData([
                'resource_id' => $resourceId,
                'current_state' => $currentState,
                'required_state' => 'ready',
            ]);

        return self::create(
            YourModuleErrorCode::INVALID_STATE,
            "Resource '{$resourceId}' is not ready (current: {$currentState})",
            $context
        );
    }

    public static function alreadyCompleted(string $resourceId): self
    {
        $context = ExceptionContext::forOperation('resource.modify', 'ResourceService')
            ->withData([
                'resource_id' => $resourceId,
                'current_state' => 'completed',
            ]);

        return self::create(
            YourModuleErrorCode::INVALID_STATE,
            "Resource '{$resourceId}' is already completed",
            $context
        );
    }
}

Type-Safe Factory Methods

Accept Value Objects for type safety:

final class UserNotFoundException extends AuthModuleException
{
    public static function byId(UserId $userId): self
    {
        $context = ExceptionContext::forOperation('user.lookup', 'UserRepository')
            ->withData(['user_id' => $userId->toString()]);

        return self::create(
            AuthErrorCode::USER_NOT_FOUND,
            "User with ID '{$userId->toString()}' not found",
            $context
        );
    }

    public static function byEmail(Email $email): self
    {
        $context = ExceptionContext::forOperation('user.lookup', 'UserRepository')
            ->withData(['email' => $email->getMasked()]);

        return self::create(
            AuthErrorCode::USER_NOT_FOUND,
            "User with email not found",
            $context
        );
    }
}

ExceptionContext Best Practices

Operation and Component

Always specify operation and component for traceability:

$context = ExceptionContext::forOperation(
    'order.process',      // Operation: what was being attempted
    'OrderService'        // Component: which class/service
);

Context Data

Add relevant data for debugging:

$context->withData([
    'order_id' => $orderId,
    'customer_id' => $customerId,
    'total_amount' => $totalAmount,
    'payment_method' => $paymentMethod,
]);

Debug Information

Add internal details for development (automatically filtered in production):

$context->withDebug([
    'query' => $sql,
    'bind_params' => $params,
    'execution_time_ms' => $executionTime,
]);

Metadata

Add structured metadata:

$context->withMetadata([
    'attempt' => $attemptNumber,
    'max_attempts' => $maxAttempts,
    'retry_strategy' => 'exponential_backoff',
]);

Exception Catching Patterns

Catch Specific Exception

try {
    $resource = $this->resourceService->find($id);
} catch (ResourceNotFoundException $e) {
    // Handle specific exception
    return $this->notFoundResponse($e->getMessage());
}

Catch Module-Level Exception

try {
    $result = $this->moduleService->process($data);
} catch (YourModuleException $e) {
    // Handle any exception from this module
    $this->logger->error('Module operation failed', [
        'error_code' => $e->getErrorCode()->getValue(),
        'message' => $e->getMessage(),
    ]);
    throw $e;
}

Catch by Category

try {
    $result = $this->service->execute();
} catch (FrameworkException $e) {
    if ($e->isCategory('DB')) {
        // Handle database errors
        return $this->databaseErrorResponse();
    }

    if ($e->isCategory('QUEUE')) {
        // Handle queue errors
        return $this->queueErrorResponse();
    }

    throw $e;
}

Catch by Specific ErrorCode

try {
    $resource = $this->service->find($id);
} catch (FrameworkException $e) {
    if ($e->isErrorCode(YourModuleErrorCode::RESOURCE_NOT_FOUND)) {
        // Handle specific error code
        return $this->notFoundResponse();
    }

    throw $e;
}

ErrorHandler Integration

The ErrorHandler automatically leverages the exception hierarchy:

Automatic HTTP Status Mapping

// ErrorCode category determines HTTP status:
// AUTH -> 401 Unauthorized
// AUTHZ -> 403 Forbidden
// VAL -> 400 Bad Request
// DB/QUEUE/CACHE -> 500 Internal Server Error

Automatic Severity-Based Logging

// ErrorCode.getSeverity() determines log level:
// CRITICAL -> critical log
// ERROR -> error log
// WARNING -> warning log
// INFO -> info log
// DEBUG -> debug log

Automatic Retry-After Headers

// ErrorCode.getRetryAfterSeconds() adds Retry-After header:
if ($errorCode->getRetryAfterSeconds() !== null) {
    $response->headers['Retry-After'] = $retryAfter;
}

Recovery Hints in Debug Mode

// ErrorCode.getRecoveryHint() included in debug responses:
$metadata['recovery_hint'] = $errorCode->getRecoveryHint();

Migration Checklist

When migrating a module to the new exception hierarchy:

  • Create module base exception ({Module}Exception extends FrameworkException)
  • Add module-specific error codes to {Module}ErrorCode enum
  • Implement all required ErrorCode methods (getValue, getCategory, getSeverity, etc.)
  • Create specific exceptions with factory methods
  • Replace RuntimeException with domain-specific exceptions
  • Replace InvalidArgumentException in business logic (keep for constructor validation)
  • Add comprehensive ExceptionContext to all exceptions
  • Test exception creation and ErrorHandler integration
  • Update module documentation

Common Patterns

Infrastructure Exceptions

For missing dependencies or configuration:

final class RedisExtensionNotLoadedException extends QueueException
{
    public static function create(): self
    {
        $context = ExceptionContext::forOperation('queue.init', 'QueueInitializer')
            ->withData([
                'required_extension' => 'redis',
                'loaded_extensions' => get_loaded_extensions(),
                'fallback_available' => 'FileQueue',
            ]);

        return self::fromContext(
            'Redis PHP extension is not loaded',
            $context,
            QueueErrorCode::WORKER_UNAVAILABLE
        );
    }
}

State Transition Exceptions

For invalid state transitions:

final class AllStepsCompletedException extends QueueException
{
    public static function forJob(string $jobId, int $totalSteps): self
    {
        $context = ExceptionContext::forOperation('step.complete', 'StepProgressTracker')
            ->withData([
                'job_id' => $jobId,
                'total_steps' => $totalSteps,
                'current_step_index' => $totalSteps,
            ]);

        return self::create(
            QueueErrorCode::INVALID_STATE,
            "All {$totalSteps} steps have already been completed for job '{$jobId}'",
            $context
        );
    }
}

Validation Exceptions

For business rule violations:

final class CircularDependencyException extends QueueException
{
    public static function inChain(string $chainId, array $circularDependencies = []): self
    {
        $context = ExceptionContext::forOperation('chain.validate', 'JobChainExecutionCoordinator')
            ->withData([
                'chain_id' => $chainId,
                'circular_dependencies' => $circularDependencies,
            ]);

        return self::create(
            QueueErrorCode::CIRCULAR_DEPENDENCY,
            "Chain '{$chainId}' has circular dependencies",
            $context
        );
    }
}

Anti-Patterns to Avoid

Don't Use FrameworkException::create() Directly

// ❌ BAD: Clutters business logic
throw FrameworkException::create(
    ErrorCode::RESOURCE_NOT_FOUND,
    "Resource not found"
)->withContext(
    ExceptionContext::forOperation('resource.lookup', 'ResourceService')
        ->withData(['resource_id' => $id])
);

// ✅ GOOD: Clean factory method
throw ResourceNotFoundException::byId($id);

Don't Create Anonymous Exceptions

// ❌ BAD: Anonymous exception, no reusability
throw new class extends FrameworkException {
    // ...
};

// ✅ GOOD: Named exception class
throw ResourceNotFoundException::byId($id);

Don't Mix Concerns in Exception Names

// ❌ BAD: Too specific, hard to reuse
final class UserNotFoundInDatabaseByEmailException extends AuthException { }

// ✅ GOOD: Generic, multiple factory methods
final class UserNotFoundException extends AuthException
{
    public static function byId(UserId $id): self { }
    public static function byEmail(Email $email): self { }
}

Don't Skip ExceptionContext

// ❌ BAD: No context
return self::create(
    ErrorCode::RESOURCE_NOT_FOUND,
    "Resource not found"
);

// ✅ GOOD: Rich context
$context = ExceptionContext::forOperation('resource.lookup', 'ResourceService')
    ->withData(['resource_id' => $id]);

return self::create(
    ErrorCode::RESOURCE_NOT_FOUND,
    "Resource with ID '{$id}' not found",
    $context
);

Benefits

Type Safety: Catch specific exceptions, not generic RuntimeException Clean API: throw ResourceNotFoundException::byId($id) vs manual setup Rich Context: Automatic operation, component, and data tracking Automatic HTTP Status: ErrorCode category determines response status Structured Logging: Severity-based logging with rich context Retry Strategies: Automatic Retry-After headers for recoverable errors Recovery Hints: Actionable hints for developers in debug mode Category-Based Handling: Catch exceptions by error category Testability: Easy to mock and test specific exception scenarios Documentation: Self-documenting exception hierarchy

Real-World Example: Queue Module

The Queue module demonstrates the complete pattern:

// Base exception
class QueueException extends FrameworkException { }

// Specific exceptions
final class JobNotFoundException extends QueueException
{
    public static function byId(JobId $jobId): self { }
    public static function inQueue(JobId $jobId, string $queueName): self { }
}

final class ChainNotFoundException extends QueueException
{
    public static function byId(string $chainId): self { }
    public static function byName(string $name): self { }
}

final class InvalidChainStateException extends QueueException
{
    public static function notPending(string $chainId, string $currentStatus): self { }
    public static function alreadyCompleted(string $chainId): self { }
    public static function alreadyFailed(string $chainId): self { }
}

// Usage
throw JobNotFoundException::byId($jobId);
throw ChainNotFoundException::byName($chainName);
throw InvalidChainStateException::notPending($chainId, $status);

See Also