Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/div0.8.0· Architecture

Architecture — @codefast/di

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 to

Five 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 lanecache/, 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: add and removeById replace the array and never splice one that has been handed out. Selection walks the registry's own list while running when() 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.ts pins 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 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 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.ts fails 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.ts asserts the refinements are absent from bind()'s type; tests/unit/container/bind-to-builder-order.test.ts asserts every one of them throws before to*(). 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 by 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 — 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 whose when() predicate resolve was 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.ts pins 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 Set that enterResolutionPath attaches past RESOLUTION_SET_THRESHOLD lives 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 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'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 replay resolveFromContext — 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.ts pins 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 BindingTag is only ever constructed through TagKey.of() — the brand on the type is the enforcement, and 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, 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.ts pins 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 onlyresolveAll 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-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.

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_THRESHOLD switched lanes, so it silently changed context identity, stack frames and promise shape at the crossing point — and reported a false CircularDependencyError for a diamond dependency past it. RESOLUTION_SET_THRESHOLD broke 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 prefixasync 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 an enterResolutionPath membership Set this lane cannot keep true. So extendResolutionBranch is the only thing that mints an OwnedBranchPath, 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, 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 rootStack is 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 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 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 ctx answering from the wrong path, so a when() predicate reading ctx.parent selects 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. 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.ts exercises 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.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.

Changing anything here

There's no gate to clear — just a suggested order that tends to save time:

  1. 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.
  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-inversify) is the source of truth, BENCH_GUIDE.md is the method, and step 5 of CONTRIBUTING.md is the checklist.

Two things that guide doesn't cover and this engine keeps demonstrating:

  1. Measure cold paths too. A change that wins the hot loop can lose badly on container construction, and the hot loops hide it completely.
  2. 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.