| name | dependency-injection |
| description | Use when creating abstractions, implementations, features, repositories, use cases, or any DI-wired service. Defines mandatory file separation — abstractions, implementations, null objects, and features MUST each be in their own file. Invoke BEFORE writing any DI-related code. |
Dependency Injection — @webiny/di Conventions
Every service lives behind an abstraction token. Implementations are classes bound to an abstraction. Consumers resolve the abstraction, never the implementation. Each concern gets its own file.
The Iron Rule
Every DI concern lives in its own file. No exceptions.
| Concern | File | Directory |
|---|
| Abstraction (interface + token + namespace) | abstractions/XxxRepository.ts | abstractions/ |
| Implementation (class + createImplementation) | XxxRepository.ts | feature root |
| Null/default implementation | NullXxxContext.ts | alongside abstraction or feature root |
| Feature (createFeature + registrations) | feature.ts or XxxFeature.ts | feature root |
Never combine these in one file. A file with createAbstraction() must not contain createImplementation(), a class, or createFeature().
Naming Conventions
Class Names vs Export Names
The Impl suffix exists only on the class declaration, never on anything exported or imported.
class ProjectRepositoryImpl implements Abstraction.Interface { ... }
export const ProjectRepository = Abstraction.createImplementation({
implementation: ProjectRepositoryImpl,
dependencies: [DatabaseClient]
});
When the same short name (ProjectRepository) is used by both the abstraction token and the implementation const, they live in different files — the import path distinguishes them. The as Abstraction alias inside the implementation file resolves the local clash:
import { ProjectRepository as Abstraction } from "./abstractions/ProjectRepository.js";
General Naming Rules
- Never abbreviate:
projectRepository not projRepo, authorizationService not authSvc
- Never use shorthand:
Project not Proj
- Constructor deps are always
private readonly with full names
- All class methods and properties MUST have explicit access modifiers (
public, private, protected) or use JS private # fields — implicit public is forbidden
Abstraction File
Abstractions MUST live in an abstractions/ directory — never a flat abstractions.ts file. Each abstraction gets its own file inside abstractions/, plus a barrel abstractions/index.ts that re-exports all tokens and interfaces. One file per abstraction, one createAbstraction() call per file.
Each file contains: interface, createAbstraction() call, namespace with type exports. Nothing else — no classes, no implementations, no features.
Every abstraction MUST have a sibling namespace with exported types. No exceptions. At minimum the namespace exports Interface (the abstraction's own interface). If the abstraction consumes external types, those are re-exported through the namespace too. An abstraction without a namespace is incomplete.
import { createAbstraction } from "@webiny/stdlib";
import type { Result } from "@webiny/stdlib";
import type { ProjectNotFoundError, ProjectPersistenceError } from "../errors.js";
export interface ProjectRecord {
id: string;
name: string;
apiUrl: string;
apiToken: string;
tenant: string;
createdAt: number;
updatedAt: number;
}
interface ProjectCreateInput {
name: string;
apiUrl: string;
apiToken: string;
tenant?: string;
}
export interface IProjectRepository {
list(): Promise<Result<ProjectRecord[], ProjectPersistenceError>>;
getById(id: string): <<, | >>;
(: ): <<, >>;
(: ): <<, | >>;
}
= createAbstraction<>();
{
= ;
= ;
= ;
= | ;
}
Rules:
- Interface is
export interface (required for strict declaration emit)
- All types accessed via namespace only (
ProjectRepository.Interface, ProjectRepository.Record)
- Every param type gets its own named interface — never inline structural types
- No
Parameters<>, ReturnType<>, or indexed access types — export explicit named types
- The namespace must contain ALL types — no top-level type exports scattered outside it
- The namespace re-exports every type the implementation needs — implementations import only the abstraction alias and reference types as
Abstraction.Record, Abstraction.CreateInput, etc.
Implementation File
Separate file at the feature root. Uses a local rename alias (as Abstraction) to avoid name clash.
import { Result } from "@webiny/stdlib";
import { DatabaseClient } from "~/db/abstractions/DatabaseClient.js";
import { ProjectRepository as Abstraction } from "./abstractions/ProjectRepository.js";
import { projects } from "~/db/schema.js";
import { eq } from "drizzle-orm";
import { generateId } from "@webiny/stdlib";
import { ProjectNotFoundError, ProjectPersistenceError } from "./errors.js";
class ProjectRepositoryImpl implements Abstraction.Interface {
public constructor(
private readonly databaseClient: DatabaseClient.Interface
) {}
public async list(): Promise<Result<Abstraction.Record[], ProjectPersistenceError>> {
{
rows = ...().(projects).();
.(rows);
} (error) {
.( (error ));
}
}
}
= .({
: ,
: []
});
Key points:
- Uses
Abstraction.createImplementation() — the abstraction token's own method
dependencies array is positional — matches constructor parameters in order
- Class is NOT exported — only the createImplementation const is
- Uses
Abstraction.Record, Abstraction.CreateInput — never imports types from other files directly
Null/Default Implementations
When a service needs a fallback, the null object gets its own file.
import { GraphQLClient } from "./abstractions/GraphQLClient.js";
class NullGraphQLClientImpl implements GraphQLClient.Interface {
}
export const NullGraphQLClient = NullGraphQLClientImpl;
Never put a class in an abstraction file.
Feature File
Container registrations live in feature.ts. Imports the implementation (not the abstraction) for registration. The register() function must be synchronous.
import { createFeature } from "@webiny/stdlib";
import { ProjectRepository } from "./ProjectRepository.js";
export const ProjectsFeature = createFeature({
name: "Shared/ProjectsFeature",
register(container) {
container.register(ProjectRepository).inSingletonScope();
}
});
Features compose — a parent feature calls child features in its register:
register(container) {
DatabaseFeature.register(container, { databaseClient });
CacheFeature.register(container, { cacheDir });
GeneratorFeature.register(container);
}
Barrel Exports (index.ts)
Export abstractions (tokens + types) and features. Never export implementations.
export { ProjectRepository } from "./abstractions/index.js";
export { ProjectsFeature } from "./feature.js";
Lifetime Scopes
container.register(Impl);
container.register(Impl).inSingletonScope();
container.registerInstance(Abstraction, value);
container.registerFactory(Abstraction, () => v);
House rule: Singletons for stateless services (repositories, clients, gateways, loggers). Transient for per-request state (use cases, presenters used standalone). Presenters injected as DI deps of other presenters must be singleton.
Dependencies
The dependencies array is positional — matches constructor parameters in order:
export const ProjectRepository = Abstraction.createImplementation({
implementation: ProjectRepositoryImpl,
dependencies: [DatabaseClient]
});
Options: [Dep, { optional: true }] for optional, [Dep, { multiple: true }] for resolveAll.
Every constructor dep is private readonly with the full type from the abstraction namespace:
public constructor(
private readonly projectRepository: ProjectRepository.Interface,
private readonly graphqlClient: GraphQLClient.Interface
) {}
Testing
Use createTestContainer() for all tests. It creates a fully-wired container with real SQLite, real generators, and a mock HttpClient (for external API calls). Resolve via container, never construct directly:
const tc = createTestContainer({ httpClient: mockHttpClient });
const repository = tc.container.resolve(ProjectRepository);
const app = await createServer(tc.container, [registerApiRoutes]);
const response = await app.inject({ method: "GET", url: "/api/projects" });
Rules:
- Never
new ProjectRepositoryImpl(dep) — always resolve through DI
- Only external HTTP calls may be mocked (via HttpClient abstraction)
- Build a fresh container per test to avoid singleton state bleeding
- Use
tc.cleanup() in afterEach to remove temp DB files
Common Mistakes
| Mistake | Fix |
|---|
| Class in abstraction file | Extract to own file (NullXxx.ts or Xxx.ts) |
| Feature + impl in same file | Split into feature.ts and Xxx.ts |
| Multiple concerns in one file | One responsibility per file — split immediately |
Impl suffix on exported const | Only the class gets Impl; const uses short name |
| Exporting implementation from index.ts | Only export abstractions and features from barrels |
Bare interface export (export interface IFoo) | Access via namespace: Foo.Interface |
| Inline structural types in signatures | Extract to named interface/type in namespace |
Flat abstractions.ts file | Always use abstractions/ directory with one file per token |
Multiple createAbstraction() in one file | Split into separate files inside abstractions/ |
| Abstraction without sibling namespace | Every abstraction MUST have a namespace (at minimum Interface) |
vi.fn() mocks for repos/services | Use real implementations via test container |
new ImplClass(dep) in tests | Resolve through DI container |
Async register() in features | register() is sync; async work in startup functions |
registerInstance() for code that should use createImplementation() | Use createImplementation() with dependencies array when possible |