Container & Dependency Injection

The Container module is the heart of Orchestr's dependency injection system. It provides a powerful IoC (Inversion of Control) container inspired by Laravel's service container, enabling automatic dependency resolution and elegant service management in TypeScript applications.

Overview

What is the Container?

The Container is a sophisticated dependency injection system that manages object creation and lifecycle in your application. Instead of manually instantiating classes and passing dependencies, you register services with the Container, and it automatically resolves and injects dependencies based on TypeScript's type system.

Automatic Dependency Resolution

Leverages TypeScript's reflection system to automatically discover and inject constructor dependencies

Multiple Binding Types

Supports transient bindings, singletons, and instance bindings

Alias Support

Create alternative names for services

Factory Functions

Use factory functions with container-injected dependencies

Type Safety

Full TypeScript support with generic type parameters

Zero Configuration

Works out of the box with minimal setup required.

Why Use Dependency Injection?

Dependency injection offers several critical benefits:

Testability

Easily swap implementations with mocks or stubs in tests

Maintainability

Changes to dependencies don't cascade through your codebase

Flexibility

Switch implementations without modifying consuming code

Separation of Concerns

Classes focus on their responsibilities, not object creation

Reusability

Services can be shared across different parts of your application

Core Concepts

Service Container

The Container acts as a central registry and factory for your application's services. Think of it as a smart object that knows how to create other objects and manages their lifecycles.

import { Container } from '@orchestr-sh/orchestr';

const container = new Container();

Bindings

A binding is a registration that tells the Container how to create an instance of a service. There are three primary types:

1. Transient Bindings

Create a new instance every time the service is requested.

container.bind('Logger', () => new Logger());

2. Singleton Bindings

Create one instance and reuse it for all subsequent requests.

container.singleton('Database', () => new Database());

3. Instance Bindings

Register an already-created instance.

const config = new Config();
container.instance('Config', config);

The @Injectable() Decorator

The @Injectable() decorator is crucial for enabling automatic dependency injection. It triggers TypeScript's emitDecoratorMetadata feature, which embeds type information in the compiled JavaScript.

import { Injectable } from '@orchestr-sh/orchestr';

@Injectable()
class UserService {
  constructor(private database: Database, private logger: Logger) {}
}

Without the decorator, TypeScript won't emit parameter type metadata, and the Container cannot automatically resolve dependencies.

Getting Started

Prerequisites

Before using the Container's dependency injection features, ensure your project is properly configured:

1. Install reflect-metadata

npm install reflect-metadata

2. Import reflect-metadata First

In your application's entry point (e.g., index.ts or main.ts), import reflect-metadata as the very first line:

import 'reflect-metadata'; // MUST BE FIRST LINE

import { Application } from '@orchestr-sh/orchestr';
// ... rest of your imports

This is critical - importing it after other modules can cause reflection metadata to be missing.

3. Configure TypeScript

Update your tsconfig.json to enable decorator support:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2015",
    "lib": ["ES2015"]
  }
}

Important: The target must be ES2015 or higher for proper metadata emission.

Basic Setup

Here's a minimal example to get started:

import 'reflect-metadata';
import { Container, Injectable } from '@orchestr-sh/orchestr';

// Define a service
@Injectable()
class Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

// Define a service with dependencies
@Injectable()
class UserService {
  constructor(private logger: Logger) {}

  getUsers(): string[] {
    this.logger.log('Fetching users');
    return ['Alice', 'Bob', 'Charlie'];
  }
}

// Create container and register services
const container = new Container();
container.singleton(Logger);
container.singleton(UserService);

// Resolve and use
const userService = container.make<UserService>(UserService);
const users = userService.getUsers();
// Console output: [LOG] Fetching users

API Reference

Container Class

The main Container class provides the following methods:

bind<T>(abstract, concrete?, shared?): void

Register a binding with the Container.

Parameters:

  • abstract: Abstract - The service identifier (string, symbol, or class)
  • concrete: Concrete<T> | null - Factory function or constructor (optional, defaults to abstract)
  • shared: boolean - Whether to create singleton (optional, defaults to false)

Examples:

// Basic binding with factory
container.bind('Logger', () => new ConsoleLogger());

// Class binding (auto-resolution)
container.bind(Logger);

// Binding with dependencies
container.bind(EmailService, (c) => {
  const config = c.make<Config>('Config');
  return new SendGridEmailService(config);
});

