The Game Panel specification

RFC-0001: Dependency injection container

Abstract

A dependency injection container that constructs objects and supplies their dependencies. Classes are resolved automatically from their constructor signatures, or through bindings that supply a concrete class, an instance or a factory. Resolved instances are shared by default and can be held weakly, resolution can be deferred through lazy proxies and ghost objects, and any callable, method or constructor can be invoked with its parameters supplied by the container.

Motivation

The panel needs a dependency injection solution. It needs to be:

Proposal

Concepts

Term Meaning
Resolution Producing an instance of a class, from a binding or automatically from its constructor.
Invocation Calling a callable, method or constructor with its parameters supplied by the container.
Abstract The class or interface requested.
Concrete The class instantiated in place of an abstract.
Binding Registered resolution information for one abstract.
Alias Another class name that resolves through the same binding.
Named binding A child binding of an abstract, selected by a string name.
Qualified binding A child binding of an abstract, selected by a qualifier attribute.
Shared Resolved once, cached, and returned on every later resolution.
Liminal Shared, but held through a weak reference, so it can be garbage collected once nothing else uses it.
Lazy proxy An object that stands in for an instance not yet resolved, and forwards to that instance once it is.
Ghost object An object of the final class left uninitialised, which becomes the instance itself once initialised.
Dependency A parameter the container has to supply.
Resolver An object that resolves a dependency.
Resolvable attribute A parameter attribute that selects a custom resolver for that dependency.

Components

Component Responsibility
Container Resolves classes and invokes callables. Holds the binding and resolver catalogues it is constructed with, and caches shared instances.
Resolution Describes a request to resolve a class.
Invocation Describes a callable, method or constructor to invoke, with any arguments supplied up front.
Dependency Describes one parameter to supply: its name, type, default, and the attributes that affect its resolution.
BindingRegistry, BindingBuilder Mutable. Collect binding definitions.
Binding Immutable. The binding for one abstract, with its named and qualified child bindings.
BindingCatalogue Immutable. The bindings and aliases the container is constructed with, looked up by class, name or qualifier.
Resolver Contract for resolving a dependency.
Resolvable Marker contract for attributes that select a custom resolver.
ResolverRegistry Mutable. Collects the default resolver and the resolver for each resolvable attribute.
ResolverCatalogue Immutable mapping of resolvable attribute to resolver, plus the default resolver, from which the container obtains resolver instances.
GenericResolver The default resolver.
GhostResolver The resolver for the Ghost attribute.
Named, Qualifier Select named and qualified bindings for a parameter.
Lazy, Liminal, NoResolution Attributes the container handles itself.
Ghost A resolvable attribute, handled by GhostResolver.
ContainerException Marker contract implemented by every exception the container throws.

Bindings

A binding is registered per abstract through a BindingRegistry. Each call to bind() returns a new BindingBuilder; several builders for the same abstract accumulate, which is how named and qualified child bindings sit alongside the main binding.

$registry->bind(Contract::class)->to(Implementation::class);
$registry->bind(Contract::class)->to(new Implementation());
$registry->bind(Contract::class)->using(fn (Dependency $dependency) => new Implementation($dependency));
$registry->bind(Contract::class)->to(Implementation::class)->as(OtherContract::class);
$registry->bind(Contract::class)->named('secondary')->to(SecondaryImplementation::class);
$registry->bind(Contract::class)->qualifier(Archive::class)->to(ArchiveImplementation::class);
$registry->bind(Report::class)->notShared();
$registry->bind(Contract::class)->to(Implementation::class)->liminal();
$registry->bind(Contract::class)->to(Implementation::class)->lazily();
Builder method Effect
to(string\|object $concrete) A class-string sets the concrete class. An object sets both the concrete class, as its class, and the instance.
as(string ...$aliases) Sets the aliases, replacing any set before. When a concrete class is set, it is also an alias.
using(Closure $factory) Resolves the binding by invoking the factory through the container, so the factory’s own parameters are supplied.
named(string $name) Identifies the binding as the child selected by that name.
qualifier(string $qualifier) Identifies the binding as the child selected by that qualifier attribute class.
liminal() Holds the shared instance weakly.
lazily() Resolves the binding as a lazy proxy.
notShared() Resolves a new instance every time.

