Dependency Injection
Registering Services
ServiceCollection registration forms, singletons created at startup, automatic registration with @Injectable and its options.
Registering a service
Services are registered on the services collection (ServiceCollection) of the builder returned by Application.CreateBuilder(), before build(). Each registration ties a token (usually the class itself; it can also be a string or a symbol) to an implementation and a lifetime; the lifetimes are on the Lifetimes and Scopes page:
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, NotesService); // a single instance for the whole app
builder.services.addScoped(NoteDraft, NoteDraft); // one instance per navigation
builder.services.addTransient(SearchIndex, SearchIndex); // a new instance on every resolution
const app = builder.build();
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, NotesService); // a single instance for the whole app
builder.services.addScoped(NoteDraft, NoteDraft); // one instance per navigation
builder.services.addTransient(SearchIndex, SearchIndex); // a new instance on every resolution
const app = builder.build();
Registration forms
In the add*(token, impl) methods, impl can be one of these:
// 1) Class (useClass)
builder.services.addSingleton(NotesService, NotesService);
// 2) Ready value (useValue): an object that is not a class is registered as is
builder.services.addSingleton("config", { apiUrl: "/api/notes" });
// 3) Factory / descriptor object: with `deps`, the factory is called as (…dependencies, provider)
builder.services.addSingleton(SearchIndex, {
useFactory: (notes: NotesService) => new SearchIndex(notes),
deps: [NotesService],
});
// a factory without deps receives only the provider
builder.services.addSingleton(SearchIndex, { useFactory: (sp) => new SearchIndex(sp.get(NotesService)) });
// 4) Async value (Promise): wrapped in an async factory; resolve it with `getAsync`, not `get`
builder.services.addSingleton(SyncService, import("./services/sync").then((m) => new m.SyncService()));
const sync = await app.provider.getAsync(SyncService);
// 1) Class (useClass)
builder.services.addSingleton(NotesService, NotesService);
// 2) Ready value (useValue): an object that is not a class is registered as is
builder.services.addSingleton("config", { apiUrl: "/api/notes" });
// 3) Factory / descriptor object: with `deps`, the factory is called as (…dependencies, provider)
builder.services.addSingleton(SearchIndex, {
useFactory: (notes) => new SearchIndex(notes),
deps: [NotesService],
});
// a factory without deps receives only the provider
builder.services.addSingleton(SearchIndex, { useFactory: (sp) => new SearchIndex(sp.get(NotesService)) });
// 4) Async value (Promise): wrapped in an async factory; resolve it with `getAsync`, not `get`
builder.services.addSingleton(SyncService, import("./services/sync").then((m) => new m.SyncService()));
const sync = await app.provider.getAsync(SyncService);
The deps array works for both class and factory registrations: the entries are resolved in order and passed to the constructor/factory in the same order; in a factory the last parameter is always the ServiceProvider.
The provider builds only a class registration (useClass) with new. A value given with useValue and the value a factory returns are handed out as they are; get and getAsync return the same value. To hand out a function or the class itself as a service, use { useValue: fn } or a factory that returns it; a factory that wants an instance writes the new itself.
A function given directly to add*(token, impl) is constructed as a class. A function that cannot be constructed (an arrow function, an async function, an object method) gives a compile error in TypeScript, and at run time it throws MJX413 on the registration line:
builder.services.addSingleton('formatter', (x: number) => x.toFixed(2)); // error: MJX413
builder.services.addSingleton('formatter', { useValue: (x: number) => x.toFixed(2) }); // the function itself
builder.services.addSingleton(SearchIndex, { useFactory: (sp) => new SearchIndex(sp.get(NotesService)) }); // factory
builder.services.addSingleton('formatter', (x) => x.toFixed(2)); // error: MJX413
builder.services.addSingleton('formatter', { useValue: (x) => x.toFixed(2) }); // the function itself
builder.services.addSingleton(SearchIndex, { useFactory: (sp) => new SearchIndex(sp.get(NotesService)) }); // factory
ServiceCollection methods:
| Method | Description |
|---|---|
addSingleton/addScoped/addTransient(token, impl) |
Registration. |
tryAddSingleton/tryAddScoped/tryAddTransient(token, impl) |
Adds only if not registered yet; returns a boolean. |
replace(token, impl, lifetime?) |
Replaces an existing registration. |
remove(token) / has(token) |
Remove / check. |
reset() |
Clear everything. |
buildServiceProvider() |
Produces a ServiceProvider (build() does this automatically). |
Singletons created at startup
For a singleton to be created when the application starts, without being asked for from anywhere, give startup: true on the registration. Services whose constructor does work on its own (writing an attribute to the document, attaching a listener, opening a connection) are registered this way. The service in the example opens an effect while it is being built and writes the selected theme to the <html> element; it has to run even if no component asks for it:
@Injectable({ lifetime: "singleton", startup: true })
export class SettingsService {
readonly state = reactive({ theme: "system" as Theme });
constructor() {
effect(() => {
const root = document.documentElement;
if (this.state.theme === "system") delete root.dataset.theme;
else root.dataset.theme = this.state.theme;
});
}
}
@Injectable({ lifetime: "singleton", startup: true })
export class SettingsService {
state = reactive({ theme: "system" });
constructor() {
effect(() => {
const root = document.documentElement;
if (this.state.theme === "system") delete root.dataset.theme;
else root.dataset.theme = this.state.theme;
});
}
}
In an explicit registration startup is given in the object form; it works with useClass and useFactory, and tryAddSingleton takes the same form:
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
builder.services.addSingleton(SearchIndex, {
useFactory: (notes: NotesService) => new SearchIndex(notes),
deps: [NotesService],
startup: true,
});
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
builder.services.addSingleton(SearchIndex, {
useFactory: (notes) => new SearchIndex(notes),
deps: [NotesService],
startup: true,
});
- The services are created inside
app.run(), before the shell and the first page are built;build()does not create them. TheuseDevelopmentanduseLoggingsettings called beforerun()apply in the constructor. - The creation order is the registration order: the
add*calls beforebuild()in the order they are written, then the@Injectableclasses. Dependencies are built first, as usual. - A service resolved with
getbeforerun()is not created again; components,FromServiceandinjectget the same instance. - If the constructor throws,
run()throws the same error and the application does not start. - The services are created synchronously, with
get: for a service registered with an async factory or a Promise,run()throwsMJX402. Do not give these servicesstartup; start them withawait app.provider.getAsync(token). startupapplies only to asingletonregistration. Ontransientandscopedregistrations it is ignored, and in development mode anMJX415warning is written; the service is created on its first resolution.- Of the
@Injectableclasses, the ones whose module is loaded at the time ofrun()are created. A class whose module loads afterrun()(a service imported only by a lazy page) is not created when it loads; it is created on its first resolution. replace(token, impl)keeps the previous registration'sstartupvalue ifstartupis not written in the new registration;startup: falseturns it off.- While the application is running,
builder.rebuild()sets up a new provider and immediately creates thestartupsingletons again in that provider; if a constructor throws,rebuild()throws the same error. Components,app.providerand theServiceProvidertoken give the new provider. The old provider is disposed;rebuild()does not wait for this disposal. Arebuild()called beforerun()does not create the services;run()does.
Automatic registration with decorators
@Injectable marks a class for automatic registration, so no explicit add* line is needed. When the application is built (autoRegisterInjectables is called inside builder.build()), the marked classes are registered with their options.
Classes whose module loads after build() (services imported only by a lazy page) are registered too: when the descriptor is not found, the registration happens at the moment of the first resolution. An explicit registration (addSingleton/addScoped/addTransient) always overrides the decorator, and the decorator cannot override an explicit registration.
import { Injectable, inject, reactive } from "@motifx/core";
@Injectable({ lifetime: "singleton" })
export class NotesService {
readonly state = reactive({ items: [] as Note[] });
}
@Injectable() // default lifetime: 'transient'
export class SearchIndex {
private notes = inject(NotesService);
count() {
return this.notes.state.items.length;
}
}
import { Injectable, inject, reactive } from "@motifx/core";
@Injectable({ lifetime: "singleton" })
export class NotesService {
state = reactive({ items: [] });
}
@Injectable() // default lifetime: 'transient'
export class SearchIndex {
notes = inject(NotesService);
count() {
return this.notes.state.items.length;
}
}
@Injectable is a standard (TC39) class decorator; it needs no extra setting in tsconfig.json or in Vite's esbuild setting. @motifx/compiler compiles decorators in .ts and .js files just as in .tsx files; vite dev and vite build give the same result. Files whose nearest tsconfig.json has experimentalDecorators: true are left to TypeScript's legacy decorator transform; @Injectable works in that form as well.
@Injectable options
| Option | Meaning |
|---|---|
lifetime |
'singleton', 'scoped' or 'transient'; 'transient' if not given. |
deps |
Tokens resolved in order as constructor parameters (see deps). |
startup |
Singleton only: the service is created inside run() without being asked for (see Singletons created at startup). |
Next step
To learn how long a service lives, go on to Lifetimes and Scopes.