Dependency Injection
Overview
ModularityJS uses an abstract DI contract (@modularityjs/di) that is independent of any specific container library. Decorators write framework-owned metadata at import time; the DI driver translates that metadata into the underlying container at boot time.
The DI driver is passed explicitly to createApp():
import { inversify } from '@modularityjs/di-inversify';
const app = await createApp({
di: inversify,
modules: [...],
});No global state, no side-effect imports, no implicit registration.
Decorators
@Injectable()
Marks a class as injectable. Required on every class that participates in DI — services, controllers, config classes, etc.
import { Injectable } from '@modularityjs/di';
@Injectable()
class UserService {
// ...
}@Inject(token)
Injects a dependency by token. The token can be a class, abstract class, string, or symbol.
@Injectable()
class OrderService {
constructor(
@Inject(UserService) private readonly users: UserService,
@Inject(CacheService) private readonly cache: CacheService,
) {}
}When the token is a class, it serves as both the TypeScript type and the DI token — no separate token declaration needed.
@InjectAll(token)
Injects all instances bound to a token as an array. Used when multiple implementations exist for the same token.
@Injectable()
class NotificationService {
constructor(
@InjectAll(NotificationChannel)
private readonly channels: NotificationChannel[],
) {}
async notify(message: string) {
for (const channel of this.channels) {
await channel.send(message);
}
}
}@InjectOptional(token)
Injects a dependency if bound, or undefined if not. Useful for optional features.
@Injectable()
class AppService {
constructor(
@InjectOptional(MetricsService) private readonly metrics?: MetricsService,
) {}
handle() {
this.metrics?.increment('requests');
}
}@Optional()
Low-level decorator that marks a parameter as optional. @InjectOptional(token) is shorthand for @Inject(token) @Optional().
Named qualifiers — @Inject(token, { named })
@Inject, @InjectAll, and @InjectOptional accept an optional second argument with a named qualifier. The qualifier selects a specific named slot of a contract — used when an app registers the same contract multiple times via Named() (see Named Bindings).
@Injectable()
class UploadService {
constructor(
@Inject(StorageService) private readonly storage: StorageService,
@Inject(StorageService, { named: 'staging' })
private readonly staging: StorageService,
@Inject(StorageService, { named: 'archive' })
private readonly archive: StorageService,
) {}
}@InjectAll (and @modularityjs/modularity's @InjectPool) also accept the qualifier — it returns only that slot's contributions to the pool:
@InjectPool(AuthResolversPool, { named: 'admin' })
private readonly adminResolvers: AuthResolver[];The qualifier is metadata-only — the DI driver bridges it to a synthetic slotToken(token, name) so any container (inversify, awilix, or a future driver) resolves through plain token lookup. No driver-specific qualifier APIs are required.
Tokens
A token identifies a binding in the container. Tokens can be:
| Token type | Example | Use case |
|---|---|---|
| Concrete class | UserService | Standard service injection |
| Abstract class | CacheService | Contract/driver pattern |
| String | 'app.name' | Configuration values |
| Symbol | Symbol('logger') | Private tokens |
Abstract classes are the preferred token type for contracts because they serve as both the TypeScript type and the DI token:
@Injectable()
export abstract class CacheService {
abstract get<T = unknown>(key: string): Promise<T | undefined>;
abstract set<T = unknown>(key: string, value: T): Promise<void>;
}Binding Scopes
Bindings can be scoped:
- Singleton (default) — one instance per container, shared across all injections
- Transient — a new instance for every injection
Most services should be singletons. Use transient scope only when each consumer needs its own instance.
The Container
The Container abstract class provides the runtime DI API. Application re-exports a subset:
const service = app.get(UserService); // Get a single instance
const all = app.getAll(NotificationChannel); // Get all instancesFor lower-level access (isBound, bind, rebind, onActivation, unwrap), inject the Container itself.
In most cases you interact with the container through constructor injection rather than calling it directly. Direct access is useful in lifecycle hooks and tests.
The DIProvider Interface
A DI driver implements this interface:
interface DIProvider {
createContainer(): Container;
createContainerModule(
callback: (context: BindingContext) => void,
): ContainerModule;
}The driver is a build-time dependency of your app, not of the framework packages. Two drivers ship with the framework:
| Driver | Underlying library | Paradigm |
|---|---|---|
@modularityjs/di-inversify | InversifyJS | Decorator-driven, class-as-token, native multi-binding and activation hooks |
@modularityjs/di-awilix | Awilix | Functional registration, proxy injection, no decorators |
Both pass the same contract test suite. Two paradigm-different containers slot in via the same DIProvider interface without changes to module code or the rest of the framework.
How It Works
- Import time — decorators (
@Injectable,@Inject, etc.) write metadata on classes usingReflect.defineMetadata. No driver needed. - Boot time —
createApp()receives the driver viadi: inversify. The framework collects all providers, preferences, and pools from modules, then uses the driver to create a container and bind everything. - Runtime — constructor injection resolves dependencies from the container. The framework handles scope, activation hooks, and preference chains.
This means:
- Framework packages never import
inversifyorawilixdirectly — they only use@modularityjs/di - Tests pass the driver explicitly to
createApp()orcreateTestHarness() - Swapping DI implementations requires changing one line in the app bootstrap
Swapping the DI driver
To swap from inversify to awilix (or vice versa), change one import and one argument in your app's bootstrap.ts:
// Before
import { inversify } from '@modularityjs/di-inversify';
const app = await createApp({ di: inversify, modules, profiles });// After
import { awilix } from '@modularityjs/di-awilix';
const app = await createApp({ di: awilix, modules, profiles });Then swap the workspace dep in your app's package.json (@modularityjs/di-inversify → @modularityjs/di-awilix) and run pnpm install.
Module classes, providers, pools, preferences, lifecycle hooks, and tests need no modification. The framework's metadata, decorator output, and contract surface are identical regardless of which driver is active.
apps/view-preview runs on awilix; apps/demo and apps/demo-prisma run on inversify.
When does the choice matter?
For most apps it doesn't — the abstract contract masks the underlying paradigm. Pick by ecosystem familiarity:
- InversifyJS is the older, more widely-known TypeScript DI container. Its decorator-driven model maps directly to how the framework's
@Injectable/@Injectdecorators work. - Awilix is functional and proxy-based, with smaller surface area. The driver translates decorator metadata into Awilix's functional registration model, so app code stays decorator-driven either way.
If a third-party library needs direct access to the underlying container, Container.unwrap<T>() returns the raw container instance for either driver. This is an escape hatch — every other piece of framework code uses only the abstract Container interface.