Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/div0.13.0· Architecture

Architecture — @codefast/di

How to read this file. These are working notes. Their job is to help the next person (possibly you, months from now) build an accurate mental model of the engine before changing it. They are not a fence around the code.

Three kinds of statement are mixed together below, and each is labelled so you can tell them apart:

  • Invariant — a correctness property that other parts of the engine, or the tests, depend on. Break one and something resolves the wrong binding or throws where it should not. Almost every invariant here names the test that pins it.
  • Convention / Heuristic — a shape kept for performance. It was true when written and it is re-measurable. Treat any "this shape is faster" claim as a pointer to go measure it against the benchmark suite, not as settled fact.
  • Practical note — advice, and the occasional lesson the engine has already taught once.

The honest summary: understand the shape and its invariants first, then change whatever you have a good reason to.

What this document is. It describes what the shape is and what it guarantees. What a shape costs, and whether a new idea beats it, is an empirical question. The benchmark suite in benchmarks/di answers it, and re-running the suite is how you check. No figure belongs in this file: a cost claim has to be re-measurable, and numbers belong with the method that produced them (the suite, its RESULTS.md ledger, or the commit).

Overview — the mental model

@codefast/di is a dependency-injection container. You register bindings (a token → how to build its value) into a registry, and you resolve a token to get a value. Containers form a parent chain; a child that does not own a token asks its parent. The value-building work lives in one engine class, DependencyResolver, which has a synchronous pipeline and an asynchronous one.

A single container.resolve(token) goes through these steps:

  1. Find the binding. A small cache answers the common case; otherwise the registry's indexes and the container chain are consulted.
  2. Select among candidates when a token has several bindings, using the request's criteria (name, tags) and any when() predicates.
  3. Dispatch on the binding's kind and scope: a constant is returned, a cached singleton is read off the binding, a factory is called, a class is constructed.
  4. Build dependencies for a class or a toResolved descriptor. A static subgraph is compiled once into a plan; anything the compiler cannot see through re-enters the interpreted resolver.
  5. Guard against cycles along the way, using the fact that synchronous code runs on one call stack.
  6. Run activation hooks if the binding or container has any, then return the value.

Four ideas hold the whole design together, and every later section is an elaboration of one of them:

  • One binding shape, one owner. Every binding is built at one construction site and belongs to exactly one container. Caches and singleton slots live on the binding itself because of this.
  • Two dependency sources, one slot shape. A constructor parameter and a toResolved descriptor are the same structure to the engine, so every rule about dependencies exists once.
  • Fast lanes share semantics with the full path. A fast lane is allowed to answer sooner, never differently. The matching rules exist in exactly one place, and the fast lanes call them.
  • Cycle detection follows the call stack. Where code runs on one synchronous call stack, a flag or an array is the ancestor chain. Where it does not (after an await), ancestors have to be carried explicitly.

A few V8 terms used below. V8 is Node's JavaScript engine. It gives every object a hidden class describing its property layout; code that reads a property from objects of one hidden class is monomorphic and fast, while code that sees many layouts becomes megamorphic and falls back to slower generic lookups. The per-site record of what shapes a read has seen is its type feedback (kept in an inline cache). Objects that survive long enough are moved from new space to old space; storing a pointer into an old-space object costs a write barrier, a small bookkeeping step for the garbage collector. Finally, V8 inlines small hot functions into their callers, and a function that grows past a size heuristic stops being inlined. Each term is explained again briefly where it matters.

Layered architecture

The six levels

Dependencies point downward only. Nothing below knows about anything above. An upward value import is a violation; a type-only one erases at build time and couples nothing. pnpm cli:audit:layers holds the order, from this package's audit.layers entry in codefast.config.js, so a new family under src/ is placed in a layer before CI goes green.

container/             Container, fluent binding chain, the functions over a container ← the public surface
  ↓
introspection/         inspector, explain, validation, dependency graph, diagnostics, adapters
  ↓
resolution/            DependencyResolver + its collaborators                       ← the engine
  ↓
lifecycle/             LifecycleManager, ScopeManager                               ← per-container state
ambient-container.ts   the container an @inject accessor reads
decorators/            @injectable, @inject, lifecycle decorators
  ↓
metadata/              the reader port, its default reader, a foreign reader's verification
  ↓
core/                  token, types, tag, binding and its chain contract, registry, module   ← the model
errors.ts              the taxonomy
injection/             the dependency slot every dependency normalises to, and its descriptor

Reading from the bottom:

  • core/, errors.ts, injection/ are the model: what a token, binding, registry and dependency are.
  • metadata/ is the port the engine reads a class through: the MetadataReader interface, the default reader over Symbol.metadata, and the verification a foreign reader's answers pass once. Nothing in it knows the engine.
  • lifecycle/, ambient-container.ts and decorators/ hold per-container state (scopes, activation hooks), the "current container" an @inject accessor reads from, and the decorators that write metadata. A decorator needs the ambient container and the metadata keys, never the engine: an @inject accessor's initializer resolves through the container the engine installed, so the engine and the decorator both depend on ambient-container.ts and neither on the other.
  • resolution/ is the engine.
  • introspection/ reads the engine's model through the engine's own selection and contexts, and never through the container.
  • container/ sits on top because it composes everything below: @injectable's registry, the metadata readers, the engine and the introspection modules. Checking and describing a container (validate, inspect, explain, …) are functions in introspect.ts, not methods, so a bundle that only binds and resolves leaves the introspection modules out; they read the container's private state through containerInternals and containerInspector, which call static methods of the unexported class, so nothing outside the package reaches it. Each builds what it hands out once per container, since everything in it is fixed when the container is constructed. tests/e2e/tree-shaking.test.ts bundles the built package with Rolldown and pins that a program which only binds and resolves keeps none of them.

The top two levels are not on any hot path, which is why they read as peripheral. They are still ordered, and an import the other way is a violation.

A directory names a family of two or more modules, and a lone module sits flat at its layer, which is why errors.ts and ambient-container.ts are files. A directory carries the topic and a file its role, so no file repeats its directory's name.

Three places that name a rule

Most directories name a topic. Three places name a rule instead:

  • errors.ts is cold by construction. The hot path imports the error constructors and nothing else. Building a message is something an error path can afford, and a hot function's prefix cannot, so message building happens at the throw site.
  • injection/ is the one shape both dependency sources normalise to. Because of it, the model can read a dependency without reaching up into decorators/.
  • resolution/ groups by lane — cache/, plan/, select/, and path.ts — because the reasoning about the engine is per-lane, not per-noun.

Every module is an entry point

package.json#exports is generated by codefast mirror from dist/, and codefast.config.js carries no entry for this package: no exclusion, no renamed specifier. This repo is its own sole consumer, so full access beats encapsulation. The root export additionally re-exports everything a typical consumer needs.

One naming consequence follows: the map is the source tree, so reorganising src/ renames published specifiers, and a rename ships with a changeset that lists them.

Practical note. package.json#exports is generated — re-run pnpm cli:mirror rather than hand-editing the map. If a refactor moves a module, pnpm cli:mirror:preview shows renamed specifiers before you commit.

Build and type-level facts

The build runs isolatedDeclarations, so every exported value carries an explicit type. Per-file declaration emit needs to know the type a declaration emits without looking at other files. A satisfies alone is not enough: it validates a literal without naming that type. The flag is set once for the whole repo in @codefast/typescript-config/library-build.json.

Covariance is annotated, not assumed. Token, Constructor and InjectionDescriptor declare out Value. The engine erases the value type at every internal lane and casts once at a public entry point, and that only works while these three types are covariant. The annotation makes the compiler reject the day one of them stops being so.

The binding kinds deliberately have no variance annotation. Their lifecycle hooks are methods, so their parameters compare bivariantly, and pinning a variance there would fight the assignability that tests/types/binding-variance.test.ts exists to protect. Value-type erasure explains why that assignability matters.

Core model

Bindings: one shape, one construction site — and the chain is the binding