A Binding holds the abstract, the concrete class, the instance, the aliases, the factory, a map of named child bindings keyed by name, a map of qualified child bindings keyed by qualifier class, and the liminal, lazy and shared flags. Bindings are shared unless marked otherwise.

BindingCatalogue::get(string $class, ?Named $named = null, ?Qualifier $qualifier = null) looks a binding up:

  1. An alias is replaced by the abstract it points to.
  2. With no binding for the class, the result is null.
  3. With a name, the result is the named child binding, or null.
  4. With a qualifier, the result is the child binding for the qualifier’s class, or null.
  5. Otherwise, the result is the binding itself.

Named and qualified bindings

Named bindings suit registries and managers, where resolved instances share a class but their contents depend on the name. A dependency selects a named binding with the Named attribute, and a resolution with named().

public function __construct(
    #[Named('replica')] private Connection $connection,
) {}

Qualified bindings suit cases where more than a name is needed to resolve. A qualifier is an attribute class implementing Qualifier, and the qualifier’s class is the key: it selects the child binding, and any value the attribute carries is ignored. The attribute is unique to one binding outcome, so one qualifier class cannot be reused across different types. A dependency selects a qualified binding by carrying the attribute, and a resolution with qualifiedBy().

#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class Archive implements Qualifier {}

public function __construct(
    #[Archive] private Storage $storage,
) {}

Shared instances of a qualified binding are cached per qualifier class. Selecting an instance by a value, such as a region, is the job of a custom resolver. A dependency cannot be both named and qualified.

Resolution

A resolution is requested with Container::resolve(), describing it with a Resolution:

$instance = $container->resolve(Resolution::for(Contract::class));

$instance = $container->resolve(
    Resolution::for(Contract::class)
        ->with(['name' => 'value'])
        ->named('secondary')
        ->lazily(),
);
Resolution method Effect
for(string $class) Starts a resolution for the class.
with(array $arguments) Supplies constructor arguments by parameter name, merged with any supplied before.
named(string $name) Selects the named binding.
qualifiedBy(Qualifier $qualifier) Selects the qualified binding.
resolveWith(Resolvable $resolvable) Resolves the class through the resolver paired with the resolvable attribute.
lazily() Returns a lazy proxy.
liminal() Holds the shared instance weakly.

Resolving a class follows these steps:

  1. Cached instance. If a shared instance is cached for the resolution, it is returned: by name for a named resolution, by qualifier class for a qualified one, from the weak cache for a liminal one while the instance is still alive, and otherwise by class.
  2. Lazy proxy. If the resolution is lazy, a lazy proxy is returned, and the steps below run when it is first used.
  3. Binding. The binding for the class, name and qualifier is looked up.
  4. Instance. If the binding holds an instance, that instance is the result. If it has a factory, the factory is invoked through the container and its return value is the result. If it has a concrete class, that class is constructed in place of the abstract.
  5. Automatic construction. Otherwise the class is constructed by the container. A class marked NoResolution cannot be, and throws. A class that is not instantiable throws. A class with no constructor is instantiated directly, and a class with one has its constructor invoked through the container, so its parameters are supplied as described under Invocation.
  6. Class attributes. A class marked Lazy is always resolved as a lazy proxy, and a class marked Liminal is always held weakly, regardless of any binding or argument.
  7. Sharing. Unless a binding marks it not shared, the instance is cached: weakly if liminal, by name or by qualifier where the resolution has one, and otherwise by class. The cache key is the binding’s abstract, or the requested class where there is no binding.

When a lazy proxy is first used, it resolves the same resolution with lazy resolution skipped, so the proxy’s own initialisation cannot produce another proxy.

A resolution given a resolvable attribute with resolveWith() is resolved through the resolver paired with that attribute, as a dependency carrying the attribute would be.

Lazy proxies

Lazy resolution uses the lazy objects introduced in PHP 8.4. A lazy proxy is always a proxy: when a property is first accessed, the container resolves the real instance, and the proxy forwards to it from then on. A class with no properties that could trigger initialisation is resolved straight away.

Ghost objects

