# 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 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 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: ```php // ❌ 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: ```php 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: ```php 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: ```php 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: ```php $context = ExceptionContext::forOperation( 'order.process', // Operation: what was being attempted 'OrderService' // Component: which class/service ); ``` ### Context Data Add relevant data for debugging: ```php $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): ```php $context->withDebug([ 'query' => $sql, 'bind_params' => $params, 'execution_time_ms' => $executionTime, ]); ``` ### Metadata Add structured metadata: ```php $context->withMetadata([ 'attempt' => $attemptNumber, 'max_attempts' => $maxAttempts, 'retry_strategy' => 'exponential_backoff', ]); ``` ## Exception Catching Patterns ### Catch Specific Exception ```php try { $resource = $this->resourceService->find($id); } catch (ResourceNotFoundException $e) { // Handle specific exception return $this->notFoundResponse($e->getMessage()); } ``` ### Catch Module-Level Exception ```php 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 ```php 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 ```php 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 ```php // 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 ```php // ErrorCode.getSeverity() determines log level: // CRITICAL -> critical log // ERROR -> error log // WARNING -> warning log // INFO -> info log // DEBUG -> debug log ``` ### Automatic Retry-After Headers ```php // ErrorCode.getRetryAfterSeconds() adds Retry-After header: if ($errorCode->getRetryAfterSeconds() !== null) { $response->headers['Retry-After'] = $retryAfter; } ``` ### Recovery Hints in Debug Mode ```php // 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: ```php 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: ```php 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: ```php 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 ```php // ❌ 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 ```php // ❌ 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 ```php // ❌ 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 ```php // ❌ 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: ```php // 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 - [ErrorHandler Enhancements Guide](./error-handler-enhancements.md) - [Developer Migration Guide](./exception-migration-guide.md) - [ErrorCode Reference](./error-code-reference.md)