Every binding is a BindingChain from binding-chain.ts: the object bind() returns is the object the registry stores and the resolver reads. Its class declares every kind's fields first, in one fixed order, and the chain's own bookkeeping after them as private fields, so all bindings in a process share one V8 hidden class (the engine-internal description of an object's property layout). to*() fills the fields in place and hands the object to the registry; a plain bind is therefore one allocation, where a builder that produced a separate binding object was three and a copy of every field.

This matters because the resolver's hot property reads — kind, scope, factory — are then monomorphic: every binding they see has the same layout. Mixed layouts would make those reads megamorphic, meaning V8 falls back to slow generic property lookup. The registry stores what it is handed by reference rather than re-copying it, so the shape survives registration.

Two names follow from the merge. The binding's id is identifier, because the chain's .id() step is a method on the same object, and the lifecycle hooks are activationHook / deactivationHook, because .onActivation() / .onDeactivation() are the steps that set them. A chain registers exactly once: a second to*() throws ChainAlreadyRegisteredError before it can overwrite the registered fields, and another binding for the same token is another bind(). The slot steps come before to*() and write the fields on the unregistered object, so to*() hands the registry a binding already in its final slot, and the one add() is the only time the registry indexes it. A slot step after to*() throws the same error. What follows to*() — a scope, a hook — is written in place on the registered object, and none of it is anything the registry indexes on.

Convention (performance-load-bearing). BindingChain is the only construction site, and its field declarations keep their order. A second construction site, or a reordered field, quietly gives that binding a different hidden class and makes the hot reads megamorphic. It still works; it just gives back what the single hidden class buys. tests/unit/container/bind-to-builder-order.test.ts pins the single registration; the shape is held by the benchmark suite's warm resolve rows.

A token's binding list appends in place and replaces on removal

A token can carry several bindings, and the registry keeps them in a list, in registration order. Every add is the newest registration, so it appends to that array; a removeById or a displacement replaces it rather than splicing it. Nothing is ever put back, so the list's order is the order of the adds and no binding carries a stamp of its own.

The reason is selection. Selection walks the registry's own list while running when() predicates, and a predicate is user code that may rebind the very token being walked. The walk reads the list's length once before it starts: a removal hands it a new array, so its own is never shifted under it, and an append lands past the length it read, so it never sees a candidate that was not there when selection began. Every candidate registered at selection start still gets its predicate evaluated, none registered during it does, and no defensive copy is needed on the read side.

Appending in place is what makes a collection cheap to build: a hundred when() bindings on one token append a hundred times and copy the list never.

Invariant (correctness). A removal or a displacement replaces a token's binding array and never splices one that has been handed out; an append lands in place, and every selection walk reads its starting length first. tests/unit/resolution/select/candidates.test.ts pins both halves.

The registry keeps the common token in one map, and a record for the rest

Most tokens are bound once, to the default slot, with no predicate. BindingRegistry keeps exactly those in one map, token → binding, and nothing else about them: no list, no record, no index. A token that becomes anything more — a second binding, a tagged slot, a predicate, a many() member — moves to a second map of records, where a record is its binding list plus the two tagged-slot indexes, which stay unallocated until a tagged slot lands on that token. A token is in exactly one of the two maps, and it moves back when a record shrinks to one default-slot binding. The record map itself is allocated by the first token that needs one, and the index a caller almost never uses, binding id → binding, is built on the first id-keyed operation and maintained from then on.

The layout is priced on the two paths that matter. A plain bind is one map write and one binding object, because the common token gets no record and no list. A synchronous resolve's first read, getFastDefault(), is a bare Map.get on the lone map returning the binding — no record indirection, no optional chain. Reading the lone binding through a record (entries.get(token)?.fastDefault) is one dependent load more on the hottest lane there is, and a paired A/B of the two layouts read that load as a measurable loss on the warm resolve rows; keeping the lone map as the primary store, rather than as a second map beside a record map, is what lets both paths win at once.

The price is paid where it is cold. getAll() on a lone token materialises a one-element list, so the resolver's selection lanes ask getFastDefault() first and reach getAll() only for a token that keeps a record, and a presence check with no criteria is has(), which also reads the lone map's size before probing it so a container that never bound anything — every per-request child — answers without a hash. A miss is priced too: once the lone probe has failed, getRecorded() reads the record map alone, so an unbound token costs one probe per map and not one more. Snapshots, error reporting and module rollback are the callers that pay for the list, and they can.

Invariant (performance-load-bearing). getFastDefault() stays a single own-registry Map.get returning the binding — no record indirection, no optional chain — and a default-slot-only token never allocates a record. The promote/demote transitions are pinned by tests/unit/core/registry.test.ts, the lazily built id index and record map by tests/unit/resolution/fast-paths-active.test.ts; the fast-default shape is held by the benchmark suite's warm resolve rows, which is where it was found.

The method is also kept under V8's small-function bytecode threshold, below which a callee is inlined at every call site whatever the inlining budget holds. Any test added to its body — a flag, a cache probe, a check for pending work, even a ?? falling back to a helper — moves it into the budgeted pool, where the resolve lanes that reach it lose it as a whole. Tried on the lookup lane, and rejected is what that cost looked like.

Within a record, a bind's last-wins displacement is answered by slot index, never a list walk. A slot holds one occupant, so the record names it three ways: the default slot in defaultOccupant, a one-criterion slot in the simple map, a two-plus-criterion slot in the multi buckets. An incoming binding asks the index for the occupant of its own slot and displaces exactly that, so registering the n-th member of a collection stays constant-time instead of scanning the n already there — a token that grows a large collection bound member by member is otherwise quadratic to build, which the fan-out cold rows priced. A collection member and a predicate-only binding occupy no slot and are left out of every index; the two-plus-criterion slot, which the buckets key by only its first criterion, keeps the list walk, because it is rare and either criterion can lead.

Invariant (correctness). A binding that occupies a slot is indexed under it for exactly as long as it holds it: an in-place change that frees the slot — many() turning it into a member, a predicate making it predicate-only — drops the index entry in the same step that rewrites the binding, or a later add would displace a binding that has already moved on. tests/unit/core/registry.test.ts pins displacement through each slot shape and across those in-place transitions.

The registry also remembers which kinds it has ever held, as one bitmask that is set on add() and never cleared. Only the negative answers have to be exact: a registry that never held a constant skips its teardown sweep, and one that never held an alias answers defaultSlotAlias() without probing. That probe is what a request carrying criteria pays when it misses every slot of a token, because a default-slot alias forwards those criteria to its target (toAlias — hint forwarding); resolve, hasOwn() and explain all ask through the one method, so the rule and its shortcut live in one place. The two flags share one field because the registry is allocated by every container, and a field there is paid on every createChild().

scope is a total field

Every binding kind declares a scope, including AliasBinding, which declares scope: "transient". An alias defers scoping to what it points at, and that is transient behaviour, so the declaration is truthful.

Because no kind is missing the field, the engine reads scope as a plain field with no undefined fallback. effectiveBindingScope() is that read, kept as a named function because it is the vocabulary validation and introspection speak. The gain is not the removed ??: it is that the field's type feedback (V8's per-site record of the shapes a read has seen) stays one shape.

The engine erases the value type

Internally, every lane of the engine takes the erased union Binding and returns unknown. Only the eight public resolve entry points name Value, and each casts exactly once, at the point where the caller's token is the claim being made.

For that to type-check, Binding<Value> has to stay assignable to Binding. One declaration choice makes it so: the lifecycle hooks on BindingLifecycleHooks are declared as methods, and TypeScript compares method parameters bivariantly. Had they been function-typed properties, Value would sit in a parameter position, strictFunctionTypes would make the binding invariant, and every internal signature would need either a cast or a structural stand-in.

The bivariance reaches only the field read off a binding. The public ActivationHandler stays a function-typed property, so a user's handler is still checked strictly.

Invariant (type safety). Internal lanes take Binding and return unknown; only the eight public resolve entry points name Value, and each casts once. A Value type parameter on a private method would be a fiction — the caller supplies it through an unchecked cast, so it documents an intent the compiler never verified. tests/types/binding-variance.test.ts fails to compile if the hooks lose method syntax.

The memoised frame and scope refinement

A binding memoises a frame — the record a resolution path and a when() predicate see for it. frame derives from the token name, id, kind, slot and scope.

A binding carries a second memo, its activationStamp: whether it needs the activation pipeline, stamped with the lifecycle and registry versions it was computed under, doubled, plus the answer. A mutation anywhere moves the version and retires every stamp at once; a first instantiation that discovers lifecycle metadata resets its own. A field the level compares twice replaces a per-resolver map a one-shot container would have built and never read again.

A memo on a binding is only sound while what it derives from is immutable, and scope is the one field a fluent chain writes in place after registration (see The fluent chain). So singleton(), transient() and scoped() call clearBindingFrame() when they change the scope of a binding that has memoised a frame. Without it, a chain whose scope changes after its first resolve would report the old scope to every when() predicate that reads ctx.parent.scope. tests/unit/resolution/cache-invalidation.test.ts pins it.

A scope verb that leaves the scope as it was returns before touching anything, since no cache, frame or version derives from a scope that did not change. One that does change it releases only what exists: the cached singleton, the scoped entries, the frame. A chain scoped straight after to*() — the bind(T).to(X).singleton() idiom — has none of the three yet, so it pays for none. The guards are load-bearing together rather than one by one: each alone buys little, and with all of them the scope verb no longer calls into the scope manager at all on that idiom.

A scoped instance lives in the scope manager of the child that resolved it, which the chain cannot reach, so every scope manager files it under the binding's scopedCacheKey rather than its id. The key starts as the id, and leaving scoped mints a new one from the id counter: every entry any child filed under the old key is then unreachable, and a later flip back to scoped materialises a fresh instance. The hit stays one field load and one Map.get — a generation compared on each read was measured as a clear loss on the scoped hit — and the id itself never changes, as SPEC requires of id(). tests/unit/container/binding-chain.test.ts pins it for the registering child, a child of the registering container, and the async lane.

The fluent chain: one object, one registration

A binding is declared through a fluent chain such as bind(T).whenNamed("a").toDynamic(f).singleton(). Two facts about that chain shape the rest of the model.

Registration happens on to*(), once, in the final slot. The slot steps — when*(), many() — only write fields on the unregistered object, so toDynamic() registers a binding whose slot, predicate and membership are already settled: one probe and one write, and a displaced binding is either deactivated or kept for its owed teardown, never parked. What follows, such as singleton(), writes scope in place on that same registered object, and nothing after to*() is anything the registry indexes on. Nothing re-registers, so id() is stable for the whole chain by construction. A registry that let a chain move its binding after the add would have to take it out, re-index it, remember what the old shape displaced and put that back when the new shape frees it; that machinery, and the correctness fixes it kept needing, is what the slot-first order removes.

A rebind of a lone token is one registration. rebind(token) hands out a chain whose registration deactivates the binding it displaces on the spot, so a token held as its lone default binding is swapped by the new chain's own add; a token holding several slots is unbound first, as before, and an unbound token still throws.

A declared binding is a chain that skips its steps. binding() checks a definition and normalises it into a DeclaredBinding once, when the module is defined: the strategy's kind and default scope, the slot whenNamed and each whenTagged build, the deps descriptors. Each load then makes one BindingChain per declaration, so the single hidden class holds, copies the final shape into it and registers it with one add, exactly what a fluent chain's to*() does once its slot steps have run. The chain is never handed out, and a binding it displaces is handled as to*() handles one. tests/integration/declared-module-parity.test.ts holds the declared path to a fluent setup written independently of it.

