feat: implement exception handling system with error context and policies

This commit is contained in:
2025-11-01 15:46:43 +01:00
parent f3440dff0d
commit a441da37f6
35 changed files with 920 additions and 88 deletions

View File

@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Framework\DI;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\Exceptions\ClassNotInstantiable;
use App\Framework\DI\Exceptions\ClassNotResolvableException;
use App\Framework\DI\Exceptions\ClassResolutionException;
use App\Framework\DI\Exceptions\ContainerException;
use App\Framework\DI\Exceptions\CyclicDependencyException;
use App\Framework\DI\Exceptions\LazyLoadingException;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Metrics\FrameworkMetricsCollector;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\Reflection\ReflectionProvider;
use Stringable;
use Throwable;
final class DefaultContainer implements Container
@@ -54,8 +60,9 @@ final class DefaultContainer implements Container
$this->instance(ReflectionProvider::class, $this->reflectionProvider);
}
public function bind(string $abstract, callable|string|object $concrete): void
public function bind(ClassName|Stringable|string $abstract, callable|string|object $concrete): void
{
$abstract = (string) $abstract;
$this->bindings->bind($abstract, $concrete);
// Only clear caches for valid class names, skip string keys like 'filesystem.storage.local'
@@ -64,23 +71,26 @@ final class DefaultContainer implements Container
}
}
public function singleton(string $abstract, callable|string|object $concrete): void
public function singleton(ClassName|Stringable|string $abstract, callable|string|object $concrete): void
{
$abstract = (string) $abstract;
$this->bind($abstract, $concrete);
$this->instances->markAsSingleton($abstract);
}
public function instance(string $abstract, object $instance): void
public function instance(ClassName|Stringable|string $abstract, object $instance): void
{
$this->instances->setInstance($abstract, $instance);
}
/**
* @inheritDoc
* @template T of object
* @param class-string<T>|ClassName|Stringable $class
* @return T
*/
public function get(string|ClassName $class): object
public function get(ClassName|Stringable|string $class): object
{
$className = $class instanceof ClassName ? $class->toString() : $class;
$className = (string) $class;
// Bereits instanziierte Objekte zurückgeben
if ($this->instances->hasSingleton($className)) {
@@ -154,9 +164,9 @@ final class DefaultContainer implements Container
// For string keys without bindings, throw immediately
if (!class_exists($class) && !interface_exists($class)) {
throw new \RuntimeException(
"Cannot resolve '{$class}': not a valid class and no binding exists. " .
"Available bindings: " . implode(', ', array_keys($this->bindings->getAllBindings()))
throw new ClassNotResolvableException(
class: $class,
availableBindings: array_keys($this->bindings->getAllBindings())
);
}
@@ -174,27 +184,16 @@ final class DefaultContainer implements Container
$dependencies = $this->dependencyResolver->resolveDependencies($className);
return $reflection->newInstance(...$dependencies->toArray());
} catch (\RuntimeException $e) {
// If it's already our detailed exception, just re-throw
if (str_contains($e->getMessage(), 'Dependency resolution chain:')) {
throw $e;
}
// Otherwise, wrap with binding information
throw new \RuntimeException(
"Cannot resolve class '{$class}': {$e->getMessage()}. " .
"Available bindings: " . implode(', ', array_keys($this->bindings->getAllBindings())) .
". Dependency chain: " . implode(' -> ', $this->resolving),
0,
$e
);
} catch (\ReflectionException $e) {
throw new \RuntimeException(
"Cannot resolve class '{$class}': {$e->getMessage()}. " .
"Available bindings: " . implode(', ', array_keys($this->bindings->getAllBindings())) .
". Dependency chain: " . implode(' -> ', $this->resolving),
0,
$e
} catch (ContainerException $e) {
// If it's already a ContainerException, just re-throw
throw $e;
} catch (\RuntimeException|\ReflectionException $e) {
// Wrap with binding information
throw ClassResolutionException::fromRuntimeException(
class: $class,
exception: $e,
availableBindings: array_keys($this->bindings->getAllBindings()),
dependencyChain: $this->resolving
);
}
}
@@ -202,38 +201,67 @@ final class DefaultContainer implements Container
private function throwDetailedBindingException(string $class/*, $reflection*/): never
{
$availableBindings = array_keys($this->bindings->getAllBindings());
$dependencyChain = implode(' -> ', $this->resolving);
// Look for similar interface bindings
$similarBindings = array_filter($availableBindings, function ($binding) use ($class) {
return str_contains($binding, basename(str_replace('\\', '/', $class)));
});
// Try to get DiscoveryRegistry from container and include discovered initializers
$discoveredInitializers = [];
if ($this->has(DiscoveryRegistry::class)) {
try {
$discoveryRegistry = $this->get(DiscoveryRegistry::class);
$initializerResults = $discoveryRegistry->attributes->get(Initializer::class);
$message = "Cannot instantiate class '{$class}': class is not instantiable (interface, abstract class, or trait).\n" .
"Dependency resolution chain: {$dependencyChain}\n" .
"Total available bindings: " . count($availableBindings) . "\n";
if (! empty($similarBindings)) {
$message .= "Similar bindings found: " . implode(', ', $similarBindings) . "\n";
if (! empty($initializerResults)) {
$discoveredInitializers = array_map(
fn($attr) => $attr->className->getFullyQualified(),
$initializerResults
);
}
} catch (\Throwable $e) {
// Silently ignore errors when trying to access DiscoveryRegistry to avoid masking the original error
}
}
$message .= "All bindings: " . implode(', ', $availableBindings);
throw new \RuntimeException($message);
throw ClassNotInstantiable::fromContainerContext(
class: $class,
dependencyChain: $this->resolving,
availableBindings: $availableBindings,
discoveredInitializers: $discoveredInitializers
);
}
private function resolveBinding(string $class, callable|string|object $concrete): object
{
return match (true) {
is_callable($concrete) => $concrete($this),
is_string($concrete) => $this->get($concrete),
default => $concrete
};
try {
return match (true) {
is_callable($concrete) => $concrete($this),
is_string($concrete) => $this->get($concrete),
/* @var object $concrete */
default => $concrete
};
} catch (ContainerException $e) {
// Re-throw ContainerExceptions as-is (they already have proper context)
throw $e;
} catch (\Throwable $e) {
// Determine binding type for better error messages
$bindingType = match (true) {
is_callable($concrete) => 'callable',
is_string($concrete) => 'string',
default => 'object'
};
throw ClassResolutionException::fromBindingResolution(
class: $class,
previous: $e,
availableBindings: array_keys($this->bindings->getAllBindings()),
dependencyChain: $this->resolving,
bindingType: $bindingType
);
}
}
/** @param class-string $class */
public function has(string $class): bool
public function has(ClassName|Stringable|string $class): bool
{
$class = (string) $class;
return $this->instances->hasSingleton($class)
|| $this->instances->hasInstance($class)
|| $this->bindings->hasBinding($class)
@@ -241,8 +269,9 @@ final class DefaultContainer implements Container
}
/** @param class-string $class */
public function forget(string $class): void
public function forget(ClassName|Stringable|string $class): void
{
$class = (string) $class;
$this->instances->forget($class);
$this->bindings->forget($class);
$this->clearCaches(ClassName::create($class));