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.
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
useFactorybody - ✅ 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.
injectreads the container that is building right now, so it only works synchronously inside a constructor body, a field initializer, or auseFactorybody. In an async factory that means BEFORE the firstawait- once the factory yields, the frame is gone. Outside construction, read from a container directly withresolve, or open a frame explicitly withrunInInjectionContext. 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
| Injector | Returns | On miss |
|---|---|---|
inject(token, { mode?, delayed? }) | T | throws |
injectOptional(token, { mode?, delayed? }) | T | undefined | undefined |
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:
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.