singleton<T>(abstract, concrete?): void

Register a singleton binding. The Container creates only one instance and reuses it.

Parameters:

  • abstract: Abstract - The service identifier
  • concrete: Concrete<T> | null - Factory function or constructor (optional)

Examples:

// Singleton with factory
container.singleton('Database', () => new PostgresDatabase());

// Class singleton (auto-resolution)
container.singleton(DatabaseConnection);

// Singleton with container-injected dependencies
container.singleton(UserRepository, (c) => {
  const db = c.make<Database>('Database');
  return new UserRepository(db);
});

make<T>(abstract, parameters?): T

Resolve and return an instance of the given service.

Parameters:

  • abstract: Abstract - The service identifier
  • parameters: any[] - Optional parameters to override automatic resolution

Returns: Instance of type T

Examples:

// Basic resolution
const logger = container.make<Logger>(Logger);

// Resolution with string abstract
const db = container.make<Database>('Database');

// Override automatic resolution with explicit parameters
const service = container.make<UserService>(UserService, [customLogger]);

Usage Examples

Example 1: Basic Service Binding

import 'reflect-metadata';
import { Container, Injectable } from '@orchestr-sh/orchestr';

@Injectable()
class EmailService {
  send(to: string, subject: string): void {
    console.log(`Sending email to ${to}: ${subject}`);
  }
}

const container = new Container();
container.bind(EmailService);

const emailService = container.make<EmailService>(EmailService);
emailService.send('user@example.com', 'Welcome!');

Example 2: Automatic Dependency Resolution

The Container automatically resolves nested dependencies:

@Injectable()
class Logger {
  log(message: string): void {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

@Injectable()
class UserRepository {
  constructor(private logger: Logger) {}

  find(id: number): object {
    this.logger.log(`Finding user ${id}`);
    return { id, name: 'John Doe' };
  }
}

@Injectable()
class UserService {
  constructor(
    private repository: UserRepository,
    private logger: Logger
  ) {}

  getUser(id: number): object {
    this.logger.log(`UserService: Getting user ${id}`);
    return this.repository.find(id);
  }
}

const container = new Container();
container.singleton(Logger);
container.singleton(UserRepository);
container.singleton(UserService);

const userService = container.make<UserService>(UserService);
const user = userService.getUser(1);

// Console output:
// [2026-02-03T...] UserService: Getting user 1
// [2026-02-03T...] Finding user 1

Example 3: Using Aliases for Flexibility

Aliases provide abstraction and allow swapping implementations:

interface PaymentProcessor {
  process(amount: number): boolean;
}

@Injectable()
class StripePaymentProcessor implements PaymentProcessor {
  process(amount: number): boolean {
    console.log(`Processing $${amount} via Stripe`);
    return true;
  }
}

@Injectable()
class PayPalPaymentProcessor implements PaymentProcessor {
  process(amount: number): boolean {
    console.log(`Processing $${amount} via PayPal`);
    return true;
  }
}

const container = new Container();
container.singleton(StripePaymentProcessor);
container.singleton(PayPalPaymentProcessor);

// Create alias pointing to current implementation
container.alias(StripePaymentProcessor, 'PaymentProcessor');

const processor = container.make<PaymentProcessor>('PaymentProcessor');
processor.process(99.99);
// Console output: Processing $99.99 via Stripe

// Switch to PayPal without changing consuming code
container.alias(PayPalPaymentProcessor, 'PaymentProcessor');

const newProcessor = container.make<PaymentProcessor>('PaymentProcessor');
newProcessor.process(49.99);
// Console output: Processing $49.99 via PayPal

Best Practices

1. Choose the Right Binding Type

Use Transient Bindings (bind) When:

  • Service holds request-specific state
  • Creating multiple independent instances is necessary
  • Service is lightweight and cheap to instantiate

Use Singleton Bindings (singleton) When:

  • Service is stateless
  • Service is expensive to create
  • Service should be shared across the application
  • Service manages shared resources (database connections, caches)

2. Keep Constructors Simple

Constructors should only assign dependencies - avoid business logic:

// ✅ GOOD - Simple assignment
@Injectable()
class UserService {
  constructor(
    private repository: UserRepository,
    private logger: Logger
  ) {}

  async createUser(data: any): Promise<User> {
    this.logger.log('Creating user');
    return this.repository.create(data);
  }
}

// ❌ BAD - Business logic in constructor
@Injectable()
class BadUserService {
  constructor(
    private repository: UserRepository,
    private logger: Logger
  ) {
    this.logger.log('UserService initialized');
    this.repository.connect(); // Side effect!
  }
}

3. Prefer Constructor Injection

Constructor injection makes dependencies explicit and enforces that they're always available:

// ✅ GOOD - Constructor injection
@Injectable()
class OrderService {
  constructor(private paymentGateway: PaymentGateway) {}
}

Troubleshooting

Error: Cannot resolve dependency at position X

Cause: The Container cannot determine the type of a constructor parameter.

Common Reasons:

  1. Missing @Injectable() decorator
  2. TypeScript compiler options not configured correctly
  3. reflect-metadata not imported or imported too late
  4. Parameter is a primitive type (string, number, etc.)

Solutions:

// ✅ Add @Injectable() decorator
@Injectable()
class MyService {
  constructor(private dependency: SomeDependency) {}
}

// ✅ Check tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2015"
  }
}