One object per bind(). A single BindingChain plays every role: the BindToBuilder before to*(), the kind-specific builder after, and it commits to the registry itself. bind() is typed as BindToBuilder, which offers the slot steps and to*() only, and each to*() returns a builder with no slot step, so singleton() is not reachable before a to*() and whenNamed() is not reachable after one. The ordering is a type-level guarantee, matching SPEC's fluent-chain section. A caller who has no types, or casts past them, gets ChainNotRegisteredError for a scope, a hook or id() before to*(), and ChainAlreadyRegisteredError for a slot step after it, each naming the token, never a silent no-op. whenDefault() checks the order too, for that reason alone, since it otherwise has nothing to do.

Invariant (contract, two-part). Both halves of the ordering contract are pinned, by two different kinds of test. tests/types/container-api.test.ts asserts each step is absent from the type on the wrong side of to*(); tests/unit/container/bind-to-builder-order.test.ts and binding-chain.test.ts assert each one throws there. Asserting instead that the methods are absent from the object would pin an implementation detail and forbid this single-object shape. If you change the class, check which of the two a failing test is actually holding.

Resolution and planning

The engine and its collaborators

DependencyResolver is one large class on purpose. #private access in JavaScript is per class, and the sync and async pipelines both need the same private state on every hop. Splitting them behind interfaces would put a call and a property load on paths that run millions of times a second. That is design reasoning, and it is a measurable claim: if you want to challenge it, the benchmark suite is where.

Everything that needs no cross-instance private access is split out into a collaborator:

Module Owns
lookup.ts options-less token → {binding, owner} memo, alias hops folded, stamped with the chain's summed registry versions
class-introspector.ts per-class facts: constructor params and their settle, @postConstruct presence, accessor injection, and the new itself
activation-need.ts per-binding "does this need the activation pipeline", versioned on the lifecycle manager
compiler.ts the plan compiler (Compiled plans and escapes)
codegen.ts renders a hot plan's PlanNode tree as a function of its own, or leaves it a closure where the runtime forbids compiling one
path.ts cycle-detection bookkeeping carried on the path array
candidates.ts, constraints.ts candidate selection for name/tag/predicate shapes, matchesSlot() — the one slot matcher — and chooseCandidate()
dependency-slot.ts DependencySlot, the shape both dependency sources share, and the ResolveOptions derived from it

Lookup caches form their own parent chain mirroring the resolvers', for the same #private-is-per-class reason.

What resolver.ts holds

The file's size is not budgeted; its contents are. resolver.ts holds the DependencyResolver class and the module-level helpers its resolve lanes call, and nothing else. A method stays in the class when it reads the private state of a resolver — its own or another one in the chain — or when it is a warm lane, whose placement is measured. Code that needs neither lives with what it is about: a class's facts with the class introspector, a memo's facts beside the memo, what validate reports in introspection/validation.ts, which reads the chain through bindingsForValidate(). Moving a cold path out is worth doing only along such a boundary; a split by size alone would hand private state through accessors and put a call on lanes that run per hop. The package's dist/ is not budgeted either, since every module ships and a consumer's bundler keeps only what it reaches: the size an app pays is the minimal program's bundle, gated by footprint-budget.json.

Three parts of the class look like extraction candidates and are not, because each reads private state across the chain: the plan compiler's host (owner lookups, the plan maps, the root stack, frames), the ForValidate peeks, and describeCaches(). Two shapes were measured and kept inside:

  • A lookup miss builds its error where it throws. #requireBinding constructs TokenNotBoundError / NoMatchingBindingError inline. A shared helper returning the error, called at the same throw site, measured as a loss on the miss row in both experiment orders, and inlining it again recovered it. The alias-cycle throws follow the same rule. Validation, which pays nothing per resolve, builds the same errors itself.
  • Warm resolve bodies stay methods. Moving one into a module function that takes the resolver was measured as a loss on the resolve rows.

Two dependency sources, one slot shape

A dependency reaches the engine from one of two sources: a class's constructor parameter (ParamMetadata, read by the class introspector) or a toResolved InjectionDescriptor. Both are structurally a DependencySlot.

Because they share a shape, #resolveDeps/#settleDeps serve both and the plan compiler compiles both. That is one pair of loops rather than four near-identical ones, and a dispatch rule that can no longer be fixed in only one of them.

Two questions about a slot are answered in exactly one place each:

  • Does a slot match a request? matchesSlot().
  • Does a request carry exactly one criterion? singleCriterionOnlyOf(). A lone name folds to the reserved slotName criterion there, so the name spelling and the tag spellings reach one lane.

The resolver's fast lanes are lanes, not separate semantics. Each one calls these two functions rather than re-deciding the rule.

Invariant (single source of truth). Whether a slot matches a request is answered by matchesSlot() alone, and whether a request carries exactly one criterion by singleCriterionOnlyOf(). A fast lane that re-implements either rule is how the spellings drift apart. (A lesson on this is recorded in Lessons.)

Compiled plans and escapes

What a plan is. A transient class/resolved binding resolved at the top level compiles once into a nested-constructor closure. The static subgraph is cycle-checked at compile time, so executing the plan does no per-resolve bookkeeping at all. The first request for a root interprets and the request that repeats it compiles — a container that resolves a root once never builds the compiler, its host or a plan it would not run again — and the plan map keeps its two states, a plan or "unplannable", with a set beside it remembering which roots have been asked once, so the warm path reads exactly what it read before.

What an escape is. Some dependencies the compiler cannot see through: a factory, a scoped binding, an activation hook, a multi/optional/named param. Such a dependency does not sink the plan. It compiles to an escape: a re-entry into the runtime resolver, seeded with exactly the ancestors the interpreted path would have pushed at that point, and dispatched through exactly the resolve the interpreter would have called. Cycle detection, constraint contexts and error paths are therefore identical to never having compiled. Without escapes, one toDynamic dependency anywhere would drop the whole graph to the interpreted path.

A plan that keeps running is generated as a function of its own. A plan starts as a closure over the compiler's function literals, and V8 keeps type feedback per literal, not per closure: the dependency calls inside one root's closure see the dependency thunks of every plan the process has compiled, so a second plan shape — an inlined class next to a factory escape, a scoped escape, a hooked one — makes those calls polymorphic for every plan, the first one included. The compiler therefore records each sync plan's shape as a PlanNode tree beside the closure, and after PLAN_CODEGEN_THRESHOLD runs renders that tree through the Function constructor into a function with a source text of its own — hence a feedback vector of its own — and swaps it into the plan map in the closure's place through host.replacePlan, identity-guarded so a map cleared and recompiled since keeps its newer plan. Below the threshold a plan stays a closure, which is all a cold container or a per-request child ever runs, so neither pays a compile. Every leaf the compiler could not see through is an opaque thunk in both renderings: an escape keeps its frames, its dispatch and its errors. A runtime whose Content Security Policy refuses the constructor leaves every plan a closure, and behaviour is identical either way; RESOLUTION_DIAGNOSTICS reports generatedPlanCount.

Invariant (correctness). The closure and the generated function are two renderings of one PlanNode tree, so anything a closure does that its node does not state is a bug in the compiler, not a difference to preserve. tests/unit/resolution/plan/compiler-codegen.test.ts pins the generated tier against the closure's behaviour and tests/unit/resolution/plan/codegen.test.ts pins each node kind's rendering.

An accessor-injected class compiles like any other class. The compiler reads the class's @inject accessor slots from its metadata, in the order their initializers run (base class first, then declaration order), and compiles each slot the way it compiles a constructor parameter. A plan run resolves the accessors before new and installs a PreparedAccessorResolution as the ambient resolution. Each accessor's init takes the next value from it. A cycle back into the class is therefore caught when the plan compiles, as a parameter's cycle is, and the class may sit at any depth of a plan. An initializer takes a value only when its slot is the next one prepared and its instance is the one that took the first. A class built inside the constructor fails that check and resolves through the fallback, which is seeded with the class and its ancestors. The compiler prepares only slots that @inject itself registered. A custom reader's own records would be resolved for an initializer that never asks for them, so such a class stays on the interpreted lane.

Invariant (correctness). A plan prepares an accessor value only for a slot an @inject initializer will take, and an initializer takes one only in order and for the instance that took the first. tests/unit/resolution/fast-paths-active.test.ts pins the compiled plan, a nested accessor class, a class built in the constructor, a custom reader's records, and the cycle that closes through an accessor.

Invariant (correctness). An escape must stay behaviourally indistinguishable from the interpreted path. If you add a case, seed it with the same ancestors and replay the same call. tests/unit/resolution/plan/compiler-escapes.test.ts pins this.

A dependency's criteria are fixed when it is declared, so nothing rebuilds them per hop. #compileInjectionThunk derives a named or tagged param's ResolveOptions at compile time and captures them in the escape thunk. The interpreted path has no such moment — #resolveDep runs per hop — so resolveOptionsForSlot memoises them on the slot itself. That memo is sound to share across containers because it derives from the slot alone; a binding-keyed memo would not be, since class metadata is global to the class rather than per container.

The memoised object is frozen. One object now answers every resolve of that slot, and a constraint predicate is handed it as currentResolveOptions; a write through that reference would rewrite what the dependency asks for from then on. Frozen, the attempt throws where it is made.

Every slot the engine resolves has one hidden class. Slots are declared in several shapes — a bare token, optional(), a name, tags, whatever a foreign reader answers — and the memo above would add properties to them as they are read. A process that has resolved many shapes then meets every #resolveDep read megamorphic, on the cold path a boot runs most. So the engine copies each slot once, where it settles it — settledParam when a class's parameters are settled, settledDescriptorsOf when a factory is bound or declared — into one shape with both memos already filled, leaving only a lone name whose criterion a later bind can still fold. app-boot-40 and the slot-injected-* rows in benchmarks/di price it in the harness's full-suite state, where it shows; a probe of one scenario alone does not.