A ghost is an object of the concrete class, created uninitialised. When it is first accessed, its constructor is invoked on that same object through the container, and from then on it is the final instance in every meaningful way, rather than a proxy in front of one. A dependency is resolved as a ghost with the Ghost attribute:

public function __construct(
    #[Ghost] private ExpensiveService $service,
) {}

GhostResolver uses the binding’s concrete class where there is one, and the parameter’s class otherwise. A ghost can only be created for a class or interface type.

Invocation

Any callable, method or constructor can be invoked with its parameters supplied by the container, describing the call with an Invocation:

$result = $container->invoke(Invocation::callable($closure));
$result = $container->invoke(Invocation::method($object, 'handle'));
$result = $container->invoke(Invocation::method(Handler::class, 'handle')->with(['input' => $input]));
$object = $container->invoke(Invocation::constructor(Implementation::class));
Invocation method Effect
callable(callable $function) Invokes a callable.
method(object\|string $on, string $method) Invokes a method on an object or a class.
constructor(string\|object $class) Invokes a class’s constructor.
with(array $arguments) Supplies arguments by parameter name, merged with any supplied before.

Invoking follows these rules:

Parameters are supplied in declaration order:

  1. A parameter with an argument supplied under its name receives that argument.
  2. A variadic parameter ends the list. Variadic parameters are never supplied by the container.
  3. Any other parameter becomes a Dependency, carrying its name, type, whether it is optional, its default, and its Named, qualifier, resolvable and Liminal attributes. The dependency is resolved by the resolver for its resolvable attribute if it has one, and by the default resolver otherwise.

Resolvers

interface Resolver
{
    public function resolve(Dependency $dependency, Container $container, array $arguments = []): mixed;
}

A ResolverRegistry sets the default resolver and registers a resolver against each resolvable attribute class. The ResolverCatalogue the container is constructed with maps each resolvable attribute to its resolver. A resolver is itself resolved through the container the first time it is needed, and the same instance is reused afterwards. A dependency carrying a resolvable attribute with no registered resolver throws.

Ghost is the resolvable attribute defined here, paired with GhostResolver. Other components define their own resolvable attributes and resolvers in the same way.

GenericResolver, the default, resolves a dependency by its type:

Attributes

Attribute Targets Meaning
Named(string $name) Parameter Resolves the dependency from the named binding.
A Qualifier attribute Parameter Resolves the dependency from the qualified binding.
Lazy Class, parameter On a class, the class is always resolved as a lazy proxy. On a parameter, that dependency is resolved as a lazy proxy.
Liminal Class, parameter On a class, the class is always held weakly. On a parameter, that dependency is resolved liminally.
NoResolution Class, parameter On a class, the class is never constructed automatically: it must be bound with custom resolution logic, or have a factory bound to it. On a parameter, that dependency is never resolved automatically: it has to be supplied as an argument, and resolving it without one throws.
Ghost Parameter Resolves the dependency as a ghost object, through GhostResolver.

Lazy, Liminal and NoResolution are handled by the container itself, as special cases of resolution, and do not go through resolvers. Ghost is the first attribute to use the custom resolver mechanism.

Caching

Shared instances live for the lifetime of the container:

Instances that are not shared are never cached. Resolver instances are cached by the resolver catalogue.

Errors

Every exception implements ContainerException.

Exception Thrown when
UnresolvableClassException A class or dependency marked NoResolution would be resolved automatically.
NotInstantiableException A class to be constructed is abstract, an interface, or otherwise not instantiable.
DependencyResolutionException A dependency cannot be resolved: a type with no default that does not allow null, an unsatisfiable intersection or union type, a ghost requested for a non-class type, or a dependency both named and qualified.
InvalidResolverException A dependency’s resolvable attribute has no registered resolver.
InvalidInvocationException A method to be invoked is not public, or an invocation is not callable or does not name a method.
InvalidClassException, InvalidMethodException A class or method cannot be reflected.
MethodCallException Calling a reflected method fails.

Out of scope

Alternatives considered

The decisions this design rests on are recorded separately, with the alternatives each one rejected:

No other alternatives were weighed.

Backwards compatibility

Nothing breaks. This is the first component, and the repository has no source code yet.

Open questions

Changelog

Sources