Skip to content

Split Di into static facade + instance-based DiContainer #455

@armanist

Description

@armanist

Summary

Split the current static Di class into two parts:

  1. DiContainer — an instance-based container that holds all dependency state ($dependencies, $container, $resolving)
  2. Di — a static facade that delegates all calls to the "current" DiContainer instance

This enables each application execution to own its own isolated DI container, which is the foundation for true execution isolation.

Parent Issue

Subtask of #373 (Refactor App Bootstrapping & DI Ownership Model).

Depends on: #454 (HttpRequest/HttpResponse must be instance-based first, otherwise static HTTP state still leaks regardless of container isolation).

Problem

Today, Di is entirely static:

class Di
{
    private static array $dependencies = [];
    private static array $container = [];
    private static array $resolving = [];
    // all methods are static
}

There is exactly one global container for the entire process. Di::reset() clears it, but you cannot have two containers coexisting. This means:

  • Two application executions cannot be alive simultaneously
  • The "execution boundary" (Di::reset()) is destructive — it doesn't create a new scope, it destroys the only one

Proposed Design

DiContainer (new class)

All current Di state and logic moves into an instance-based class:

namespace Quantum\Di;

class DiContainer
{
    private array $dependencies = [];
    private array $container = [];
    private array $resolving = [];

    public function register(string $concrete, ?string $abstract = null): void { /* ... */ }
    public function get(string $dependency, array $args = []) { /* ... */ }
    public function create(string $dependency, array $args = []) { /* ... */ }
    public function set(string $abstract, object $instance, bool $override = true): void { /* ... */ }
    public function has(string $abstract): bool { /* ... */ }
    public function isRegistered(string $abstract): bool { /* ... */ }
    public function registerDependencies(array $dependencies): void { /* ... */ }
    public function autowire(callable $entry, array $args = []): array { /* ... */ }
    public function reset(): void { /* ... */ }
    public function resetContainer(): void { /* ... */ }
    // all private methods: resolve(), instantiate(), resolveParameters(), etc.
}

Di (refactored to static facade)

Di keeps its existing static API but delegates to a current DiContainer instance:

namespace Quantum\Di;

class Di
{
    private static ?DiContainer $current = null;

    public static function setCurrent(DiContainer $container): void
    {
        self::$current = $container;
    }

    public static function getCurrent(): DiContainer
    {
        return self::$current;
    }

    public static function get(string $dependency, array $args = [])
    {
        return self::$current->get($dependency, $args);
    }

    public static function set(string $abstract, object $instance, bool $override = true): void
    {
        self::$current->set($abstract, $instance, $override);
    }

    // ... all other methods delegate to self::$current
}

Backward Compatibility

Zero breaking changes. Every existing call site (Di::get(), Di::set(), Di::register(), Di::autowire(), etc.) continues to work identically. The static API is preserved — only the internal implementation changes.

Integration with AppContext

After this split, AppFactory creates a fresh DiContainer per execution:

private static function createInstance(string $type, string $baseDir): App
{
    $container = new DiContainer();
    Di::setCurrent($container);

    $context = new AppContext($type, $container);

    App::setBaseDir($baseDir);

    return new App(new $adapterClass());
}

AppContext holds a reference to its own container:

class AppContext
{
    private string $mode;
    private DiContainer $container;

    public function __construct(string $mode, DiContainer $container)
    {
        // ...
        $this->mode = $mode;
        $this->container = $container;
    }

    public function getContainer(): DiContainer
    {
        return $this->container;
    }
}

Each AppContext owns its DiContainer. Switching the active app means calling Di::setCurrent($context->getContainer()).

Tasks

  • Create DiContainer class with all instance-based logic (extracted from Di)
  • Refactor Di to static facade delegating to DiContainer::$current
  • Initialize a default DiContainer in Di (so existing code works without explicit setCurrent)
  • Update AppFactory::createInstance() to create and set a new DiContainer
  • Update Di::reset() — either resets the current container or creates a new one
  • Update DiTest to cover both DiContainer directly and Di facade
  • Verify all existing tests pass with zero call-site changes

Acceptance Criteria

  • All existing Di:: static calls work without modification
  • DiContainer can be instantiated independently (useful for testing)
  • Each AppFactory::createInstance() produces an isolated container
  • All 1227+ existing tests pass
  • PHPStan passes

Files Affected

  • src/Di/Di.php — refactored to facade
  • src/Di/DiContainer.php — new file, extracted logic
  • src/App/Factories/AppFactory.php — creates and sets container
  • tests/Unit/Di/DiTest.php — updated/expanded
  • No other files should need changes (that's the point of the facade pattern)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions