Providers & Resolution
A provider is a recipe: a token to register under, and a way to produce the value. A module takes a list of them and turns it into live instances; resolution is how you read those instances back.
This page covers every form a provider can take, when each one is constructed, and every way to read one - from a service, from a component, from a module hook.
Provider forms
The providers array accepts the following forms:
| Form | Spelling | Produced |
|---|---|---|
| Class | OrdersStore | constructed at module init |
| Class with options | { useClass: OrdersStore, ... } | constructed at module init |
| Class, explicit token | { provide, useClass } | constructed at module init |
| Value | { provide, useValue } | never - you hand the value over |
| Factory | { provide, useFactory } | the factory runs at module init |
| Alias | { provide, useExisting } | never - points at another token |
const OrdersModule = createModuleComponent({
providers: [
// The shorthand: the class is both the token and the implementation
OrdersStore,
// The same registration, in the spelling that takes options
{ useClass: OrdersCache, scope: "transient" },
// Explicit token, separate implementation
{ provide: OrdersApi, useClass: RestOrdersApi },
// A value you already have: config, a host object
{ provide: API_URL, useValue: "https://acme.example" },
// Construction that needs logic
{ provide: OrdersSocket, useFactory: () => connectSocket() },
// A second name for something already registered
{ provide: OrdersReader, useExisting: OrdersStore },
],
})The class shorthand covers most of an app. The object forms exist for the cases it cannot spell: a token that is not a class, a value produced outside, construction logic, an alias.
Tokens
Every provider registers under a token. A class is its own token - that is why the shorthand works. For everything else - a value, a factory, an interface - you mint one with makeTokenizer:
import { makeTokenizer } from "@remodulo/container"
const token = makeTokenizer("@acme/orders")
export const API_URL = token<string>("api-url")
export const OrdersReader = token<Reader>("orders-reader")makeTokenizer(namespace) takes a required namespace - use your package name. Every token it mints is interned as Symbol.for("<namespace>:<name>"), so the same name through the same namespace is always the same token: twice in one file, across hot reloads, or in two copies of a package sharing a process.
A plain string or symbol also works as a token. makeTokenizer is the recommended way because it namespaces for you - two packages both minting "api-url" never collide.
Scopes
A scope answers one question: how many instances does one declaration produce.
| Scope | Instances |
|---|---|
singleton (default) | one per module that declares it |
transient | a fresh one per resolve |
request | one per resolution graph |
const OrdersModule = createModuleComponent({
providers: [
OrdersStore, // singleton
// A fresh instance for every resolve
{ useClass: OrdersMapper, scope: "transient" },
// One instance shared by everything reached from a single resolve, fresh for the next one
{ useClass: RequestTrace, scope: "request" },
],
})scope exists on the class and factory forms. A value is handed over, an alias points elsewhere - there is nothing to scope.
Only singletons carry lifecycle. One instance, one death point. A transient or request instance never sees the module hooks - see Lifecycle.
Lazy
Singletons are constructed at module init, in registration order. lazy: true skips that eager pass - the instance is constructed on first resolve instead:
{ useClass: ReportExporter, lazy: true }A lazy instance catches up on construction: onModuleInit fires immediately, and onModuleMount follows if the module is already mounted. From there it is an ordinary participant - unmounted and destroyed with the module.
Never resolved means never constructed: no instance, no hooks, nothing to bury.
Good for something heavy that most sessions never touch.
Collections
multi: true contributes to a collection under the token instead of claiming it. Several providers, one token, and the reader gets them all:
const token = makeTokenizer("@acme/orders")
export const OrderCheck = token<Check>("order-check")
const CheckoutModule = createModuleComponent({
providers: [
{ provide: OrderCheck, useClass: StockCheck, multi: true },
{ provide: OrderCheck, useClass: FraudCheck, multi: true },
],
})
class Checkout {
// [StockCheck, FraudCheck] - in registration order
private readonly checks = injectAll(OrderCheck)
}injectAll reads the whole chain by default: contributions from the module and every module above it, nearest first. An unregistered token is an empty collection - [], not an error.
A token is either single or multi, across the whole chain. A single registration claims the token - a second claim throws. multi contributes - mixing the two throws at registration, even when the other side lives in an ancestor module. The reads enforce the same line: inject on a multi token throws, injectAll on a single one throws.
A multi class provider needs an explicit provide. The shorthand registers under the class itself, and a class cannot be a collection of itself.
A collection agrees on lazy. All contributions eager, or all lazy - a mix throws at registration.
Injection & Resolution
There are two ways to read a provider, split by time: injection happens while an instance is being constructed, resolution works any time after.
Injection
Inside a provider, dependencies are read with the injectors:
class Checkout {
private readonly api = inject(OrdersApi)
private readonly flags = injectOptional(FEATURE_FLAGS)
private readonly checks = injectAll(OrderCheck)
}inject(token)- required: throws when nothing is registeredinjectOptional(token)-undefinedwhen nothing is registeredinjectAll(token)- the collection;[]when nothing contributes
Injection works only during construction. The injectors read the container that is building right now, so they work synchronously inside a constructor body, a field initializer, or a useFactory body - and nowhere else:
class Checkout {
// ✅ Field initializer
private readonly api = inject(OrdersApi)
constructor() {
// ✅ Constructor body
this.currency = inject(CURRENCY)
}
submit(): void {
// ❌ Throws: "inject(OrdersApi) was called outside a construction frame."
const api = inject(OrdersApi)
}
}
const CheckoutModule = createModuleComponent({
providers: [
Checkout,
{
provide: OrdersReport,
// ✅ A factory body is a construction frame too
useFactory: () => {
const api = inject(OrdersApi)
return buildReport(api)
}
},
],
})Circular Dependencies
Two providers injecting each other can never finish constructing. The read throws, with the full chain in the error:
class A {
private readonly b = inject(B)
}
class B {
// ❌ Throws: "Circular dependency found: A -> B -> A"
private readonly a = inject(A)
}The fix is delayed - one side moves its read out of construction time, and the cycle is gone:
class B {
// ✅ Resolved on call, when A already exists
private readonly getA = inject(A, { delayed: true })
}Delayed
{ delayed: true } takes the read out of construction time. The injector returns a function instead of a value, and the resolve happens on call:
class Checkout {
private readonly exporter = inject(ReportExporter, { delayed: true })
export(): void {
this.exporter().open()
}
}Every call is a live resolve: a singleton comes back as the same instance, a transient is fresh per call. Nothing is cached by the function itself.
Mode
{ mode } controls how far the read looks. The default is "nearest" - the first declaration at or above the declaring module. "self" reads the declaring module's own providers only:
private readonly local = injectOptional(OrdersApi, { mode: "self" })Resolution
Resolution reads a module that is already built. Every door hands you the same object - the module's resolver.
In a component:
function OrdersView() {
const store = useResolve(OrdersStore)
const flags = useResolveOptional(FEATURE_FLAGS)
const checks = useResolveAll(OrderCheck)
return <ul>{/* ... */}</ul>
}The hooks mirror the injectors one for one. And useResolve is a read, not a subscription - it never re-renders anything. Updates are the job of your reactivity layer: Connecting Reactivity.
Resolver
For several reads, or a read inside a handler, take the resolver itself. A component gets it from useResolver, a module hook is called with it, a service grabs it at construction with injectResolver:
const OrdersModule = createModuleComponent({
providers: [OrdersStore],
onModuleMount: (resolver) => {
resolver.resolve(OrdersStore).refresh()
},
})
function RefreshButton() {
const resolver = useResolver()
return <button onClick={() => resolver.resolve(OrdersStore).refresh()}>Refresh</button>
}class Devtools {
// Taken at construction, used any time after
private readonly resolver = injectResolver()
}The resolver's surface:
resolve(token)- required: throws when nothing is registeredresolveOptional(token)-undefinedwhen nothing is registeredresolveAll(token)- the collection;[]when nothing contributesresolveOr(token, fallback)- the fallback when nothing is registeredisRegistered(token)-truewhen a read would landregistrations()- every registration the module itself declares, in orderentry(token)- the snapshot of a single token's registration;undefinedwhen the module declares noneentries(token)- the snapshots of a multi token's contributionson(event, listener)- observation; returns the unsubscribe callback
Every read takes the same modes as the injectors, spelled as a plain second argument: resolve(OrdersApi, "self"). The hooks follow the resolver: useResolve(OrdersApi, "self").
There is no register here. Providers are declared when the module is constructed and never after - if a different situation needs different bindings, that is a different module.
Prefer injection
A field declared with inject is a visible dependency; a resolver read inside a method is not. Reach for the resolver when the read genuinely happens later - a handler, a module hook, a decision made at runtime.
Features
A feature is a named bag of providers you can drop into any module's providers array:
import { createFeature } from "@remodulo/react"
export const presenceFeature = createFeature({
name: "presence",
providers: [PresenceSocket, PresenceStore],
})
const WorkspaceModule = createModuleComponent({
providers: [presenceFeature, OpenAreas],
})Features nest and are flattened where the module is constructed. The container is handed the contents, never the bag - a feature costs nothing at resolution and never becomes a scope of its own.
The same feature twice flattens to one copy. Include it directly and through another feature - still one set of providers, no duplicate-registration error.
A feature owns nothing. The module its providers land in is the owner: the same feature dropped into two modules gives two independent sets of instances, each disposed by its own module.
Metadata
Every object-form provider can carry a metadata bag - arbitrary readonly data attached to the registration:
{ useClass: OrdersStore, metadata: { owner: "team-orders" } }The container never reads it. It is frozen at registration and carried to the entry's snapshot, where diagnostics can:
resolver.entry(OrdersStore)?.metadata // { owner: "team-orders" }Good for devtools, tracing, and conventions of your own.
"lazy" is reserved
lazy: true on a provider is stored through this same bag under the key "lazy". Don't claim that key for yourself.