- 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.
685 lines
18 KiB
Markdown
685 lines
18 KiB
Markdown
# 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
|
|
<?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
|
|
<?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
|
|
<?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:
|
|
|
|
```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)
|