The frame copy in #compileEscapeThunk is load-bearing

An escape hands the runtime a copy of the frame array ([...frames]), never the array itself — or the one owned copy it lends and reclaims when a call hands it back at the seed's length. A constraint predicate runs on a live seed before any push, at exactly the length a depth guard reads as idle, and can re-enter the same cached plan. One indexed write into a lent seed would then survive forever, whereas the interpreted lane's rootStack drains to zero after each top-level resolve and self-heals. A poisoned frame changes which binding is selected — a wrong value, not just a wrong diagnostic. That is why this one is firm rather than a matter of taste.

A criterion the registry can settle is baked into the plan. whenNamed/whenTagged write criteria into the slot rather than a predicate, so a single-criterion request is usually a plain index hit. lookupPathIndependentEntry bakes that selection into the plan when, and only when, the candidate carries no predicate and its slot matches the request. A predicate reads the resolution path, so it is the runtime's to evaluate.

Invariant (correctness). An entry reached by a criterion carries that criterion into every escape it falls back to. #compileDepThunk's escapes replay resolveFromContext — the default slot — when handed no options, so a named singleton's cold materialisation or a named factory would silently resolve a different binding without this. tests/unit/resolution/plan/compiler-named.test.ts pins it.

Lookup caches and inline caches

An inline cache in front of a map. When a loop asks a map about the same key every iteration, a one-entry cache in front of the map turns a hash lookup into a pointer compare. LifecycleManager.activationHandlersFor() keeps one such token → hooks cache, invalidated by registerActivation, because a resolve loop asks about the same token every iteration. It keeps a second, class → @postConstruct / @preDestroy methods, because a teardown asks about the same class for every binding it deactivates. That one sits on the per-container manager and not on the reader: the default reader lives as long as the process, so a front there would keep the last class it read alive after everything else let go of it, where the manager's goes with its container.

defaultEntry() is the same shape one layer down. BindingLookupCache.defaultEntry() is reached by exactly two cases the registry's direct index cannot serve: an alias, whose terminal the index cannot name, and a token owned by a parent, whose entry has to carry that owner. Both are resolved in a loop over one token. null is a real answer there ("this shape needs full selection"), so the slot tracks absence by its token, not by its entry. The map behind the slot follows the same deferral as taggedEntry() below: the first token a cache generation sees is answered from the walk and parked in the slot, and the map is allocated and written only when a second distinct token appears. The repeat hit — same token, same chain version — is the whole of defaultEntry(), small enough for the two callers that reach it to inline; everything that fills the slot or the map is a separate miss, so an alias resolve and a parent-owned resolve from a child pay one inlined compare where a call used to sit.

Invariant (correctness). Alias hops are not folded into registry.getFastDefault(). That method is a bare own-registry Map.get returning a binding, and an alias terminal may live in a parent container whose rebind only the chain's summed version can see. Alias folding belongs where the version stamp is; moving it into getFastDefault() would miss a parent rebind.

The chain sum is walked only when something, anywhere, changed. Both memos are stamped with the summed versions of every registry up the chain, and the plan caches with the summed activation versions, so a parent's rebind or a grandparent's hook registration invalidates a descendant exactly. Summing is a walk of the chain, and it used to run on every memo read, which gave the parent walk a slope: a resolve from depth eight paid eight version reads before its one-entry front could even answer. Every registry version bump and every activation-hook registration now also advances one process-wide state epoch (core/state-epoch.ts), and the lookup cache memoizes its registry sum against the epoch it was taken at. While the epoch stands, no registry in the process has moved, so no sum in any chain has either, and the read is one global compare; the moment anything moves, the next read re-sums exactly as before. A loop that rebinds on every iteration therefore pays the walk it always paid plus one compare, and a per-request child that binds nothing pays the compare alone. A root container is exempt from the memo altogether: its sum is its own version, one field read, which is cheaper than the compare and the stamp — and a resolveAll over a root asks for it once per candidate.

The activation sum is deliberately not memoized on the resolver. It is read only by the plan getters and the collection lanes, a root answers it with one field read, and the memo that would serve a deep child costs two more fields on DependencyResolver — which a paired A/B read as a measurable loss on the warm transient class row, with the same two fields on the lookup cache costing nothing. The resolver's field count is load-bearing on that lane.

Invariant (performance-load-bearing). DependencyResolver carries no per-chain memo fields of its own; a cache that serves one lane lives on the collaborator that owns the lane (BindingLookupCache for the registry sum). The warm transient class row in the benchmark suite is what holds it, which is where it was found.

taggedEntry() mirrors defaultEntry(), and defers its map. It is chain-versioned, null means "this shape needs full selection", and predicate- and alias-carrying hits are declined. The memo key is the criterion object itself: interning (see Criteria and tag indexes) makes identity the slot contract's own Object.is, so an indexed hit needs no value re-check.

One difference came out of measuring fresh containers against warm ones. A per-request child usually asks one (token, tag) exactly once, and allocating an inner map on that first ask was the whole cost of the memo on that shape. So the first shape a cache generation sees is answered from the walk and parked in a one-entry front, and the map is not written until a second distinct shape appears. An alternating pair converges after one extra walk per key. The warm-vs-fresh numbers that settled this, and the shape's A/B, are recorded in the package CHANGELOG.md at the entry that landed the tagged chain-walk memo.

A root-level collection is memoized on the same cache. resolveAll with no options at the top level gathers the chain's bindings and runs every when() predicate against the root context, which is a constant. The contract makes a predicate pure, so that candidate list is a function of the chain's registries alone, and the lookup cache keeps it under the same chain-version stamp as its other two memos. When every member is a hook-free constant or a hook-free singleton whose instance is cached, and no activation hook exists anywhere in the chain, the value list is kept as well, stamped with the chain's activation version, and kept unfrozen, because a frozen array iterates through a slow elements kind in V8. Every read hands out that list itself, typed ReadonlyArray, as SPEC allows, so a stable collection costs a stamp check and nothing else; a read that materialises the last such singleton settles the list for the next one, and any registry change that evicts an instance drops the memo with it. The list settles only on the read that repeats the first, the rule a plan follows: a container read once — a fresh container per request, or the cold row — would otherwise build a list it never reads again. The memo is one object the cache builds on its first root read, because every per-request child builds a lookup cache and almost none reads a collection at the root; a field per piece of it would be paid on every createChild(). Inside it one entry sits in front of a map written only once a second token appears, as for defaultEntry(), because a collection is read in a loop over one token — an event bus dispatching each event. collection() stamps the chain version before the gather, so a predicate that binds during it leaves the entry stale rather than current, and the version branch clears the entry in front before the rebuild, since the rebuild can throw on an alias cycle and a read after the throw must not answer from the list the generation before kept. A read carrying options or made from inside a factory goes through the full gather, because its context is not a constant. The container routes a top-level read with no options to the memo's own entry, resolveRootCollection, so resolveAll itself keeps the exact shape the options lane had — a branch added there was measured as a loss on the tagged collection row. tests/unit/resolution/resolver-collections.test.ts pins the boundaries.

Late hooks are why the activation-need memo reads the field first. .onActivation() writes the hook field in place on an already-registered binding and bumps no version. needsActivation() therefore answers the binding's own hook from the field itself before touching the memo, and stamps the memo with the registry version too, so a rebind loop cannot grow it. tests/unit/resolution/cache-invalidation.test.ts pins all the lanes. (The silent failure this guards against is recorded in Lessons.)

One alias walk, one set of not-bound diagnostics. resolve and resolveAsync both take their terminal binding from #requireBinding, so the alias-cycle walk and the diagnostics exist once. The stack frame that adds is charged only to a resolve that fails, and constructing the error at the throw site rather than in the helper keeps most of the cost there.

Criteria: interning and the tag indexes

A criterion is interned. A tag key is minted by tag() and its criteria by TagKey.of(), which caches one object per value. So the Object.is equality the contract specifies (SPEC — ResolveOptions) becomes object identity, and the registry keys tagged bindings by the criterion itself rather than by key-then-value.

Interning removes a hash level from every tagged lookup. More importantly, it removes a divergence: a value-keyed Map compares keys by SameValueZero, which holds +0 and -0 equal, while Object.is does not. The intern cache splits those two under a private symbol, so the index cannot conflate them.

Invariant (correctness). A BindingTag is only ever constructed through TagKey.of() — the brand on the type is the enforcement — because interning is what makes identity sound here. tests/unit/resolution/select/tagged-selection.test.ts pins the ±0 split it buys. Constructing one another way would let the index conflate +0 and -0.

The multi-tag lane prefilters on keys with a bitmask. Each key carries a bit, each slot the OR of its keys, and a request the OR of what it names. A slot whose keys the request does not cover is rejected by (requestMask & slotMask) !== slotMask before any criterion is read — a subset test a Map cannot do. Bits wrap every 32 keys, so two keys can share one; that is a false positive the identity comparison then rejects, never a false negative.

Three spellings are one request. tag: pair, tags: [pair] and — through the reserved criterion — name: n are each one-criterion requests. singleCriterionOnlyOf is the admission test into the single-criterion lane, and it sees all three the same way. A request carrying criteria from two sources at once still declines: two criteria asked for is not something a one-criterion index can answer without skipping the ambiguity check the full path runs.

Invariant (consistency of contract). Two spellings SPEC calls equivalent have to reach the same lane, or the shorter one becomes the slower one and the documentation recommending it becomes wrong. SPEC makes the two spellings one request; tests/unit/resolution/select/tag-shorthand-parity.test.ts pins the lane alongside the answer.

