Declare and inject front-end components
Version 1.3 by superadmin on 2026/05/13 18:07
Content
Steps
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
- _init() calls manager.build(), which constructs the Inversify container, binds the resolver, and installs a lazy trampoline for each registered (role, name).
- 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.
- 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").