Skip to main content
Version: 4.0.0-preview

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.

ExpressoTS v4 DI architecture: AppContainer with modules, controllers, use cases, and providers with scope annotations

  • 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

ComponentDescription
ContainerThe root DI container (AppContainer) that holds all bindings.
ModuleGroups related controllers and their dependencies together with CreateModule().
ControllerPrimary interface between client and server; handles incoming HTTP requests.
Use CaseBusiness logic class injected into controllers.
ProviderReusable service (email, database, cache) injected into use cases or controllers.

Scope decorators

Scopes control how many instances of a class the container creates:

DecoratorScopeBehavior
@provide(Class)RequestOne instance per HTTP request (default)
@provideSingleton(Class)SingletonOne instance for the entire application lifetime
@provideTransient(Class)TransientNew instance on every injection
@provideInScope(Class, "name")CustomShared 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
}
info

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

  1. Prefer Request scope for most services — it is stateless and scales naturally with concurrent requests.
  2. Use Singleton for shared infrastructure — database pools, caches, configuration, and providers with IBootstrap / IShutdown.
  3. Use Transient sparingly — every injection creates a new instance, which has a performance cost.
  4. Use @provideInScope() for multi-tenant or transaction-scoped services that need isolation.
  5. Inject by class rather than string tokens when possible for type safety.
  6. Use LazyServiceIdentifier to break circular dependencies rather than restructuring your entire architecture.
  7. Inspect bindings during development with container.getFormattedBindingsView() or container.introspect() to verify scopes are correct.

Support the Project

ExpressoTS is MIT-licensed open source. See the support guide to contribute.