Dependency Injection
Dependency Injection (DI) is a design pattern where an object receives the instances it depends on rather than constructing them itself. ExpressoTS uses InversifyJS under the hood and layers its own decorator and scope system on top, giving you fine-grained control over how services are created, shared, and disposed.
Benefits
- Decoupling — components depend on abstractions, not on concrete implementations. Dependencies can be swapped without touching the consumer.
- Testability — injecting mock or stub implementations makes unit testing straightforward.
- Reusable code — the same class can be used in different contexts with different injected dependencies.
- Easier maintenance — object creation is centralized; when a class changes you typically update one place.
- Lifecycle management — the container manages instance lifetimes through scopes (Request, Singleton, Transient, and custom scopes). See Lifecycle Hooks for advanced lifecycle management.
- Concurrency safety — the container handles service lifetimes in a concurrent environment automatically.
Architecture
The diagram below shows how the DI container, modules, controllers, use cases, and providers relate to each other, each with its own scope.

- The container has a default scope (
Request) that can be overridden per module. - Defining a scope for a module forces all controllers under that module to share the same scope.
- Not defining a scope for a module allows each controller to declare its own scope with
@scope(). - Providers, use cases, entities, and other classes can have independent scopes via their decorator.
Components
| Component | Description |
|---|---|
| Container | The root DI container (AppContainer) that holds all bindings. |
| Module | Groups related controllers and their dependencies together with CreateModule(). |
| Controller | Primary interface between client and server; handles incoming HTTP requests. |
| Use Case | Business logic class injected into controllers. |
| Provider | Reusable service (email, database, cache) injected into use cases or controllers. |
Scope decorators
Scopes control how many instances of a class the container creates:
| Decorator | Scope | Behavior |
|---|---|---|
@provide(Class) | Request | One instance per HTTP request (default) |
@provideSingleton(Class) | Singleton | One instance for the entire application lifetime |
@provideTransient(Class) | Transient | New instance on every injection |
@provideInScope(Class, "name") | Custom | Shared within the same named scope context |
Request scope (default)
import { provide } from "@expressots/core";
@provide(UserService)
export class UserService {
// New instance created for each HTTP request
// Disposed when the request completes
}
Singleton scope
import { provideSingleton } from "@expressots/core";
@provideSingleton(DatabasePool)
export class DatabasePool {
// One instance shared across the entire application
// Created on first resolution, cached forever
}
Transient scope
import { provideTransient } from "@expressots/core";
@provideTransient(CorrelationId)
export class CorrelationId {
readonly id = crypto.randomUUID();
// Brand new instance every time it is injected
}
Custom scope
import { provideInScope } from "@expressots/core";
@provideInScope(TenantConfig, "tenant")
export class TenantConfig {
// Shared within the same tenant context
// Different instances for different tenants
}
Custom scope names must not conflict with built-in names ("Singleton", "Request", "Transient"). Using a built-in name throws an error at startup.
The @Provider decorator
For providers that need rich metadata, use the @Provider decorator which combines scope, source tracking, and metadata in one:
import { Provider, IProvider } from "@expressots/core";
@Provider({
scope: "Singleton",
name: "Redis Cache",
version: "2.0.0",
description: "Redis caching provider",
author: "My Team",
source: "external",
dependencies: ["DatabaseProvider"],
priority: 10,
})
export class RedisCacheProvider implements IProvider {
readonly name = "Redis Cache";
readonly version = "2.0.0";
}
See Providers for the full provider API.
Injecting dependencies
Constructor injection
The most common pattern — dependencies are declared as constructor parameters with @inject():
import { provide, inject } from "@expressots/core";
@provide(CreateUserUseCase)
export class CreateUserUseCase {
constructor(
@inject(EmailProvider) private email: EmailProvider,
@inject(DatabaseProvider) private db: DatabaseProvider,
) {}
async execute(dto: CreateUserDTO): Promise<UserResponseDTO> {
const user = await this.db.save(dto);
await this.email.sendWelcome(user.email);
return { id: user.id, status: "created" };
}
}
Multi-injection
Inject all bindings for a given identifier with @multiInject():
import { provide, multiInject } from "@expressots/core";
@provide(NotificationService)
export class NotificationService {
constructor(
@multiInject("INotificationChannel")
private channels: INotificationChannel[],
) {}
async notifyAll(message: string): Promise<void> {
await Promise.all(this.channels.map((ch) => ch.send(message)));
}
}
Controller scope
Controllers can declare their own scope using the @scope() decorator, provided the parent module does not already enforce a scope:
import { scope, controller } from "@expressots/core";
@scope("Singleton")
@controller("/metrics")
export class MetricsController {
// Singleton-scoped controller
}
You cannot define a scope for a controller if the parent module already has a scope defined. The module scope takes precedence.
Container configuration
AppContainer wraps the InversifyJS container with ExpressoTS defaults:
import { AppContainer, Scope } from "@expressots/core";
const container = new AppContainer({
defaultScope: Scope.Request, // default
autoBindInjectable: true, // auto-bind @injectable classes
skipBaseClassChecks: false,
});
container.create([
new UserModule(),
new ProductModule(),
]);
Container introspection
The container exposes a v4 introspection API for debugging and tooling:
// Get all bindings
const bindings = container.getBindingsInfo();
// Summary statistics
const summary = container.getBindingsSummary();
console.log(`Total: ${summary.total}`);
console.log(`Singletons: ${summary.byScope["Singleton"] || 0}`);
// Filter bindings
const singletons = container.filterBindings({ scope: "Singleton" });
const controllers = container.filterBindings({ identifier: "Controller" });
// Formatted view for debugging
console.log(container.getFormattedBindingsView());
// Full introspection (for Studio)
const data = container.introspect();
Circular dependencies
Circular dependencies occur when two or more classes depend on each other, creating a resolution loop.
Lazy injection
Use LazyServiceIdentifier to defer resolution until the dependency is actually used:
import { provide, inject, LazyServiceIdentifier } from "@expressots/core";
@provide(ServiceA)
class ServiceA {
constructor(
@inject(new LazyServiceIdentifier(() => ServiceB))
private serviceB: ServiceB,
) {}
}
@provide(ServiceB)
class ServiceB {
constructor(@inject(ServiceA) private serviceA: ServiceA) {}
}
ServiceB is injected into ServiceA lazily, breaking the circular dependency at construction time.
Other strategies
- Factory injection — use factory functions to create instances on demand, avoiding circular references at construction time.
- Proxies — employ proxies to delay resolution, giving more control over when and how the dependency is resolved.
Scope registry
For advanced custom scope management, ExpressoTS provides a ScopeRegistry that maintains separate instance stores per scope:
import { globalScopeRegistry } from "@expressots/core";
// Get the store for a named scope
const tenantScope = globalScopeRegistry.getScopeStore("tenant");
// Check if a scope exists
globalScopeRegistry.hasScope("tenant");
// Clear all instances in a scope (e.g., on tenant switch)
globalScopeRegistry.clearScope("tenant");
// List all registered scope names
const scopes = globalScopeRegistry.getScopeNames();
Best practices
- Prefer Request scope for most services — it is stateless and scales naturally with concurrent requests.
- Use Singleton for shared infrastructure — database pools, caches, configuration, and providers with
IBootstrap/IShutdown. - Use Transient sparingly — every injection creates a new instance, which has a performance cost.
- Use
@provideInScope()for multi-tenant or transaction-scoped services that need isolation. - Inject by class rather than string tokens when possible for type safety.
- Use
LazyServiceIdentifierto break circular dependencies rather than restructuring your entire architecture. - Inspect bindings during development with
container.getFormattedBindingsView()orcontainer.introspect()to verify scopes are correct.
Support the Project
ExpressoTS is MIT-licensed open source. See the support guide to contribute.