A one-criterion index miss is a registry miss. A request carrying one criterion matches only a slot that is that criterion: a multi-criterion slot needs every one of its criteria covered, and the default slot matches no request that carries any. Every one-criterion slot is in the registry's simple index, so when the index has no entry for the criterion the container has nothing to scan and the lookup walks to the parent at once — the tagged-miss row was that scan. The index is complete by construction: last-wins keeps one binding per tagged slot, a predicate on a tagged binding does not exempt it from that, and a predicate-only binding sits on the default slot, which no criterion reaches.

Invariant (correctness). The simple index holds an entry for every binding whose slot carries exactly one criterion, or a miss the lookup declares from it would hide a live binding. Anything that lets two bindings share a tagged slot has to index both. tests/unit/resolution/select/tagged-selection.test.ts pins last-wins on a tagged slot, the parent walk and that a default-slot predicate is never evaluated for a tagged request.

A name beside one tag has a lane of its own. Two criteria at once fell to the scan, because no single index holds a two-criterion slot. The request shape a name plus one tag makes is the common one, and its slot is exact — a binding whose criteria are precisely those two, in either declaration order — so the lookup cache memoizes it like the one-criterion entry: keyed by token, then the interned name criterion, then the tag, stamped with the chain version, filled by a walk that reads the registry's first-criterion buckets from either side. A predicate or an alias declines to the full path, as the one-criterion memo does, and a name no binding anywhere has declared is a miss before any lookup. tests/unit/resolution/select/tagged-selection.test.ts pins both declaration orders, the predicate, the parent walk and the undeclared name.

resolveAll reads the tag index too. A request carrying exactly one criterion (a lone name folds to the reserved criterion) matches exactly the bindings whose slot is that criterion: a multi-criterion slot cannot satisfy it, and last-wins keeps at most one such binding per registry. So the index holds the whole answer per container, and walking the chain produces the candidate set rather than a prefilter. Both lanes that read the index evaluate a found binding's predicate, because a predicate needs a live context no index can hold.

Several criteria form a subset query. A request carrying several criteria matches every binding whose criteria are a subset of them: [A], [B] and [A,B] all answer a request for [A,B]. No single Map lookup can serve that. The index that serves it buckets every multi-criterion slot under its first criterion. A matching slot's every criterion is in the request, its first included, so walking the request's few buckets (plus the single-tag index under each request criterion, the folded name criterion among them) finds each candidate exactly once. There is no dedup set, no per-resolve allocation beyond the candidate list selection was already building, and no stringified tag values — buckets are keyed by the interned criterion, so the Object.is rule in SPEC still holds.

Two deliberate bounds on that lane:

  • It serves resolve only. resolveAll keeps the full scan, because its result order follows the token's list and bucket order would reorder it.
  • It engages only past a size threshold on the token's list. Under it, the generic scan is cheaper than the bucket walk. The threshold switches the data structure, never the semantics: both paths answer identically, which tests/unit/resolution/select/multi-tag-selection.test.ts pins along with the subset, specificity, predicate and index-invalidation behaviour. The residual cost on a small list is one length read.

Under the threshold, and on every request the indexes decline, the list gets a first pass before full selection. One slot match carrying no predicate is the whole answer, and no match is a clean miss; neither builds a constraint context, a display name or a candidate array, which is what a name-plus-tag request and a tagged miss over a populated token used to pay on every resolve. A second match, or a predicate on a match, hands the same list to full selection, which weighs specificity and reports ambiguity, so the two lanes answer identically.

explain reads the full path and shares the one decision

explain answers why a request selects the binding it does, so it must not have an opinion of its own. It reads each registry of the chain through getAll(), the token's whole list, never through getFastDefault(), the tagged index or the lookup memo, and classifies every binding with the resolver's own pieces: membership, matchesSlot(), then the when() predicate against a DefaultConstraintContext built the way the resolver builds one. The eligible candidates are settled by chooseCandidate(), the same function selectBinding() calls, and alias hops restart at the asked container and stop on a revisited token exactly as #requireBinding() does. The rule it reports is read off the answer by candidateRuleOf(), beside the decision, so resolve pays nothing for the label. chooseCandidate() keeps its predicate loop written out rather than delegating to the helper the label uses: splitting the decision into two calls was measured as a loss on the multi-candidate lane. The fast lanes are held to this walk by tests/integration/explain-parity.test.ts, which compares both answers for generated bindings on a root and its child.

Nested requests take their ancestors as tokens. Each is selected with the same walk and pushed as the frame the resolver would push, so a predicate that reads the parent sees the one it would inside the parent's factory. The walk runs on the container asked; it does not model an owner resolving its own dependencies.

Cycle detection: one flag for synchronous paths, one scan for async branches

A resolution path is the chain of ancestors a level is being resolved under — "A is resolving B, which is resolving C". A cycle is a binding that appears on its own path. How membership in that path is checked depends on one structural fact: whether the code producing the path runs on one synchronous call stack.

Lane Mechanism Why
Every synchronous lane binding.inFlight, set on entering the level and cleared on leaving Synchronous code does not interleave, so the flag is exact path membership — O(1) at any depth, no side table
Async, a factory's prefix binding.inFlight, held for the factory's synchronous prefix The request that closes a cycle comes from that prefix, and synchronous code does not interleave
Async, on a branch extendResolutionBranch — append-only path, scanned by branch depth A continuation's ancestors are on no call stack and several branches interleave, so a flag cannot name the branch

enterSyncPath / leaveSyncPath in resolution/path.ts are the whole synchronous mechanism: check the flag, set it, push the frame; pop, clear. The stack is still kept — it is what names the path in an error and what a constraint predicate reads — but nothing scans it and nothing is sized.

A seeded path is marked before a synchronous call runs over it. Two paths reach a synchronous call without any synchronous frame having pushed them: the static ancestors a compiled plan seeds into an escape, and the branch prefix an async level hands a factory's synchronous ctx.resolve. Their bindings carry no flag, so enterSeededPath marks them and leaveSeededPath clears them around the call, reading the bindings through a WeakMap from resolver-built frames (linkFrameBinding, set where the frame is minted) so the public frame shape is untouched. An escape marks the ancestor bindings the compiler already held, and the one-ancestor escape — a plan root's own opaque dependency, the common shape — is written out with no loop and no call.

Invariant (correctness). Marking a seed is idempotent. A binding a seed finds already flagged was flagged by an enclosing synchronous frame that is still running — an async factory's own prefix — and is the same fact stated once already, not a cycle. It is left as it is on the way in and on the way out; a genuine cycle is reported where the path re-enters the binding, which is where the frames to name it are. A version that threw on an already-flagged seed reported false cycles on every async level whose factory made a synchronous call, and the lane differential test (tests/integration/resolution-lanes-differential.test.ts) caught it on the fourth random graph.

Every path-based check keys on binding identity, never on a token's display name. A display name is not unique: two token("app:Config") from different modules are distinct tokens. The flag lives on the binding and extendResolutionBranch compares bindingId read off the frames. The names an error or ctx.resolutionPath reports are derived from the frames at the moment they are asked for, so no name array exists to keep in step.

Invariant (correctness). Every lane answers a graph identically — the same value, the same sharing pattern, the same error class and message. The lane differential test generates random graphs and deep chains and holds the interpreted lane, the tiered plan lanes, the collection and optional reads, a per-request child and the async entry points to one snapshot — the first async resolve to a fresh host's cold interpreted resolve, the lanes after it to the warm one, since a singleton still pending refuses a synchronous read that a warm resolve answers, by contract. A mechanism that catches a cycle one hop late on one lane, or runs a factory twice before catching it, fails that test; three such divergences were found and fixed when it was first run.

The async pipeline: one branch lane

The sync pipeline gets two things for free from the call stack: one array pushed and popped is the ancestor chain, and binding.inFlight is exact membership in it. The async pipeline has neither for long: a factory's request for a dependency is made from its synchronous prefix — async ctx => await ctx.resolveAsync(dep) calls resolveAsync before it awaits anything — but a request made from a continuation, after an await, has ancestors on no call stack. So every async level owns a branch: its prefix of the path, which it keeps for as long as its factory holds the context, across an await included.

The branch lane. resolveAsyncFromRoot opens a branch over a fresh array; extendResolutionBranch appends to a path while this branch still owns the next slot, and copies its own prefix once a sibling has claimed it. Nothing is removed there, so it needs no settle listener; it pays a context per level instead — AsyncLevelContext, which reads its depth off the branch it was handed rather than taking it as a parameter, so the two cannot disagree. The dominant level — a transient factory with no activation, asked with no options — is served by a method that is deliberately not async, so it costs the factory's own promise and nothing on top; a single resolveAsync and every member of a resolveAllAsync collection take that same lane, and the async method behind them is reached only by scoped, singleton, activated or option-carrying members. A transient factory root — asked with no options, its path its own frame alone — is handed the one context its binding keeps, built on the first resolve and reused by every later one, concurrent roots included: the level allocates nothing per resolve, and a descendant that outgrows the kept branch copies its prefix as any sibling would. clearBindingFrame drops it with the frame it was built over.

A factory's flag is held for its synchronous prefix and cleared when it returns its promise, not when that promise settles. That is what makes a diamond resolve: when A awaits B and C in parallel and both need D, D's flag is already clear when the second sibling reaches it, so no cycle is reported for b → d → c, which is not a dependency edge at all. A cycle formed entirely from post-await edges is caught by the branch scan and named from the true root.

Invariant (correctness). A level's context answers from that level's own prefix whenever it is asked — before an await, after one, and while other chains are in flight. whenParentIs selects the same binding a synchronous resolve would, and ctx.graph.resolutionPath names the level's own ancestors. A shared per-chain context that read a live stack once answered a post-await ctx.resolve from another chain's frames; that is why every level owns one. tests/unit/resolution/resolver-async.test.ts pins the three shapes.

