How to read this file. These are working notes to help the next person (possibly you, months from now) build an accurate mental model of the engine before changing it — not to fence the code off. Two kinds of statement are mixed together below, and it's worth telling them apart:
- Invariants (labelled as such) are about correctness — behaviour that other parts of the engine, or the tests, depend on. If you break one, something resolves the wrong binding or throws where it shouldn't. Take these seriously, and note that most are pinned by a named test.
- Everything else is explanation and design rationale — why the code is shaped the way it is, and what was true when it was written. That reasoning is a starting point, not a boundary. Performance claims in particular are time-specific; treat any "this shape is faster" argument here as a pointer to go measure it yourself against the benchmark suite, not as settled fact.
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-inversify 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).
Layers
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.
container/ Container, fluent binding chain ← the public surface
introspection/ inspector, dependency graph, adapters
↓
decorators/ @injectable, @inject, lifecycle decorators
metadata/ the reader port and its default reader
↓
resolution/ DependencyResolver + its collaborators ← the engine
↓
lifecycle/ LifecycleManager, ScopeManager ← per-container state
ambient/ the container an @inject accessor reads
↓
core/ token, types, tag, binding, registry, module ← the model
errors/ the taxonomy and its diagnostics
injection/ the descriptor every dependency normalises toFive levels, not four: decorators/ sits above the engine because an @inject accessor's initializer resolves through
the ambient container while the instance is being constructed, and container/ sits above both because it composes them
— it imports @injectable's registry and four of the metadata modules. Neither of those two levels is on a hot path,
which is why they read as peripheral; they are still ordered, and an import the other way is a violation.
Three of those directories name a rule rather than a topic. errors/ is cold by construction — the hot path imports
the constructors and nothing else, because message building at a throw site is what an error path can afford and a hot
function's prefix is not. injection/ is the one shape both dependency sources normalise to, which is why the model
can read it without reaching up into decorators/. And resolution/ groups by lane — cache/, path/, plan/,
select/ — because the reasoning about it is per-lane, not per-noun.
Every module is an entry point. package.json#exports is generated by codefast mirror from the
mirror["@codefast/di"] entry in codefast.config.js, with no exclusions: this repo is its
own sole consumer, so full access beats encapsulation, and the root export additionally re-exports everything a typical
consumer needs. One naming consequence: because the map is derived from dist/, reorganising src/ renames published
specifiers, so strip: "./introspection/" pins the introspection modules' consumer-facing specifiers where they
shipped.
Practical note. package.json#exports is generated — edit codefast.config.js and re-run pnpm cli:mirror rather
than hand-editing the map. If a refactor moves a directory, pnpm cli:mirror:preview shows renamed specifiers before
you commit.
The build runs isolatedDeclarations, so every exported value carries an explicit type. A satisfies alone is not
one: it validates a literal without naming the type the declaration emits, which is what per-file declaration emit
needs. 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, so the
compiler rejects the annotation the day one of them stops being covariant — the property the engine leans on when it
erases the value type at every internal lane and casts once at a public entry point. 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 tests/types/binding-variance.test.ts exists to protect.
The model
One binding shape, one construction site. Every binding is built by createBinding() in
binding.ts — a single object literal listing every kind's fields in one fixed order, so all
bindings in a process share one V8 hidden class. Mixed binding kinds otherwise make the resolver's hot property reads
(kind/scope/factory) megamorphic. The registry therefore stores what it is handed by reference rather than
re-copying it.
Invariant (correctness). A token's binding list is copy-on-write:
addandremoveByIdreplace the array and never splice one that has been handed out. Selection walks the registry's own list while runningwhen()predicates — user code free to rebind the very token mid-walk — and the walk keeping its pre-mutation array is what lets every candidate registered at selection start still get its predicate, with no defensive copy on the read side.tests/unit/resolution/select/binding-select.test.tspins the observable half.
Convention (performance-load-bearing). Bindings are constructed through
createBinding(), keeping the literal's key order intact, so the single-hidden-class property holds. Constructing a binding with a bare object literal, or reordering the keys, 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. If you have a reason to change the construction site, measure it against the benchmark suite.
scope is total, so the engine reads it as a field. AliasBinding declares scope: "transient" — an alias defers
scoping to what it points at, which is transient behaviour — so no kind is missing the field and no read needs an
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's that the field's type feedback stays one
shape.
The engine erases the value type, and one declaration choice is what allows it. Binding<Value> has to stay
assignable to Binding, because every internal lane takes the erased union and the cast to Value belongs at the
public entry point, where the caller's token is the claim being made. That holds only because the lifecycle hooks are
declared as methods on BindingLifecycleHooks: as function-typed properties, Value sits in a parameter position,
strictFunctionTypes makes the binding invariant, and every internal signature grows either a cast or a structural
stand-in. Method syntax compares those parameters bivariantly. The public ActivationHandler stays a function-typed
property, so a user's handler is still checked strictly — the bivariance reaches only the field read off a binding.
Invariant (type safety). Internal lanes take
Bindingand returnunknown; only the eight public resolve entry points nameValue, and each casts once. AValuetype parameter on a private method is a fiction — the caller supplies it through an unchecked cast, so it documents an intent the compiler never verified.tests/types/binding-variance.test.tsfails to compile if the hooks lose method syntax.
A memo on a binding is only sound while what it derives from is immutable. frame derives from the token name, id,
kind, slot and scope — and scope is the one field a fluent chain writes in place after registration. So
singleton()/transient()/scoped() call clearBindingFrame(). Without it a chain refined after its first resolve
reports the old scope to every when() predicate that reads ctx.parent.scope;
tests/unit/resolution/cache-invalidation.test.ts pins it.
Registration happens once. bind(T).toDynamic(f).singleton() registers on toDynamic(); singleton() then writes
scope in place on that same registered object. Only when*() re-slots, because slot and predicate are what the
registry indexes on — and it re-registers under the chain's original id, so id() is stable for the whole chain.
One object per bind(). A single BindingChain plays every role — the BindToBuilder before to*(), the
kind-specific builder after — and commits to the registry itself. bind() is typed as BindToBuilder, so
when*()/singleton() are not reachable before a to*(); the ordering is a type-level guarantee, matching
SPEC's fluent-chain section ("Compiler enforce"). A caller who has no types, or casts past them,
gets a ChainNotRegisteredError naming the token, never a silent no-op — whenDefault() asserts registration 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.tsasserts the refinements are absent frombind()'s type;tests/unit/container/bind-to-builder-order.test.tsasserts every one of them throws beforeto*(). Asserting instead that the methods are absent from the object would pin an implementation detail and forbid this single-object shape — so if you change the class, check which of the two a failing test is actually holding.
The engine
DependencyResolver is one large class on purpose: #private access 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's the design reasoning; if you ever want to challenge it, it's a
measurable claim, not an axiom.)
What is split out are the collaborators that need no cross-instance private access:
| Module | Owns |
|---|---|
binding-lookup-cache.ts |
options-less token → {binding, owner} memo, alias hops folded, stamped with the chain's summed registry versions |
class-introspector.ts |
per-class metadata: constructor params, @postConstruct presence, accessor injection, and the new itself |
activation-need.ts |
per-binding "does this need the activation pipeline", versioned on the lifecycle manager |
instantiation-plan.ts |
the plan compiler (below) |
resolution-path.ts |
cycle-detection bookkeeping carried on the path array |
binding-select.ts, constraints.ts |
candidate selection for name/tag/predicate shapes, and matchesSlot() — the one slot matcher |
resolve-options.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.
Invariant (single source of truth). Whether a slot matches a request is answered by
matchesSlot()alone, and whether a request carries exactly one criterion bysingleCriterionOnlyOf()— a lone name folds to the reservedslotNamecriterion there, so the name spelling and the tag spellings reach one lane. The resolver's fast lanes are lanes, not separate semantics — a fast lane that re-implements one of those rules is how the spellings drift. This isn't hypothetical:resolveAll's name lane once returned a binding whosewhen()predicateresolvewas refusing, precisely because it re-decided a rule instead of calling the shared matcher.
Both dependency sources are one shape. A class's ParamMetadata and a toResolved InjectionDescriptor are
structurally DependencySlot, so #resolveDeps/#resolveDepsAsync serve both and the plan compiler compiles both —
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.
Compiled plans and escapes
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 it does no per-resolve bookkeeping at all.
A dependency the compiler cannot see through — a factory, a scoped binding, an activation hook, a class past the depth
limit, a multi/optional/named param — 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, dispatched
through exactly the resolve the interpreter would have called. So cycle detection, constraint contexts and error paths
are identical to never having compiled. Without escapes, one toDynamic dependency anywhere drops the whole graph to
the interpreted path.
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/instantiation-plan-escapes.test.tspins this.
A dependency's criteria are fixed when it is declared, so nothing may rebuild 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 memoizes them on the slot
itself: sound to share across containers because they derive from the slot alone (a binding-keyed memo would not be,
since class metadata is global to the class rather than per container). The memoized object is frozen, because 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.
The frame copy in #compileEscapeThunk is load-bearing. This is a correctness invariant. Two mechanisms defeat any
scheme that shares or lends that array:
- The membership
SetthatenterResolutionPathattaches pastRESOLUTION_SET_THRESHOLDlives on the array object, so a lent array carries it into a lane that does not maintain it;[...frames]hands the escape an array with no set at all, because a spread does not copy symbol-keyed properties. - 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 then survives forever, where the interpreted
lane's
rootStackdrains 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's why this one is firm rather than a matter of taste; anything faster here has to keep both mechanisms from firing.
A criterion the registry can settle is not opaque. A dependency used to escape on options !== undefined, before
anything tried to look it up — yet 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, since a predicate reads the
resolution path and 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 replayresolveFromContext— the default slot — when handed no options, so a named singleton's cold materialization or a named factory would silently resolve a different binding without this.tests/unit/resolution/plan/instantiation-plan-named.test.tspins it.
Fast lanes that read as duplication
Two shapes here look like copy-paste of #resolveBinding and aren't. Both were removed on a DRY pass and put back after
measuring the regression, which is the 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.
A candidate answers where it is selected. #resolveCandidateSync/#resolveCandidateAsync re-check plain-constant
and cached-singleton before delegating, which #resolveBinding would check anyway. Routing every candidate through
#resolveBinding instead is paid per candidate rather than per call, which a resolveAll over cached singletons
multiplies 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, helps a constant resolve and charges 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 isn't the case it's looking for, and there are usually more of those. This dispatcher is also inlining-sensitive: a test added inside a branch it doesn't even take has moved an unrelated row. 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 (a criterion matches at most once per registry, so the size is known), and
#findBinding treats a lone candidate as its own selection — matching it is the decision, with no specificity to
weigh and no ambiguity to report.
An upsert's fallback is eager or computed by hit rate. The package's own upsert helpers
(core/map-upsert.ts) come in both forms: getOrInsert takes a fallback the caller has
already evaluated, getOrInsertComputed calls a factory only on a miss. So the choice follows which case dominates.
BindingLookupCache.taggedEntry() runs on every criterion-carrying resolve and almost always hits a long-lived
container, so it takes the computed form with the factory hoisted to module scope, so no closure is allocated per call
either. 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, and they stay the package's
own rather than the platform's ES2025 Map methods, 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 ??=, so a bare new Map() there becomes Map<any, any> and silently drops
every check that reads the field. (Note that "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 won't read again — worth
measuring fresh vs warm if you revisit this.)
A criterion is interned, so the index can be keyed by it. A tag key is minted by tag() and its criteria by
TagKey.of(), which caches one object per value — so Object.is equality
(SPEC — ResolveOptions) becomes object identity, and the registry keys tagged bindings by the
criterion itself rather than by key-then-value. That removes a hash level from every tagged lookup and, more
importantly, removes a divergence: a value-keyed Map compares keys by SameValueZero, which holds +0 and -0 equal
where Object.is does not. The intern cache splits those two under a private symbol, so the index cannot conflate them.
Invariant (correctness). Interning is what makes identity sound here, so a
BindingTagis only ever constructed throughTagKey.of()— the brand on the type is the enforcement, andtests/unit/resolution/select/tagged-selection.test.tspins the±0split it buys. Constructing one another way would let the index conflate+0and-0.
The multi-tag lane prefilters on keys, which is a subset test a Map still cannot do. 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. Bits wrap every 32 keys, so two keys can share
one — a false positive the identity comparison then rejects, never a false negative.
Agreeing on the answer is not agreeing on the lane. tag: pair, tags: [pair] and — through the reserved criterion
— name: n are each one-criterion requests, and singleCriterionOnlyOf is the admission test that has to see them that
way — reading the presence of tag as a reason to give up once made the shorthand (the form the README reaches for) the
only spelling the index never served. 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.tspins the lane alongside the answer.
resolveAll reads the tag index too, and one criterion is not the subset query several are. 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 is 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.
A caution this lane earned the hard way, worth remembering when you read any comment justifying a restriction:
simpleTagOf once excluded predicate-bearing bindings from the tag index "because the index is read without a re-check"
— a justification that stayed in place long after the ±0 fix had given every indexed hit a predicate evaluation, i.e.
long after the reason had stopped being true. The restriction outlived the fact. The predicate evaluation is now pinned
by tests/unit/resolution/select/tagged-selection.test.ts, so the failure that stale reasoning could have caused — an
indexed hit reaching a caller past a predicate that refused it — can't land quietly.
The multi-criterion case is a subset query — a request carrying several criteria matches every binding whose
criteria are a subset of them, so [A], [B] and [A,B] all answer a request for [A,B] — which no single Map
lookup can serve. 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 — no
dedup set, no per-resolve allocation beyond the candidate list selection was already building, and no stringified tag
values (the Object.is rule in SPEC still holds; buckets are keyed by the interned
criterion).
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. And it engages only past a size threshold on the
token's list: under it the generic scan is cheaper than the bucket walk, so 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 behavior. The residual cost on a small list is
one length read.
A hash lookup a loop repeats can benefit from an inline cache. LifecycleManager.activationHandlersFor() keeps a
one-entry token→hooks cache in front of its map, invalidated by registerActivation, because a resolve loop asks about
the same token every iteration. BindingLookupCache.defaultEntry() is the same shape one layer down: it 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 — and 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.
Invariant (correctness). Alias hops are not folded into
registry.getFastDefault(). That method is a bare own-registryMap.getreturning 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 intogetFastDefault()would miss a parent rebind.
Both lookup lanes memoize the chain walk, and the criterion one defers its map. taggedEntry mirrors defaultEntry
— chain-versioned, null meaning "this shape needs full selection", predicate- and alias-carrying hits declined — with
one difference the fresh-vs-warm measurement forced: a per-request child usually asks one (token, tag) exactly once,
and an inner-map allocation 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 memo key is the criterion object
itself — interning makes identity the slot contract's own Object.is, the same exactness the registry's tagged index
relies on, so an indexed hit needs no value re-check. The warm-vs-fresh numbers that settled this, and the shape's A/B,
live in the benchmark suite's RESULTS.md.
Late hooks are why a memo over a binding keys on the hook's identity. .onActivation() writes the 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. Getting this wrong is silent: the memo cached "no activation" per binding id and skipped a late hook on
every lane that consulted it, while the default dynamic lane read the field fresh and honored it.
tests/unit/resolution/cache-invalidation.test.ts pins all the lanes.
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 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 it back.
Cycle detection — two mechanisms, on purpose
| Lane | Mechanism | Why not the other one |
|---|---|---|
| Sync transient-dynamic | binding.inFlight, set on factory-enter and cleared on exit |
Sync resolution runs on one call stack, so the flag is exact path membership: O(1), no hashing, no side table |
| Everything else sync | enterResolutionPath — push and pop one shared path/stack pair |
One call stack, so the array is a stack; a per-binding flag cannot name the path in the error |
| Async, in a cascade | binding.inFlight, cleared when the factory returns its promise |
The request that closes a cycle comes from a factory's synchronous prefix, and synchronous code does not interleave |
| Async, out of one | extendResolutionBranch — append-only path, read by branch depth |
A continuation's ancestors are on no call stack, so they have to be carried explicitly |
Both variants of the first lane — with and without activation hooks — take the flag, because the argument for it
doesn't mention hooks: a hook runs on the same call stack the factory did. A hook that re-resolves its own token still
reports CircularDependencyError rather than recursing, and the flag is still released on every exit path —
tests/unit/resolution/in-flight-invariants.test.ts pins both for the hooked lane too.
Every path-based check keys on binding identity, never on a token's display name. A display name is not unique — two
token("Config") from different modules are distinct tokens, and a name-keyed check reported a false cycle for a
legitimately acyclic chain that held both. enterResolutionPath and extendResolutionBranch compare bindingId read
off the frame stack; the names an error or ctx.resolutionPath reports are derived from the frames at the moment
they are asked for — no name array exists to keep in step. A hop pushes and pops one stack, the branch helper takes one
depth, and an escape thunk copies one frame array; the error path pays the name materialization, not the hot path.
enterResolutionPath scans the frames linearly while the stack is short and attaches a membership Set of binding ids
past RESOLUTION_SET_THRESHOLD, which is 32. That threshold switches a data structure, not a behaviour: both
branches answer identically. (The 32 is a tuning constant from a depth sweep; re-sweeping it with the benchmark is cheap
if the typical graph depth in real consumers shifts, or the collector's behaviour changes.)
The set is seeded from the stack, so it has to be able to notice that it has gone stale. The frames already on the
stack when it attaches are handed no set and delete nothing on unwind, and the array outlives a resolve — the resolver
lends one stack — so a set that survived the unwind would refuse bindings nobody is resolving. A live set mirrors the
stack exactly (ids on an acyclic path are unique); enterResolutionPath drops one whose size no longer matches the
stack's length, and the next deep frame rebuilds it. tests/unit/resolution/path/resolution-path.test.ts pins the three
ways the seed becomes observable: a second resolve of the same deep graph, a sibling branch below the attach depth, and
the entry point called directly.
Invariant (correctness). A threshold here may choose an implementation; it must not choose a semantics. The removed
DEEP_LANE_THRESHOLDswitched lanes, so it silently changed context identity, stack frames and promise shape at the crossing point — and reported a falseCircularDependencyErrorfor a diamond dependency past it.RESOLUTION_SET_THRESHOLDbroke the same rule while looking like it was only choosing a data structure.
The async lane has two lanes, and the cheap one costs nothing per level
A resolution path is the chain of ancestors a level is being resolved under. Sync resolution runs on one call stack, so
one array pushed and popped is that chain, and binding.inFlight is exact membership in it. Async resolution was
assumed to have neither property, and paid for a settle-scoped path on every level to compensate.
It has both, for the requests that matter. 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. So the chain of "who is
resolving whom" at the moment of a request is the synchronous call stack, and a whole eight-level chain is built inside
one synchronous cascade before any of it settles. While that cascade is open the resolver's own #cascadeStack is
the ancestor chain: pushed on factory-enter, popped when the factory returns its promise — not when that promise
settles. Two cascades can never interleave, so binding.inFlight is exact path membership again, and every level shares
one AsyncCascadeContext. Nothing is allocated per level, nothing observes its own settlement.
That also removes a false positive rather than adding one. The shared settle-scoped path reported
Circular dependency detected: a → b → d → c → d for a diamond — A awaiting B and C in parallel, both needing
D, in which b → d → c is not a dependency edge at all. Under the cascade, D's flag is cleared when D's factory
returns its promise, so the second sibling finds it clear.
What the cascade cannot see is a request made from a continuation, after an await: its ancestors are on no call stack. Such a request arrives with the cascade empty, which is an exact test — a continuation never runs inside a synchronous cascade — so it escapes to the branch lane, and so does anything the cascade lane does not serve, seeded with a snapshot of the ancestors the cascade had reached. Once a subtree leaves the cascade it stays off it, which is what keeps a cycle crossing the boundary on one path.
The branch lane is the general one: 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 either, so it needs no settle
listener; it pays a context per level instead. A cycle formed entirely from post-await edges is caught there —
post-q → post-p → post-q — one level in from the true root, because the ancestors before the first escape were never
written down. That imprecision is the price of the cascade lane, and tests/unit/resolution/resolver-async.test.ts pins
it rather than leaving it to be discovered.
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, and it may carry anenterResolutionPathmembershipSetthis lane cannot keep true. SoextendResolutionBranchis the only thing that mints anOwnedBranchPath,AsyncLevelContextaccepts nothing else, and aBranchDepthis branded so a bare number cannot stand in for one — a depth from anywhere but this branch silently re-parents a level.AsyncLevelContextreads 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.tsfails to compile if either brand is removed — check that it still fails before trusting it, since a type test that compiles once its invariant is gone asserts nothing.
The cascade entry answers a plain constant and a cached singleton itself rather than escaping — a materialized async singleton that escaped would snapshot both cascade arrays for a resolve that never reads a path. (A perf shape, not a correctness matter.)
The sync lane's answer to the same question is InstantiationPlanCompiler, which cycle-checks a static subgraph once at
compile time and then executes with no bookkeeping. It ports 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, while a
dynamic-async factory stays opaque and keeps the cascade — which needs no graph because it reads the ancestors off the
call stack that is already there.
The async plan runs only at a true root — a resolveAsync arriving with the cascade idle. Inside an open cascade
the same binding escapes 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, and anything that
may yield one routes its dependencies through Promise.all — which is exactly how the interpreted async path treats
every dependency, down to unwrapping a promise-valued constant and starting every sibling before the first rejection
propagates. tests/unit/resolution/plan/instantiation-plan-async.test.ts pins the lane being active, the escape
criteria, the late-hook invalidation, and those two exactness corners.
The sync context pool, and the stack it lends
Pooling a resolution context by depth beat allocating one per level in measurement, which is why it's there. reset()
writes a handful of fields, and a pooled context outlives enough resolves to sit in old space, so each pointer write
takes a write barrier — which is why the resolver and the stack are compared before they are stored. That comparison can
only ever hit if the stack is the same object, so one stack is reused per resolver rather than minted per
container.resolve() call. The same mechanism sets the price: a fresh array is 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
rootStackis the whole lending protocol — every sync lane pops what it pushes, so a non-empty one means a resolve is holding the stack and the caller mints its own. A nestedcontainer.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.tspins 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 can't break here.
Invariant (correctness). A pooled context is reused only for the stack it already holds. The pools are keyed by stack — one for the root stack, one for the cascade stack, each depth-indexed — and any other stack (a nested resolve's minted array, an async level's snapshot) 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
ctxanswering from the wrong path, so awhen()predicate readingctx.parentselects 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.tspins 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. Everything else arrives on first use — the inspector, the module ref/binding
tables, the scope's in-flight and scoped caches, the registry's tagged slot indexes, and the class introspector's three
metadata caches. An empty Map is not free — V8 gives it a backing store — and those are Maps a bind-and-resolve
container never reads.
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.tsexercises each one as the first thing a fresh container does.
Deferral also raises the question of what a bulk reader should hand back when its cache was never allocated.
ScopeManager.getAllScoped() was the only such reader and had no callers anywhere, so it was removed rather than given
an empty-map fallback: the cheapest answer to "what should this return when there is nothing to return" is to not carry
the method.
One binding, one container — and the singleton slot that follows from it
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. So a singleton's slot
is per-binding, not per-container, and 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
materialized, so disposal and inspect() can still enumerate them.
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.tspins the parts that are easy to get wrong: the chain-shared read, invalidation on unbind and rebind, enumeration for disposal, and a cachedundefinedthat must stay distinguishable from a miss.
Changing anything here
There's no gate to clear — just a suggested order that tends to save time:
- Understand the invariant(s) the code you're touching depends on (the labelled blocks above), and check the test named next to each. If a test is what's holding an invariant, it'll tell you fast whether your change broke it.
- 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-inversify) is the source of truth,BENCH_GUIDE.mdis the method, and step 5 of CONTRIBUTING.md is the checklist.
Two things that guide doesn't 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.
License
Released under the MIT License.