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,
});

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.