Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-cloths-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@webiny/di": patch
---

fix: multiple singleton implementations of the same abstraction now resolve correctly in minified/production builds
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules
dist
coverage/**
.idea
/.claude/settings.local.json
125 changes: 125 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# @webiny/di - Agent Guide

## What This Is

A TypeScript dependency injection container for the Webiny ecosystem. Published as `@webiny/di` on npm. ESM-only, Node >= 22.

## Architecture

The DI system has five core concepts:

1. **Abstraction** (`src/Abstraction.ts`) - A typed token (`symbol`-based) that represents an interface. Created with `new Abstraction<T>("Name")`. Each instance gets a unique symbol, so two abstractions with the same name are still distinct.

2. **Implementation** - A class bound to an abstraction via `createImplementation()` or `Abstraction.createImplementation()`. Metadata (abstraction token, dependencies) is stored on the class using `reflect-metadata`.

3. **Decorator** - Wraps an existing implementation. Created with `createDecorator()`. The decoratee is always the **last** constructor parameter. Additional dependencies come before it.

4. **Composite** - Aggregates multiple implementations of the same abstraction into one. Created with `createComposite()`. Typically takes `[Abstraction, { multiple: true }]` as a dependency.

5. **Container** (`src/Container.ts`) - Manages registrations and resolves instances. Supports child containers (`createChildContainer()`), where child overrides are preferred during resolution but unresolved dependencies fall back to the parent.

## Resolution Order

When resolving a single abstraction from a container:

1. Composite (if registered)
2. Instance registrations (`registerInstance`) - last registered wins
3. Class registrations (`register`) - last registered wins
4. Factory registrations (`registerFactory`) - last registered wins
5. Walk up to parent container and repeat
6. Throw if nothing found (unless `{ optional: true }`)

Decorators are applied after resolution, in registration order.

## Child Container Semantics

Child containers resolve dependencies starting from the **requesting** container (the one `resolve()` was called on), not from where the registration lives. This means a service registered in a parent can have its dependencies overridden by a child. The `resolveFrom` parameter in internal methods tracks this origin.

## Lifetime Scopes

- **Transient** (default) - New instance on every `resolve()` call.
- **Singleton** - One instance per container where registered. Cached after first resolution (including decorators). Shared with all child containers that don't shadow the registration.

## Key Files

| File | Role |
| ------------------------ | -------------------------------------------------------------------------------- |
| `src/Container.ts` | All registration and resolution logic |
| `src/Abstraction.ts` | Token class with factory methods |
| `src/Metadata.ts` | `reflect-metadata` wrapper (keys prefixed `wby:`) |
| `src/types.ts` | Core types and advanced mapped types for dependency validation |
| `src/create*.ts` | Factory functions (`createImplementation`, `createDecorator`, `createComposite`) |
| `src/is*.ts` | Runtime type guards |
| `src/DependencyGraph.ts` | WIP, not used in production, excluded from coverage |

## Type System

The `Dependencies<T>` type in `types.ts` maps constructor parameters to their abstraction declarations. It enforces at compile time that:

- Required params map to `Abstraction<T>` or `[Abstraction<T>]` or `[Abstraction<T>, { multiple: false }]`
- Optional params (`?`) require `[Abstraction<T>, { optional: true }]`
- Array params require `[Abstraction<T>, { multiple: true }]`

Type-level tests live in `__tests__/types.test-d.ts`.

## Toolchain

| Tool | Purpose |
| ---------------- | ------------------------------------- |
| **pnpm** | Package manager (v10 in CI) |
| **TypeScript 6** | Type checking (`tsc --noEmit`) |
| **Vitest** | Test runner (v4, `--run --typecheck`) |
| **rslib** | Bundler (ESM, esnext, with DTS) |
| **oxlint** | Linter (NOT eslint) |
| **oxfmt** | Formatter (NOT prettier) |
| **changesets** | Versioning and publishing |

## Commands

```sh
pnpm test # run all tests with typecheck
pnpm lint # tsc + oxlint + oxfmt check
pnpm build # rslib build
pnpm test:coverage # V8 coverage for src/
```

Run a single test file: `pnpm vitest run __tests__/container.test.ts`

## Checks to Run Before Committing

Run all three in order. All must pass — this matches CI.

```sh
pnpm lint # tsc (type errors) + oxlint (lint) + oxfmt --check (formatting)
pnpm build # rslib build → dist/index.js + dist/index.d.ts
pnpm test # vitest --run --typecheck (46 tests + type-level tests)
```

If `oxfmt --check` fails, fix with `pnpm oxfmt --write <file>`. Do not use prettier.

## CI

GitHub Actions on every push: `pnpm install --frozen-lockfile && pnpm lint && pnpm build && pnpm test`.

## Test Structure

- `__tests__/container.test.ts` - Core registration, resolution, decorators, composites, factories, error cases, type shorthand tests.
- `__tests__/singletons.test.ts` - Singleton lifetime across parent/child hierarchy.
- `__tests__/childContainer/` - Cross-container resolution bug tests with multi-level dependency override scenarios.
- `__tests__/types.test-d.ts` - Compile-time type assertion tests.
- `__tests__/setupEnv.ts` - Imports `reflect-metadata` globally for tests.

## Conventions

- No comments unless explaining a non-obvious "why".
- No eslint or prettier - use oxlint and oxfmt.
- `.js` extensions in imports (ESM resolution).
- Tests use `describe`/`test` from Vitest, not `it` (except `singletons.test.ts` which uses `it`).
- Abstractions are created as module-level constants (e.g., `const Logger = new Abstraction<ILogger>("Logger")`).
- Implementations are created via factory functions and stored as constants (e.g., `const ConsoleLoggerImpl = createImplementation({...})`).
- Run `pnpm changeset` before opening any PR that should trigger a release.

## Known Issues

- `DependencyGraph.ts` is WIP and uses `@ts-nocheck`. It references a `graphlib` dependency that isn't installed. Excluded from coverage.
- The child container test file (`__tests__/childContainer/childContainer.test.ts`) includes tests for a cross-resolution bug where child overrides must propagate through parent-registered services. This is a critical correctness property of the container.
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @webiny/di

See [AGENTS.md](./AGENTS.md) for full codebase documentation, architecture, conventions, and toolchain reference.
14 changes: 14 additions & 0 deletions __tests__/registry/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Abstraction } from "../../src/index.js";

export interface IPlugin {
name: string;
execute(): string;
}

export interface IPluginRegistry {
getAll(): IPlugin[];
executeAll(): string[];
}

export const PluginAbstraction = new Abstraction<IPlugin>("Plugin");
export const PluginRegistryAbstraction = new Abstraction<IPluginRegistry>("PluginRegistry");
67 changes: 67 additions & 0 deletions __tests__/registry/implementations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createImplementation } from "../../src/index.js";
import {
PluginAbstraction,
PluginRegistryAbstraction,
type IPlugin,
type IPluginRegistry
} from "./abstractions.js";

export class AuthPlugin implements IPlugin {
name = "auth";

execute(): string {
return "auth:executed";
}
}

export class CachePlugin implements IPlugin {
name = "cache";

execute(): string {
return "cache:executed";
}
}

export class LoggingPlugin implements IPlugin {
name = "logging";

execute(): string {
return "logging:executed";
}
}

export class PluginRegistry implements IPluginRegistry {
constructor(private plugins: IPlugin[]) {}

getAll(): IPlugin[] {
return this.plugins;
}

executeAll(): string[] {
return this.plugins.map(p => p.execute());
}
}

export const AuthPluginImpl = createImplementation({
abstraction: PluginAbstraction,
implementation: AuthPlugin,
dependencies: []
});

export const CachePluginImpl = createImplementation({
abstraction: PluginAbstraction,
implementation: CachePlugin,
dependencies: []
});

export const LoggingPluginImpl = createImplementation({
abstraction: PluginAbstraction,
implementation: LoggingPlugin,
dependencies: []
});

export const PluginRegistryImpl = createImplementation({
abstraction: PluginRegistryAbstraction,
implementation: PluginRegistry,
dependencies: [[PluginAbstraction, { multiple: true }]]
});
78 changes: 78 additions & 0 deletions __tests__/registry/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, test, expect, beforeEach } from "vitest";
import { Container } from "../../src/index.js";
import { PluginAbstraction, PluginRegistryAbstraction } from "./abstractions.js";
import {
AuthPlugin,
AuthPluginImpl,
CachePlugin,
CachePluginImpl,
LoggingPlugin,
LoggingPluginImpl,
PluginRegistryImpl
} from "./implementations.js";

describe("Plugin Registry - Singleton Scope", () => {
let container: Container;

beforeEach(() => {
container = new Container();
container.register(AuthPluginImpl).inSingletonScope();
container.register(CachePluginImpl).inSingletonScope();
container.register(LoggingPluginImpl).inSingletonScope();
container.register(PluginRegistryImpl).inSingletonScope();
});

test("registry is a singleton - resolving twice returns the same instance", () => {
const registry1 = container.resolve(PluginRegistryAbstraction);
const registry2 = container.resolve(PluginRegistryAbstraction);

expect(registry1).toBe(registry2);
});

test("registry contains all registered plugin implementations", () => {
const registry = container.resolve(PluginRegistryAbstraction);
const plugins = registry.getAll();

expect(plugins).toHaveLength(3);
expect(plugins.some(p => p instanceof AuthPlugin && p.name === "auth")).toBe(true);
expect(plugins.some(p => p instanceof CachePlugin && p.name === "cache")).toBe(true);
expect(plugins.some(p => p instanceof LoggingPlugin && p.name === "logging")).toBe(true);
});

test("plugins inside the registry are the same singleton instances as individually resolved", () => {
const registry = container.resolve(PluginRegistryAbstraction);
const plugins = registry.getAll();

const allResolved = container.resolveAll(PluginAbstraction);

for (const plugin of plugins) {
const matchingResolved = allResolved.find(p => p.constructor === plugin.constructor);
expect(plugin).toBe(matchingResolved);
}
});

test("plugin order matches registration order", () => {
const registry = container.resolve(PluginRegistryAbstraction);
const plugins = registry.getAll();

expect(plugins[0]).toBeInstanceOf(AuthPlugin);
expect(plugins[1]).toBeInstanceOf(CachePlugin);
expect(plugins[2]).toBeInstanceOf(LoggingPlugin);
});

test("child container resolves the same singleton registry as the parent", () => {
const child = container.createChildContainer();

const registryFromParent = container.resolve(PluginRegistryAbstraction);
const registryFromChild = child.resolve(PluginRegistryAbstraction);

expect(registryFromParent).toBe(registryFromChild);
});

test("executeAll delegates to every plugin in order", () => {
const registry = container.resolve(PluginRegistryAbstraction);
const results = registry.executeAll();

expect(results).toEqual(["auth:executed", "cache:executed", "logging:executed"]);
});
});
93 changes: 93 additions & 0 deletions __tests__/singletonCacheKeyCollision/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Container, Abstraction, createImplementation } from "../../src/index";

interface IPlugin {
name: string;
execute(): string;
}

interface IPluginRegistry {
getAll(): IPlugin[];
executeAll(): string[];
}

const PluginAbstraction = new Abstraction<IPlugin>("Plugin");
const PluginRegistryAbstraction = new Abstraction<IPluginRegistry>("PluginRegistry");

class AuthPlugin implements IPlugin {
name = "auth";

execute(): string {
return "auth:executed";
}
}

class CachePlugin implements IPlugin {
name = "cache";

execute(): string {
return "cache:executed";
}
}

class LoggingPlugin implements IPlugin {
name = "logging";

execute(): string {
return "logging:executed";
}
}

class PluginRegistry implements IPluginRegistry {
constructor(private plugins: IPlugin[]) {}

getAll(): IPlugin[] {
return this.plugins;
}

executeAll(): string[] {
return this.plugins.map(p => p.execute());
}
}

const AuthPluginImpl = createImplementation({
abstraction: PluginAbstraction,
implementation: AuthPlugin,
dependencies: []
});

const CachePluginImpl = createImplementation({
abstraction: PluginAbstraction,
implementation: CachePlugin,
dependencies: []
});

const LoggingPluginImpl = createImplementation({
abstraction: PluginAbstraction,
implementation: LoggingPlugin,
dependencies: []
});

const PluginRegistryImpl = createImplementation({
abstraction: PluginRegistryAbstraction,
implementation: PluginRegistry,
dependencies: [[PluginAbstraction, { multiple: true }]]
});

const container = new Container();
container.register(AuthPluginImpl).inSingletonScope();
container.register(CachePluginImpl).inSingletonScope();
container.register(LoggingPluginImpl).inSingletonScope();
container.register(PluginRegistryImpl).inSingletonScope();

const registry = container.resolve(PluginRegistryAbstraction);
const plugins = registry.getAll();
const results = registry.executeAll();

const output = {
pluginCount: plugins.length,
names: plugins.map(p => p.name),
results,
distinctInstances: new Set(plugins.map(p => p.constructor)).size
};

process.stdout.write(JSON.stringify(output));
Loading
Loading