Invariant (ownership, held by the compiler). A branch may only ever append to an array it minted itself. A sync frame's path is one that frame will pop in its own finally, so appending to it would hand a synchronous level a frame it never pushed. So extendResolutionBranch is the only thing that mints an OwnedBranchStack, AsyncLevelContext accepts nothing else, and a BranchDepth is branded so a bare number cannot stand in for one — a depth from anywhere but this branch silently re-parents a level. AsyncLevelContext reads its depth off the branch it was handed rather than taking it as a parameter, so the two cannot disagree. tests/types/async-branch-ownership.test.ts fails to compile if either brand is removed. Check that it still fails before trusting it: a type test that compiles once its invariant is gone asserts nothing.

A level's context lives only as long as its chain, and the collector notices. A full collection that finds no live instance of AsyncLevelContext clears something the optimized code of the lane had embedded weakly, and every such site deoptimizes — on that collection and on every one after, for as long as instances keep dying between chains. Measured under a forced collection every thousand resolves, that was forty deoptimization marks per thirty collections and roughly half the throughput; with one instance kept live, none and parity. So the resolver keeps its most recent root level's context in one field. What is embedded was narrowed — not the class's map, not the branch array — but not named; the field is the measured remedy, not an explanation.

A level yields only where something awaits. The interpreted lane's #settle* methods — #settleBinding, #settleInstance, #settleDeps, #settleCandidates and the request cores behind resolveAsync, resolveOptionalAsync and resolveAllAsync — answer with the value when the level completed in the caller's own tick and with a promise only where a dependency, factory or hook really returned one; the public entry points wrap that answer into the promise they hand out, and turn a synchronous throw into a rejection there. This is the discipline the compiled async plan settles at compile time, carried at run time. It is what lets a fan-out start its siblings in order and still serve a sibling that reads an earlier one synchronously: a singleton or scoped instance built without yielding is cached by #settleBinding before the fan-out moves on, so a toDynamic factory's ctx.resolve, an @inject accessor or a top-level resolve finds it, exactly as the sync lane would have. Only a materialisation that is genuinely pending is published in flight, so the sync guard in #resolveBinding refuses exactly the request it should: one for a value that cannot exist yet. A singleton whose subtree is asynchronous keeps that refusal cold and answers warm — that is the contract, not a divergence — while one whose subtree is synchronous no longer differs between the two.

Invariant (correctness). The interpreted async lane completes in the caller's tick whatever the sync lane would complete, and caches a singleton or scoped instance before the sibling that may read it synchronously starts. tests/unit/resolution/resolver-async-sibling-reads.test.ts pins the shapes: a cold singleton read by a later sibling's factory, accessor, optional or collection request, by name or through an alias, in a child, and under the compiled and generated tiers of both lanes.

Async plans. The sync lane's compiled plan (Compiled plans and escapes) ports to async exactly as far as the graph is visible. class, resolved and resolved-async bindings declare their dependencies, so compileAsync compiles those into an async plan; a dynamic-async factory stays opaque and runs on the branch lane. The async plan runs only at a root: a resolveAsync the container starts with no options. A request from inside a factory keeps the live path instead, because a plan does no bookkeeping and its escapes must carry the live ancestors. Each node's promise-ness is settled at compile time: a fully synchronous subtree touches no promise at all; a node with one awaiting dependency awaits it directly; anything with several routes them through settleInOrder, which is Promise.all on the happy path and, on a failure, every sibling settled and the earliest rejection in declaration order — the order the synchronous lanes report. That is exactly how the interpreted async path treats every dependency, down to unwrapping a promise-valued constant and starting every sibling before any failure is reported. tests/unit/resolution/plan/compiler-async.test.ts pins the lane being active, the escape criteria, the late-hook invalidation, and those exactness corners.

An async plan that keeps running is generated as a function of its own exactly as a sync one is (Compiled plans and escapes): the compiler records an AsyncPlanNode tree beside the closure, and a node that awaits its dependencies renders as an inner function of the same source — every dependency started in order inside its own try, a sync throw turned into that slot's rejection, the settle in declaration order over the slots, the constructor or factory applied to the settled values — so the awaiting nodes get call sites of their own too. The threshold, the identity-guarded swap (host.replaceAsyncPlan, over the async map) and the closure fallback are the sync lane's.

The sync context pool, and the stack it lends

What is pooled. A sync resolve needs a resolution context per depth level. Rather than allocating one per level, the resolver keeps a pool indexed by depth and calls reset() on a pooled context, which writes a handful of fields. Pooling beat per-level allocation in measurement, which is why it is there.

Why the fields are compared before they are stored. A pooled context outlives enough resolves to be promoted to V8's old space, and every pointer write into an old-space object pays a write barrier (a small bookkeeping step for the garbage collector). So reset() compares the resolver and the stack against what the context already holds and writes only when they differ. That comparison can only ever hit if the stack is the same object, which is why one stack is reused per resolver rather than minted per container.resolve() call.

The same mechanism sets the price of the shared stack: a fresh array lives in new space and needs no barrier to push a frame onto, while the shared stack pays one per push.

Invariant (lending protocol). An empty rootStack is the whole lending protocol. Every sync lane pops what it pushes, so a non-empty stack means a resolve is holding it and the caller mints its own. A nested container.resolve() inside a factory must therefore still see an empty stack, and a resolve that throws must hand it back; tests/unit/resolution/in-flight-invariants.test.ts pins both. If the stack ever leaks dirty, the failure mode is lost reuse (slower), never a wrong path — the protocol is built so that the correctness case cannot break here.

Invariant (correctness). A pooled context is reused only for the stack it already holds. The pool is keyed by the root stack, depth-indexed, and any other stack (a nested resolve's minted array, an async level's prefix) mints a context per call. A nested top-level resolve reaches the same depth while the outer factory still holds that depth's pooled context; re-pointing it at the nested resolve's freshly minted stack would leave the outer factory's ctx answering from the wrong path, so a when() predicate reading ctx.parent would select the wrong binding. Keying the pools by stack holds this structurally, with one pointer compare on the hot lane. Asking the context whether it holds the requested array answered the same question but cost the acquire its inlining. tests/unit/resolution/context-pool-isolation.test.ts pins the behaviour.

A container defers most of itself

DefaultContainer's constructor builds only what a resolve cannot happen without: the registry, the scope manager, the lifecycle manager and the resolver chain — and each of those, in turn, allocates only its hot lane. Everything else arrives on first use — the inspector, the module ref/binding/import tables (the import table records which modules a module imported, so an unload releases their ref-counts as symmetrically as the import took them), the scope's in-flight and scoped caches, the registry's record map (first token that is more than one default binding), id index (first id-keyed operation) and tagged slot indexes (first tagged slot), the per-reader metadata caches, which every container reading through the same reader shares — a child takes its parent's by hand, a root finds them by reader on its first question — so a child inheriting its parent's reader meets no class cold that the parent already met, the resolver's plan compiler and both plan maps (first plan request, which only a class or resolved binding makes), the lookup cache's memo maps (second distinct token or tag in one cache generation), the activation-need cache (first interpreted resolve that asks whether a binding needs the activation pipeline) and its memo (first answer its early returns cannot give).

The per-reader metadata cache holds one record per class — its constructor metadata, whether it has a @postConstruct hook, whether accessor injection reads the container, and the parameter list an instantiation injects — each field unknown until first asked. The parameter list is settled by the first instantiation that succeeds, the check that a subclass with an implicit constructor is not silently dropping its base's declared deps included, since both follow from metadata that cannot change once a class is defined. A cold class resolve looks the record up once and hands it to the params read, the accessor check and the construction, and a class with no params is constructed with no arguments, so no dependency array is built for it. The introspector keeps the class it was asked about last and that record in front of the map, because a cold resolve asks about one class several times in a row — the activation-need check, then the instantiation — and a container that binds one class under many tokens asks about it over and over. hasPostConstruct must start unknown, never false: unknown is what makes the first instantiation activate conservatively and read the lifecycle metadata, and filling it early would ask a custom MetadataReader — a public seam — for lifecycle metadata before construction. The same fact lets refreshAfterFirstInstantiation() return at once on a false answer, because a class binding answers false only once its lifecycle metadata is known.

The scope manager and the lifecycle manager stay eager on purpose: every generic level reads them, and a nullable field there is a branch on every hop. The resolver's lookup memo, its class introspector and its sync context pool are built by the first request that needs them — a container that never misses its own lone map, never resolves a class and never runs a factory with a context pays for none of them — and the plan host holds the introspector it was built with, so no plan run reaches it through an accessor. The registry's fast-default map is one shared empty map until the first add mints one, so a container that only resolves through its parent — every per-request child — allocates no map at all, and getFastDefault() on it is a read of an empty map, the same miss it always was. The reason the rest defers is that an empty Map is not free: V8 gives it a backing store, and a closure-heavy host object such as the plan compiler's is a dozen allocations. Those are costs a per-request child — created, asked one parent-owned token, disposed — never earns back, and that child is the shape Container.create() and createChild() are priced on.

Invariant (correctness). Deferral is an allocation decision only. A deferred collaborator must answer identically whether or not something touched it first — an unallocated cache reads as a miss, never as an error — which is why tests/unit/container/deferred-subsystems.test.ts exercises each one as the first thing a fresh container does.

One binding, one container, and the singleton slot that follows

A binding is registered by its chain into exactly one registry, and only that container's scope ever caches it. A child resolving a parent's token delegates to the parent resolver, which owns the same binding object.

