Declare and inject front-end components

Version 1.3 by superadmin on 2026/05/13 18:07

Content

Steps

Warning

This page is a work in progress

Step 1 — Declare role symbols

Role identifiers are symbol values. Put them in a small, dependency-free module so every contributor can import them without pulling implementations.

~/~/ src/roles.ts export const NOTIFIER_ROLE = Symbol("Notifier"); export const CHANNEL_ROLE = Symbol("Channel"); export const FORMATTER_ROLE = Symbol("Formatter");

Step 2 — Define interfaces

~/~/ src/types.ts export interface Formatter { format(message: string): string; } export interface Channel { send(message: string): Promise<void>; } export interface Notifier { notify(message: string): Promise<void>; }

Step 3 — Implement leaf components (no dependencies)

Each implementation class is marked @injectable().

~/~/ src/plain-formatter.ts import { injectable } from "@xwiki/platform-component-annotation-default"; import type { Formatter } from "./types"; @injectable() export class PlainFormatter implements Formatter { format(message: string): string { return message; } }
~/~/ src/loud-formatter.ts import { injectable } from "@xwiki/platform-component-annotation-default"; import type { Formatter } from "./types"; @injectable() export class LoudFormatter implements Formatter { format(message: string): string { return message.toUpperCase() + "!"; } }
~/~/ src/console-channel.ts import { injectable } from "@xwiki/platform-component-annotation-default"; import type { Channel } from "./types"; @injectable() export class ConsoleChannel implements Channel { async send(message: string): Promise<void> { console.log(`[console] ${message}`); } }
~/~/ src/memory-channel.ts import { injectable } from "@xwiki/platform-component-annotation-default"; import type { Channel } from "./types"; @injectable() export class MemoryChannel implements Channel { public readonly sent: string[] = []; async send(message: string): Promise<void> { this.sent.push(message); } }

Step 4 — Implement a component that injects others

DefaultNotifier depends on:

  • a specific Formatter selected by name (@inject + @named),
  • every registered Channel (@injectAll).
~/~/ src/default-notifier.ts import { inject, injectAll, injectable, named, } from "@xwiki/platform-component-annotation-default"; import { CHANNEL_ROLE, FORMATTER_ROLE } from "./roles"; import type { Channel, Formatter, Notifier } from "./types"; @injectable() export class DefaultNotifier implements Notifier { constructor( @inject(FORMATTER_ROLE) @named("loud") private readonly formatter: Formatter, @injectAll(CHANNEL_ROLE) private readonly channels: Channel[], ) {} async notify(message: string): Promise<void> { const formatted = this.formatter.format(message); await Promise.all(this.channels.map((c) => c.send(formatted~)~)~); } }

Step 5 — Register every contribution

Registrations attach lazy loaders to the shared manager. The loaders are only invoked on first resolution, so this file stays cheap to import.

~/~/ src/register.ts import { manager } from "@xwiki/platform-component-manager-default"; import { CHANNEL_ROLE, FORMATTER_ROLE, NOTIFIER_ROLE } from "./roles"; manager .registerComponent( FORMATTER_ROLE, () => import("./plain-formatter").then((m) => m.PlainFormatter), { name: "plain" }, ) .registerComponent( FORMATTER_ROLE, () => import("./loud-formatter").then((m) => m.LoudFormatter), { name: "loud" }, ) .registerComponent( CHANNEL_ROLE, () => import("./console-channel").then((m) => m.ConsoleChannel), { name: "console" }, ) .registerComponent( CHANNEL_ROLE, () => import("./memory-channel").then((m) => m.MemoryChannel), { name: "memory" }, ) .registerComponent( NOTIFIER_ROLE, () => import("./default-notifier").then((m) => m.DefaultNotifier), ~/~/ No name → defaults to "default". `getAsync(NOTIFIER_ROLE)` resolves it ~/~/ without needing a name. );

Notes:

  • The name option disambiguates contributions sharing the same role symbol.
  • @injectAll(CHANNEL_ROLE) will collect both channels because every registration is automatically tagged for "all" collection.
  • If two registrations collide on (symbol, name, priority), the second call throws synchronously.

Step 6 — Initialize, then resolve

Bootstrap code calls _init() once, after all registrations have been performed. Application code then awaits resolverPromise.

~/~/ src/bootstrap.ts import "./register"; ~/~/ side-effect import: triggers registrations import { _init, resolverPromise } from "@xwiki/platform-component-manager-default"; import { NOTIFIER_ROLE } from "./roles"; import type { Notifier } from "./types"; async function main() { await _init(); const resolver = await resolverPromise; const notifier = await resolver.getAsync<Notifier>(NOTIFIER_ROLE); await notifier.notify("hello world"); ~/~/ → ConsoleChannel logs: "[console] HELLO WORLD!" ~/~/ → MemoryChannel appends "HELLO WORLD!" to its `sent` array. } main();

What just happened

  1. _init() calls manager.build(), which constructs the Inversify container, binds the resolver, and installs a lazy trampoline for each registered (role, name).
  2. resolver.getAsync(NOTIFIER_ROLE) triggers resolution of the default notifier. Inversify imports default-notifier.ts, instantiates DefaultNotifier, and, as part of constructor injection:
    • resolves FORMATTER_ROLE with name "loud" → imports and instantiates LoudFormatter;
    • resolves every binding tagged "all" for CHANNEL_ROLE → imports and instantiates ConsoleChannel and MemoryChannel.
  3. All resolved instances are cached as singletons; subsequent getAsync/getAllAsync calls return the same instances.

Common pitfalls

  • Registering after _init() — registrations are ignored by the already- built container. Always finish registration before bootstrap.
  • Forgetting @injectable() on a class — Inversify will refuse to instantiate it.
  • String identifiers — role identifiers must be symbols; strings are not supported by this API.
  • Calling _init() twice — throws Error("init() has already been called").

Get Connected