| name | di-patterns |
| description | How dependency injection works in @webiny/stdlib — abstractions, implementations, features, and container wiring. |
| context | guides |
DI Patterns in @webiny/stdlib
Everything in this repo is built on constructor injection via @webiny/di. Tool code never uses @webiny/di directly — it always goes through the createAbstraction / createFeature wrappers exported from @webiny/stdlib.
Mental model
Every service lives behind an abstraction — a typed token. Implementations are classes bound to that token. Consumers resolve the abstraction, never the implementation:
import { createAbstraction } from "@webiny/stdlib";
interface IFileTool {
readFile(path: string): string | null;
}
export const FileTool = createAbstraction<IFileTool>("Core/FileTool");
export namespace FileTool {
export type Interface = IFileTool;
}
The namespace FileTool { export type Interface } pattern lets consumers write FileTool.Interface as the type and FileTool as the DI token — one import, both concepts.
The token name ("Core/FileTool") follows a "Domain/ToolName" format: "Core/" prefix for common utils, "Node/" prefix for Node-specific ones. It's used only for debug/error messages — uniqueness comes from an internal symbol, not the string.
createAbstraction
const FileTool = createAbstraction<IFileTool>("Core/FileTool");
Creates the DI token. Always paired with the namespace ... { export type Interface } block described above.
createImplementation
Attaches a concrete class to an abstraction token:
import { Logger } from "~/common/index.js";
import { FileTool as FileToolAbstraction } from "./abstractions/FileTool.js";
import { DirectoryTool } from "../DirectoryTool/abstractions/DirectoryTool.js";
class FileToolImpl implements FileToolAbstraction.Interface {
public constructor(
private readonly logger: Logger.Interface,
private readonly directoryTool: DirectoryTool.Interface
) {}
}
export const FileTool = FileToolAbstraction.createImplementation({
implementation: FileToolImpl,
dependencies: [Logger, DirectoryTool]
});
Note the local rename alias (FileTool as FileToolAbstraction) — it avoids a name collision, since the implementation file re-exports its own const FileTool (the createImplementation output). Consumers never import from this file directly; only feature.ts does.
The dependencies array is positional. It must match the constructor parameter order exactly. TypeScript statically enforces this — a mismatched array is a compile error.
Optional dependencies
Wrap the token in a tuple with { optional: true }. The container passes undefined if nothing is registered for that token:
export const PinoLogger = Logger.createImplementation({
implementation: PinoLoggerImpl,
dependencies: [[PinoLoggerConfig, { optional: true }]]
});
The constructor receives PinoLoggerConfig.Interface | undefined and must handle both cases. This is how PinoLoggerFeature and ConsoleLoggerFeature accept configuration without a required registration — callers who don't care about config just skip registering PinoLoggerConfig.
createFeature
A "feature" bundles related registrations behind one name — the composition unit that bootstrap code calls instead of registering individual tokens.
Most features take no parameters:
import { createFeature } from "@webiny/stdlib";
import { FileTool } from "./FileTool.js";
export const FileToolFeature = createFeature({
name: "Core/FileToolFeature",
register(container) {
container.register(FileTool).inSingletonScope();
}
});
When a feature needs runtime configuration, use the typed parameter form:
interface MyFeatureParams {
logLevel: "debug" | "info" | "warn" | "error";
}
export const MyFeature = createFeature<MyFeatureParams>({
name: "Core/MyFeature",
register(container, params) {
container.registerInstance(Logger, makeLogger(params!.logLevel));
}
});
Call it as MyFeature.register(container, { logLevel: "error" }). The params! non-null assertion is required because createFeature<TRegister> types the parameter as TRegister | undefined to support both the parameterless and parameterized calling forms.
Features compose — a larger feature can call other features from its own register, keeping the bootstrap/composition root tidy.
Container
Used in application bootstrap and in tests to wire everything up:
import { Container } from "@webiny/di";
const container = new Container();
container.register(FileTool).inSingletonScope();
container.registerInstance(Logger, myLoggerInstance);
const tool = container.resolve(FileTool);
register(ImplClass) — registers an implementation class produced by createImplementation. Returns a builder; chain .inSingletonScope() for singleton lifetime. Default is transient (a new instance per resolve).
registerInstance(abstraction, value) — registers a pre-built value. Always singleton in effect.
resolve(abstraction) — returns a single instance. Throws if nothing is registered for the token.
Singleton scope
Always register utils as .inSingletonScope() unless there's a specific reason not to. Without it, the container creates a new instance on every resolve call, which is almost never what you want for stateless services like loggers, caches, and filesystem tools.
container.register(FileTool).inSingletonScope();
container.register(FileTool);
The four-file layout
Every tool/service in this repo follows the same shape:
abstractions/ToolName.ts — interface, createAbstraction token, namespace ToolName { export type Interface }.
ToolName.ts — implementation class + createImplementation, using the local rename alias.
feature.ts — createFeature call that registers the implementation.
index.ts — re-exports the abstraction token and the feature. Never re-exports the implementation class or the createImplementation output — those stay internal to the feature folder.
See the adding-a-feature skill for the full step-by-step when creating a new one.