Skip to content

Dependency Injection

inject() works with no decorators, no reflect-metadata, no compiler flags. This page explains the mechanism that makes that possible: the construction frame.

The construction frame

When a container materializes a binding, it opens a frame - a record of which container is building right now, plus the read's request cache and its token chain. Construction runs inside that frame, and inject() simply reads it: whatever is being built, inject(Token) resolves through the container that is building it.

tsx
class OrdersStore {
    // Runs during construction - the frame is open, `inject` knows who is asking
    private readonly api = inject(OrdersApi)
}

The frame is anchored at the container that declares the binding - never the one the read started from. A fork resolving an inherited singleton constructs it in the declaring container's frame, so the singleton's own dependencies resolve from where it was declared. Shadowing below cannot change what an inherited instance is made of.

Where injection works

The frame exists only while construction runs, synchronously:

  • ✅ a field initializer
  • ✅ a constructor body
  • ✅ a useFactory body
  • ✅ inside container.construct(Clazz)
  • ✅ inside runInInjectionContext(container, fn)

Everywhere else there is no frame, and every injector throws an InjectionContextError (code REMODULO/INJECTION_CONTEXT):

inject(OrdersApi) was called outside a construction frame. inject reads the container that is building right now, so it only works synchronously inside a constructor body, a field initializer, or a useFactory body. In an async factory that means BEFORE the first await - once the factory yields, the frame is gone. Outside construction, read from a container directly with resolve, or open a frame explicitly with runInInjectionContext. A frame that should be there and is not can also mean two copies of @remodulo/container in one process: each holds its own frame, and injection never crosses between them.

The last sentence is worth keeping in mind: two copies of the package in one bundle each hold their own frame, and injection never crosses between them. One deduplicated @remodulo/container per process is part of the contract.

The injectors

InjectorReturnsOn miss
inject(token, { mode?, delayed? })Tthrows
injectOptional(token, { mode?, delayed? })T | undefinedundefined
injectAll(token, { mode?, delayed? })T[][]
injectContainer()the declaring Container-
injectResolver()the declaring container's Resolver-

{ mode } and { delayed: true } behave exactly as in the module layer - Providers & Resolution covers both. injectContainer and injectResolver capture the door itself: taken at construction, used any time after.

Opening a frame by hand

runInInjectionContext(container, fn) opens a frame anchored at container and runs fn inside it, so bare inject() works outside construction:

tsx
const report = runInInjectionContext(container, () => {
    const api = inject(OrdersApi)
    return buildReport(api)
})

A frame already in progress is not replaced - the nested frame lends the outer one's request cache and token chain, so a request-scoped instance stays shared and a cycle through the boundary is still caught.

The request cache

The frame carries the read's request cache, and that is the whole implementation of the request scope: one resolve/resolveAll call opens one graph, everything constructed inside it shares the cache, and the cache dies when the read returns. The next read starts a fresh one.

Cycles

The frame also carries the token chain - every token currently under construction. A token that appears twice is a cycle, caught on the way down:

Circular dependency found: A -> B -> A

The CycleError carries the ring as error.chain, alias hops included. Breaking one is a code change: { delayed: true } moves one side's read out of construction time - Circular Dependencies.

MIT licensed.