Because of that, a singleton's slot is per-binding, not per-container. The instance lives on binding.instance (NO_INSTANCE when unset) instead of in a Map keyed by binding id. That replaces a keyed lookup with a field read on the most common resolve shape there is: a transient over cached singletons. ScopeManager keeps only a lazily-created list of the bindings that have materialised, so disposal and inspect can still enumerate them; a removal clears the binding's instance and marks the list, and the next enumeration compacts it, keeping each live binding once at its latest position — a teardown of a hundred singletons is a hundred field writes, not a hundred splices.

A scoped instance cannot live on its binding, because one binding caches an instance in every child that resolves it, so it stays in the child's Map under the binding's scopedCacheKey. Beside that map ScopeManager keeps each binding with the resolver that owns it, in caching order — the first in two fields, so a per-request child caching one scoped instance allocates nothing for it, and nothing on a hit. A child's disposal walks them latest first and hands each current instance to its owner, which runs its hooks and reader as activation did, and only when the binding owes a teardown: a child whose scoped instances owe none awaits nothing.

Invariant (correctness). This is only sound while one binding maps to one owning container. Anything that would share a binding object between two registries — a snapshot that re-registers into a different container, a clone that copies bindings by reference — breaks it silently, by making two containers share one instance. tests/unit/resolution/singleton-on-binding.test.ts pins the parts that are easy to get wrong: the chain-shared read, invalidation on unbind and rebind, enumeration for disposal, and a cached undefined that must stay distinguishable from a miss.

Important invariants

Every invariant above, grouped, with the test that pins it. Read this table before touching a file; read the linked section before changing what the table describes.

Model and types

Invariant Pinned by Where
A removal or displacement replaces a token's binding array, an append lands in place, and a selection walk reads its starting length. tests/unit/resolution/select/candidates.test.ts Binding list
Internal lanes take Binding and return unknown; only the eight public entry points name Value. Lifecycle hooks stay method syntax. tests/types/binding-variance.test.ts Value-type erasure
frame is cleared whenever scope changes in place. tests/unit/resolution/cache-invalidation.test.ts The memoised frame
Chain steps are absent from the type and throw on the wrong side of to*(). tests/types/container-api.test.ts, tests/unit/container/bind-to-builder-order.test.ts The fluent chain
One binding belongs to one container; the singleton slot lives on the binding. tests/unit/resolution/singleton-on-binding.test.ts Singleton on the binding
The common token lives in the lone map alone and only the rest keep a record; getFastDefault() is one bare Map.get. tests/unit/core/registry.test.ts, the suite's warm resolve rows The common token
Every lookup agrees with a history-free model of the bindings, across any sequence of binds, scope changes, removals and rebinds on containers that share tokens. tests/integration/binding-lookup-parity.test.ts Tried on the lookup lane
A module-scoped token keeps no disposed or displaced container reachable. tests/integration/container-retention.test.ts Tried on the lookup lane

Selection and lookup

Invariant Pinned by Where
matchesSlot() and singleCriterionOnlyOf() are the only places their two rules are decided. (design rule; the parity and selection tests below exercise it)
explain selects the binding resolve answers with, whichever lane resolve takes, and ends where resolve throws. tests/integration/explain-parity.test.ts One slot shape
A BindingTag is only constructed through TagKey.of(); the index never conflates +0 and -0. tests/unit/resolution/select/tagged-selection.test.ts Criteria and tag indexes
Equivalent spellings (tag, tags, folded name) reach the same lane. tests/unit/resolution/select/tag-shorthand-parity.test.ts Criteria and tag indexes
The multi-tag size threshold switches data structure only; both sides answer identically. tests/unit/resolution/select/multi-tag-selection.test.ts Criteria and tag indexes
Alias hops are folded where the chain version stamp is, never in registry.getFastDefault(). (structural) Lookup caches
needsActivation() reads a binding's own late hook from the field before the memo, and stamps the memo with the registry version. tests/unit/resolution/cache-invalidation.test.ts Lookup caches

Plans and escapes

Invariant Pinned by Where
An escape is behaviourally indistinguishable from the interpreted path: same ancestors, same call. tests/unit/resolution/plan/compiler-escapes.test.ts Compiled plans and escapes
An escape thunk copies the frame array; it never lends it. (structural; see the two mechanisms) The frame copy
An entry reached by a criterion carries that criterion into every escape. tests/unit/resolution/plan/compiler-named.test.ts Compiled plans and escapes
The async plan runs only at a root and mirrors the interpreted async path exactly. tests/unit/resolution/plan/compiler-async.test.ts The async pipeline
A generated plan and its closure are two renderings of one PlanNode tree, and behave identically. tests/unit/resolution/plan/compiler-codegen.test.ts Compiled plans and escapes

Cycle detection and paths

Invariant Pinned by Where
binding.inFlight is set and released on every exit path, with or without activation hooks. tests/unit/resolution/in-flight-invariants.test.ts Cycle detection
Path checks key on binding identity, never on a display name. (structural) Cycle detection
A seeded path is marked idempotently; an enclosing frame's flag is left as found. tests/unit/resolution/path.test.ts Cycle detection
Every lane answers a graph identically — value, sharing and error; a first async resolve is held to a cold reference, the rest to a warm one. tests/integration/resolution-lanes-differential.test.ts Cycle detection
A branch appends only to an OwnedBranchStack it minted; BranchDepth is branded. tests/types/async-branch-ownership.test.ts The async pipeline
A level's context answers from its own prefix before and after an await. tests/unit/resolution/resolver-async.test.ts The async pipeline
The async lane completes in the caller's tick whatever the sync lane would, and caches it first. tests/unit/resolution/resolver-async-sibling-reads.test.ts The async pipeline

Pooling, lending and deferral

Invariant Pinned by Where
An empty rootStack is the lending protocol; a nested resolve sees it empty, a throw returns it. tests/unit/resolution/in-flight-invariants.test.ts The context pool
A pooled context is reused only for the stack it already holds; pools are keyed by stack. tests/unit/resolution/context-pool-isolation.test.ts The context pool
Deferral is an allocation decision only; a deferred subsystem answers identically when first touched. tests/unit/container/deferred-subsystems.test.ts Deferral

Performance-sensitive decisions

Every item here is a shape kept because it measured well at the time. None is an axiom. If you touch one, re-run the benchmark suite before and after; if the arities or workloads the suite measures have changed, the trade may have too.

Fast lanes that read as duplication

Two shapes in the resolver look like copy-paste of #resolveBinding. Both are deliberate.

A candidate answers where it is selected. #resolveCandidateSync/#settleCandidate re-check plain-constant and cached-singleton before delegating, which #resolveBinding would check anyway. Routing every candidate through #resolveBinding instead pays those checks per candidate rather than per call, and a resolveAll over cached singletons multiplies that out.

The dispatcher's prefix is charged to every resolve. In #resolveDefaultEntry the plain-constant test lives inside the singleton branch, because a constant is a singleton that is already its own instance. Hoisting it to the top, where it reads more naturally, would help a constant resolve and charge every transient-factory resolve for a kind it never has.

Heuristic (perf, not correctness). A test tends to be cheapest under the branch that already implies it. A test in the hot dispatcher's prefix is paid by every resolve that is not the case it is looking for, and there are usually more of those. This dispatcher is also inlining-sensitive: a test added inside a branch it does not even take has moved an unrelated row in the benchmark, because it changed the function's size. That sensitivity is a V8 property and can shift between versions, so measure any edit near it against the benchmark rather than trusting a past number.

The same reasoning covers two smaller shapes. #taggedBindingsFromChain returns [binding] whole for a root container rather than growing an empty list, because a criterion matches at most once per registry and the size is known. #findBinding treats a lone candidate as its own selection: matching it is the decision, with no specificity to weigh and no ambiguity to report.

Practical note. Both duplicated shapes were removed on a DRY pass and put back after measuring the regression. That is their honest status: the duplication earns its keep at the arities the suite measures, and if those change the trade is worth revisiting. Re-run the benchmark before assuming either way.

The disposed guard stays inside the inline budget

Every public entry point but resolve calls #assertNotDisposed() first, and V8 inlines it into whichever caller inlines the entry point, so its bytecode is charged against that caller's cumulative inline budget. The guard is therefore two methods, each under V8's small-function size so that every caller inlines both outright. #assertNotDisposed() is two field reads — #disposed || #parent !== undefined — and a live root never leaves it. #assertChainLive() is a child's one compare of the dispose epoch against the epoch its chain was last confirmed live at. The walk and the throw sit in #confirmChainLive(), which runs only after some container in the process was disposed. Every dispose advances the epoch, the container's own included, so a disposed child always reaches the walk; and createChild() stamps the new child with the current epoch, since it has just checked the parent's chain, so a per-request child never walks on its first read.

The obvious spellings are all losses. Checking #disposed and then the epoch in one method, with or without a cold helper, grows it past the small-function size, and a root's tagged lookup stops inlining its lookup-cache reads. Folding both checks into one epoch compare puts the epoch read on every root resolve. One method holding the compare, the walk and the throws is too large to inline outright, and every child resolve pays for it.

resolve is installed per role. Once children have run the child path in a process, V8 compiles that path into every entry point they share with a root, and a root's resolve(token, options) would pay for it although a root never takes it. No spelling of the guard removes that: a call-free child path, a guard under the small-function size, a larger inline budget and running without forced collections each left it in place. So resolve is an own property that construction sets to one of two functions: the root's, which reads only #disposed, and the child's, behind #assertChainLive(). Each keeps its own copy of the body, so neither role's compiled resolve carries the other's path, and the rule that a disposed ancestor refuses its descendants stays whole. The other entry points share the guard; their root lanes were measured without the cost. The price moves to creation: every container carries one more field, and a child that is created and dropped at once is slower, while one that is used is not.