// ✅ Import reflect-metadata first
import 'reflect-metadata'; // FIRST LINE
import { Container } from '@orchestr-sh/orchestr';

// ✅ For primitives, use explicit parameters
const service = container.make(MyService, ['primitive-value']);

Integration with ServiceProviders

ServiceProviders are the recommended way to organize service registration in Orchestr applications. They work hand-in-hand with the Container.

ServiceProvider Structure

import { ServiceProvider, Application } from '@orchestr-sh/orchestr';

class AppServiceProvider extends ServiceProvider {
  /**
   * Register bindings with the container
   * This runs before boot() is called on any provider
   */
  register(): void {
    // Register services here
    this.app.singleton(Logger, () => new ConsoleLogger());
    this.app.singleton(Database);
  }

  /**
   * Bootstrap services after all providers have registered
   * This is optional - only needed for initialization logic
   */
  boot(): void {
    // Perform initialization here
    const db = this.app.make<Database>(Database);
    db.connect();
  }

  /**
   * Optional: Declare which services this provider offers
   */
  provides(): string[] {
    return ['Logger', 'Database'];
  }
}

Complete Example

Here's a full example with multiple providers:

import 'reflect-metadata';
import { Application, ServiceProvider, Injectable } from '@orchestr-sh/orchestr';

// Services
@Injectable()
class Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

@Injectable()
class Database {
  connect(): void {
    console.log('Database connected');
  }

  query(sql: string): void {
    console.log(`Executing: ${sql}`);
  }
}

@Injectable()
class UserRepository {
  constructor(
    private database: Database,
    private logger: Logger
  ) {}

  findAll(): string[] {
    this.logger.log('Fetching all users');
    this.database.query('SELECT * FROM users');
    return ['Alice', 'Bob'];
  }
}

// Providers
class LoggingServiceProvider extends ServiceProvider {
  register(): void {
    this.app.singleton(Logger, () => new Logger());
  }
}

class DatabaseServiceProvider extends ServiceProvider {
  register(): void {
    this.app.singleton(Database, () => new Database());
  }

  boot(): void {
    const db = this.app.make<Database>(Database);
    db.connect();
  }
}

class RepositoryServiceProvider extends ServiceProvider {
  register(): void {
    this.app.singleton(UserRepository);
  }
}

// Bootstrap application
const app = new Application();

app.register(new LoggingServiceProvider(app));
app.register(new DatabaseServiceProvider(app));
app.register(new RepositoryServiceProvider(app));

// Use services
const userRepo = app.make<UserRepository>(UserRepository);
const users = userRepo.findAll();

console.log('Users:', users);

// Console output:
// Database connected
// [LOG] Fetching all users
// Executing: SELECT * FROM users
// Users: [ 'Alice', 'Bob' ]

Conclusion

The Container module is the backbone of Orchestr's dependency injection system. By leveraging TypeScript's type system and reflection capabilities, it provides automatic dependency resolution with minimal configuration.

Key Takeaways:

  • Use @Injectable() on all classes that need dependency injection
  • Choose the appropriate binding type for each service (transient, singleton, instance)
  • Organize service registration in ServiceProviders
  • Keep constructors simple - only assign dependencies
  • Prefer constructor injection for explicit, testable dependencies
  • Use factories for complex initialization logic
  • Test with separate container instances and mock implementations

With these patterns and best practices, you can build maintainable, testable, and flexible TypeScript applications using Orchestr's powerful Container system.