Changes to how the container caches shared instances and resolves classes. Shared instances are kept in one instance cache type, held twice for strong and weak retention. Class-level marker attributes are memoised as presence flags, and whether a resolution is liminal is worked out from every source before the cache is checked. Circular dependencies are detected, and reported with the chain of classes involved.
The container keeps shared instances in four separate caches, and the logic that picks one is written twice, once for reading and once for writing, so the two can disagree.
When a resolution is checked against the cache, only what the resolution itself says is known. Whether its binding or its class makes it liminal is found out afterwards, once the cache has already been consulted.
A class that depends on itself, directly or through other classes, exhausts the call stack, and nothing reports which classes are involved.
| Term | Meaning |
|---|---|
| Instance cache | A store of shared instances, keyed by class, and by name or qualifier class where a resolution has one. |
| Retention | Whether an instance cache holds its instances strongly, or weakly through weak references. |
| Effective liminality | Whether a resolution is liminal, worked out from the resolution, its binding and its class. |
| Resolution stack | The resolutions being constructed eagerly, in the order resolution reached them. |
| Circular dependency | A resolution whose eager construction requires the same resolution again, directly or through others. |
| Component | Responsibility |
|---|---|
InstanceCache |
Holds shared instances under a class, and a name or qualifier class, with strong or weak retention. |
Container |
Holds a strong and a weak instance cache, the memoised class attribute flags, and the resolution stack. |
BindingCatalogue |
Exposes alias normalisation, so the container can normalise a class before consulting the cache. |
CircularDependencyException |
Thrown when a circular dependency is found. |
final class InstanceCache
{
public static function strong(): self;
public static function weak(): self;
public function get(string $class, ?string $name = null, ?string $qualifier = null): ?object;
public function put(string $class, object $instance, ?string $name = null, ?string $qualifier = null): void;
}
| Method | Effect |
|---|---|
strong() |
Static. Creates a cache that holds its instances strongly. |
weak() |
Static. Creates a cache that holds its instances through weak references. |
get(string $class, ?string $name = null, ?string $qualifier = null): ?object |
Returns the instance stored under the class, and the name or qualifier class where one is given, or null. |
put(string $class, object $instance, ?string $name = null, ?string $qualifier = null): void |
Stores the instance under the class, and the name or qualifier class where one is given. |
A cache holds one instance per class, one per class and name, and one per class and qualifier class. The qualifier is its class, so a qualified instance is found by a direct lookup, as the qualifier’s class is the key in RFC-0001.
Retention is chosen when the cache is created. Under weak retention the weak reference is internal: get() returns
the instance, typed as the class requested, or null, both when nothing was stored and when the stored instance has
been collected.
The cache knows nothing of bindings or aliases. It stores and returns under whatever class it is given, and the container normalises the class first.
The container holds two caches:
private InstanceCache $instances = InstanceCache::strong();
private InstanceCache $liminalInstances = InstanceCache::weak();
Reading and writing both pick a cache by effective liminality, then delegate to it, so the choice is made in one place. A liminal resolution and a shared resolution of the same class occupy separate entries.
The container memoises, for each class, whether it carries NoResolution, Lazy and Liminal, per
ADR-0014. The flags come from one unfiltered
getAttributes() pass matched on getName() the first time the class is resolved, and no attribute is instantiated.
Later resolutions of the class read the flags without reflection.
Nothing is memoised about parameters, per ADR-0020.
These steps replace the resolution steps of RFC-0001:
Liminal, read from the memoised flags. This is worked out once, and used for both reading and writing
the cache.NoResolution,
read from the memoised flags, 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.Lazy, read from the memoised flags, is always resolved as a lazy proxy.The container holds a resolution stack. Each resolution adds an entry when it starts, and removes it when it finishes, whether it returns or throws. An entry is identified the same way as a shared instance in the instance cache: by the class after alias normalisation, and the name or qualifier class where the resolution has one.
A resolution whose entry is already on the stack throws CircularDependencyException. Its message names every class
in the chain in order, as each was requested, from the first appearance of the entry to its repetition, such as A,
B, C and then A again.
Only eager construction of the same resolution is detected:
Cache, whose constructor takes the Cache binding named
inner, resolves normally.Lazy and Ghost still break a cycle.| Exception | Extends | Thrown when |
|---|---|---|
CircularDependencyException |
RuntimeException |
A class being constructed eagerly is required again before its construction finishes. It carries the chain of classes in order, and implements ContainerException. |
The decisions this design rests on are recorded separately, with the alternatives each one rejected:
One cache holding every instance through weak references. A weak reference is pointless beside a strong one in the same store, and a liminal and a shared resolution of one class are different cache identities: resolving a class without liminality should still cache it normally.
Choosing retention with an enum or a flag. Static factories follow the container’s own convention, such as
Resolution::for() and Invocation::callable().
No other alternatives were weighed.
Nothing that resolves today breaks. A circular dependency throws CircularDependencyException instead of exhausting
the call stack.
get() returns
null for it. Whether such entries are removed when a read finds them, or kept and documented, is not settled.BindingCatalogue, and
chained aliases left to building catalogues.CircularDependencyException carrying the chain in order, lazy
proxies and ghosts still breaking cycles, and sequential resolution of one class not counting as a cycle.