The async resolve entry points turn the guard's throw into a rejection without changing the guard. resolveAsync, resolveOptionalAsync and resolveAllAsync are not async, which would cost every resolve a promise and a tick, so each calls the shared guard inside a try and returns its throw through Promise.reject, leaving the resolver call outside the try. The container's other async methods are async already and convert their own throw.

Upserts: eager or computed, by hit rate

The package's own upsert helpers in core/map-upsert.ts come in two forms. getOrInsert takes a fallback the caller has already evaluated; getOrInsertComputed calls a factory only on a miss. The choice follows which case dominates:

  • BindingLookupCache.taggedEntry() runs on every criterion-carrying resolve and almost always hits on a long-lived container, so it takes the computed form, with the factory hoisted to module scope so no closure is allocated per call.
  • The registry's index insertions are the mirror image: a bind is usually the token's first, so the fallback is usually the value that gets stored, and the eager form wins. (add() itself no longer upserts — its list is copy-on-write, so it always builds the next array.)

Both forms are in the tree on purpose. They stay the package's own rather than the platform's Map.prototype.getOrInsert pair, which would move the Node floor to 26 for two call shapes a local helper already covers. Both reject a value type that admits undefined, because they read absence with one get rather than a second has; a map that stores undefined needs ScopeManager.readScoped()'s shape, not this one.

A lazily allocated index also spells its type arguments — this.#field ??= new Map<Key, Value>() — because TypeScript does not contextually type the right-hand side of ??=. A bare new Map() there becomes Map<any, any> and silently drops every check that reads the field.

Practical note. "Almost always hits" is a claim about a long-lived container. It inverts in a per-request one, where every first criterion-carrying resolve of a token buys a map it will not read again. Measure fresh vs warm if you revisit this; the deferred inner map in Lookup caches is what that measurement produced.

Tried on the lookup lane, and rejected

Two restructurings of the registry's hot lookup were built, gated by tests/integration/binding-lookup-parity.test.ts and tests/integration/container-retention.test.ts, measured with paired A/B passes against a same-build A/A, and dropped. Each looked like a win in isolation; both paid on every resolve for something only binding or a narrow lane gained. They are recorded so the next attempt starts from here.

  • A lookup cache on the token. A token carried a slot naming the binding a registry last answered it with, checked against a loneRegistry field on the binding, so a hit skipped the hash. The check, or the flag choosing whether a registry used the cache at all, took getFastDefault() past the small-function threshold; where it stayed small, a root's resolve won in one call-site context and lost in another. Every variant also paid on a lane it could not serve: a child-owned binding read hot, a per-request child seating a cache it never read again, a token rebound before each single read writing into a long-lived object.
  • Filing registrations lazily. A bind appended to a log the registry filed on its next read, skipping the last-wins probe while nothing had been read. Binding-only work sped up and the cold bind-then-resolve path a little, but a read then has to know whether a log is waiting. That test sat either in getFastDefault(), past the threshold, or at every entry point a resolve can start from, which is every resolve. Equivalence with immediate registration also took a differential test to hold: a displacement is decided when the displacer registers, and any later write invalidates what that decision assumed.

What moves registration's cost is the step count, not the lookup: a fluent chain and a declared module (the fluent chain) each register a binding once, in its final shape, and leave the resolve lane untouched. Resolution outnumbers registration by orders of magnitude in any application, so a shape that taxes the resolve lane to shorten the bind lane is the wrong trade even where the bind rows show it winning.

Two refinements of the declared path were measured and dropped. One version bump for the whole list saved nothing a probe could see, and filing the list as a second pass over chains built first cost more than adding each chain as it is built. A frozen declaration list put every element read of every load on V8's slower path for frozen arrays, so Module.fromBindings copies the caller's list and leaves the copy unfrozen.

Shapes described elsewhere that are perf decisions

These are covered in the sections above; this list exists so a perf review can find them in one place.

  • One hidden class for all bindings, and the chain is the binding — One shape, one construction site. One allocation per bind; BindingChain is the only construction site.
  • scope as a total field — scope is a total field. Keeps the field's type feedback one shape.
  • Path-independent entries baked into plans — Compiled plans and escapes. Saves a runtime lookup per criterion-carrying param.
  • One-entry inline caches in front of maps, and the deferred memo maps behind defaultEntry() and taggedEntry() — Lookup caches.
  • One facts record per class, looked up once per cold class resolve — A container defers most of itself.
  • Chain sums memoized against a process-wide state epoch — Lookup caches. Removes the parent walk's slope while nothing has changed.
  • Interned criteria as Map keys, and the bitmask prefilter — Criteria and tag indexes. Removes a hash level and the stringification of tag values.
  • Multi-tag bucket index past a size threshold — Criteria and tag indexes. Semantics identical on both sides.
  • The synchronous cycle check is a flag on the binding — one field read and two writes per level, at any depth, where a scan grew with the path and a Set past a depth threshold cost an allocation and a hash per level. The seeded paths an escape and an async level's synchronous call hand the runtime are marked around the call instead.
  • One live level context kept per resolver — The async pipeline. A collector that finds none deoptimizes the lane on every collection.
  • The depth-indexed context pool and the shared, lent rootStack — The context pool. Write barriers are the cost model.
  • Deferred subsystems — Deferral. An empty Map has a backing store.
  • Singleton instance on the binding — Singleton on the binding. Field read instead of keyed lookup.
  • Errors built at the throw site — Layered architecture and #requireBinding in Lookup caches.

Practical notes for maintainers

Changing anything here

There is no gate to clear, just a suggested order that tends to save time:

  1. Understand the invariant(s) the code you are touching depends on (the labelled blocks above and the table in Important invariants), and check the test named next to each. If a test is what is holding an invariant, it will tell you fast whether your change broke it.
  2. If the change is about speed, measure it. What a shape costs, and whether a new idea beats it, is an empirical question. The benchmark suite (benchmarks/di) is the source of truth, BENCH_GUIDE.md is the method, and the performance step of CONTRIBUTING.md is the checklist.

Two things that guide does not cover and this engine keeps demonstrating:

  • Measure cold paths too. A change that wins the hot loop can lose badly on container construction, and the hot loops hide it completely.
  • Validate a perf idea by throwaway ablation, not by reasoning. Build the variant, measure it, delete it. Past attempts against this engine were mostly wrong in the direction their author expected, which is the best argument for measuring rather than arguing.

Type tests need to be checked in both directions

A type test that compiles once its invariant is gone asserts nothing. Before trusting tests/types/* to hold an invariant, remove the thing it guards and confirm the test stops compiling. This applies to the variance test, the container-API test and the branch-ownership test alike.

Two tests can hold one contract

The fluent-chain ordering (The fluent chain) is pinned by a type test and a runtime test that assert different things. When one fails, work out which half it is holding before reshaping the class; asserting on the object's shape instead would forbid the single-object design.

Lessons the engine has already taught

Each of these is a failure that happened once and is now pinned by a test. They are kept here because each one is a general pattern, not just a fixed bug.

  • A fast lane that re-decides a rule drifts from the full path. resolveAll's name lane once returned a binding whose when() predicate resolve was refusing, because it re-implemented the slot match instead of calling matchesSlot(). That is why One slot shape insists on one matcher.
  • Reading the presence of a key as a reason to give up hid the recommended spelling from the index. singleCriterionOnlyOf once treated a present tag field as "not a single criterion", which made the shorthand — the form the README reaches for — the only spelling the index never served. The parity test in Criteria and tag indexes exists because of it.
  • A restriction can outlive the fact that justified it. simpleTagOf once excluded predicate-bearing bindings from the tag index "because the index is read without a re-check". That justification stayed in place long after the ±0 fix had given every indexed hit a predicate evaluation. The predicate evaluation is now pinned by tests/unit/resolution/select/tagged-selection.test.ts, so the failure stale reasoning could have caused — an indexed hit reaching a caller past a predicate that refused it — cannot land quietly. When you read any comment justifying a restriction, check whether the reason is still true.
  • A dependency escaped too early. A named or tagged dependency used to escape on options !== undefined, before anything tried to look it up. lookupPathIndependentEntry (Compiled plans and escapes) is the fix: a criterion the registry can settle is not opaque.
  • A memo keyed on binding id missed a late hook. The activation-need memo once cached "no activation" per binding id and skipped a late .onActivation() hook on every lane that consulted it, while the default dynamic lane read the field fresh and honoured it. The failure was silent. Lookup caches describes the fix.
  • A name-keyed cycle check reported a false cycle. Two token("app:Config") from different modules are distinct tokens, and a check keyed on display name reported a cycle for a legitimately acyclic chain that held both. Every path check now keys on bindingId (Cycle detection).
  • A threshold that switched lanes changed semantics. A removed constant, DEEP_LANE_THRESHOLD, switched the async pipeline between lanes past a depth. That silently changed context identity, stack frames and promise shape at the crossing point, and reported a false CircularDependencyError for a diamond dependency past it. The membership-set threshold that later replaced it broke the same rule once while looking like it was only choosing a data structure; the synchronous check is now a flag with no threshold at all, and the invariant in Cycle detection is stated as a rule about thresholds in general.
  • A shared async path reported a false cycle for a diamond. A settle-scoped path once reported Circular dependency detected: a → b → d → c → d for A awaiting B and C in parallel, both needing D. Clearing inFlight on promise-return (The async pipeline) removed it.
  • A context shared by every level of a chain answered a continuation from another chain's frames. One context per chain read a live stack; after an await that stack held whatever was resolving then, so whenParentIs picked the wrong binding. Every level now owns its context and its prefix. The lane differential test's probes found it.
  • The cheapest answer to "what should this return when there is nothing to return" is no method. ScopeManager.getAllScoped() was the only bulk reader that could meet an unallocated cache, and it had no callers, so it was removed rather than given an empty-map fallback.

License

Released under the MIT License.