Dependency Injection

Understanding and using the Dependency Injection container in Vercube

Dependency Injection (DI) is a design pattern that helps you build maintainable, testable, and flexible applications. The DI Container in Vercube manages your application's dependencies automatically, making your code cleaner and easier to work with.

What is Dependency Injection?

Imagine you're building a house. Instead of each room creating its own electricity generator, water pump, and heating system, you connect them to centralized utilities. Dependency Injection works the same way - instead of each class creating its own dependencies, the container provides them.

Without Dependency Injection

class UserService {
  private database: Database;
  private logger: Logger;
  
  constructor() {
    // Hard to test - creates concrete instances
    this.database = new PostgresDatabase();
    this.logger = new FileLogger();
  }
}

With Dependency Injection

class UserService {

  @Inject(Database)
  private database: Database;

  @Inject(Logger)
  private logger: Logger;

  // Easy to test - dependencies injected from outside
  
}

How the Container Works

The Container acts as a centralized registry that knows how to create and manage all your application's dependencies. Here's a visual representation:

Key Benefits

Testability - Easily replace real services with mocks in tests

// In tests, swap real database with a mock
container.bindMock(Database, { query: jest.fn() });

Flexibility - Change implementations without modifying code

// Switch from PostgreSQL to MongoDB without touching UserService
container.bind(Database, MongoDatabase);

Maintainability - Clear dependency relationships

// Just look at the constructor to see what a class needs
constructor(private db: Database, private cache: Cache) {}

Single Responsibility - Classes focus on their job, not creating dependencies

// UserService focuses on users, not database connection logic

Basic Usage

Step 1: Create Your Service

Create a regular TypeScript class. The container will handle creating instances.

UserService.ts
export class UserService {
  public getUsers(): User[] {
    return [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];
  }
}

Step 2: Register in Container

Tell the container about your service during application setup.

setup.ts
import { type App } from '@vercube/core';
import { UserService } from './services/UserService';

export function setup(app: App): void {
  app.container.bind(UserService);
}

Step 3: Inject Dependencies

Use the @Inject decorator to request dependencies in your controllers.

UserController.ts
import { Controller, Get, Inject } from '@vercube/core';
import { UserService } from './services/UserService';

@Controller('/users')
export class UserController {
  
  @Inject(UserService)
  private userService!: UserService;
  
  @Get('/')
  public getUsers() {
    return this.userService.getUsers();
  }
}
The ! after the property name is TypeScript's "definite assignment assertion". It tells TypeScript "trust me, this will be assigned before I use it" - which is true because the container injects it.

Service Lifetimes

The container supports different lifetimes for your services, controlling when instances are created and how long they live.

Singleton (Default)

One instance is created and shared across your entire application.

app.container.bind(Database);
// or explicitly
app.container.bind(Database, PostgresDatabase);

Use for: Services that maintain state or are expensive to create (database connections, caches, configuration)

Transient

A new instance is created every time it's requested.

app.container.bindTransient(RequestLogger);

Use for: Stateless services or when you need isolation between uses

Instance

Bind an already-created instance to the container.

const config = new AppConfig();
app.container.bindInstance(AppConfig, config);

Use for: Pre-configured objects, global singletons, or sharing instances between containers

Working with Interfaces and Abstract Classes

TypeScript interfaces don't exist at runtime, so the container can't use them directly. Instead, use symbols or abstract classes as service identifiers.

Using Symbols

symbols.ts
import { Identity } from '@vercube/di';

export const $Database = Identity('Database');
setup.ts
import { $Database } from './symbols';
import { PostgresDatabase } from './services/PostgresDatabase';

app.container.bind($Database, PostgresDatabase);
UserService.ts
import { Inject } from '@vercube/core';
import { $Database } from './symbols';
import type { Database } from './interfaces/Database';

export class UserService {
  @Inject($Database)
  private database!: Database;
}

Using Abstract Classes

Database.ts
export abstract class Database {
  abstract query(sql: string): Promise<any>;
}
PostgresDatabase.ts
export class PostgresDatabase extends Database {
  async query(sql: string): Promise<any> {
    // Implementation
  }
}
setup.ts
app.container.bind(Database, PostgresDatabase);
UserService.ts
export class UserService {
  @Inject(Database)
  private database!: Database;
}

Dependency Chains

The container automatically resolves nested dependencies - when Service A needs Service B, and Service B needs Service C, everything just works.

UserController.ts
@Controller('/users')
export class UserController {
  @Inject(UserService)
  private userService!: UserService;
}
UserService.ts
export class UserService {
  @Inject(Database)
  private database!: Database;
  
  @Inject(Logger)
  private logger!: Logger;
}

The container handles the entire chain automatically!

Optional Dependencies

Sometimes a dependency might not be available, and that's okay. Use @InjectOptional for dependencies that may or may not exist.

EmailService.ts
import { InjectOptional } from '@vercube/core';

export class EmailService {
  @InjectOptional(TemplateEngine)
  private templateEngine?: TemplateEngine | null;
  
  public sendEmail(to: string, message: string) {
    if (this.templateEngine) {
      // Use fancy templates
      message = this.templateEngine.render(message);
    }
    // Send email
  }
}

Manual Resolution

Sometimes you need to create instances manually - use container.resolve() for this.

By using container.resolve(), you create a new instance of the desired service. The key difference compared to using the new operator directly is that container.resolve() will automatically resolve and inject all dependencies required by the class, following the IoC pattern. This ensures your new instance is fully constructed with everything it needs, without you having to manually create or pass any dependencies.

export class ReportGenerator {
  @Inject(Container)
  private container!: Container;
  
  public generateReport(type: string) {
    // Dynamically create service based on report type
    const service = this.container.resolve(ReportService);
    return service.generate();
  }
}

Testing with Mocks

The container makes testing incredibly easy. Replace real services with mocks for isolated testing.

UserService.test.ts
import { Container } from '@vercube/di';
import { UserService } from './UserService';
import type { Database } from './Database';

describe('UserService', () => {
  it('should get users from database', async () => {
    // Create test container
    const container = new Container();
    
    // Mock the database
    container.bindMock(Database, {
      query: jest.fn().mockResolvedValue([
        { id: 1, name: 'Test User' }
      ])
    });
    
    // Register service
    container.bind(UserService);
    
    // Get service and test
    const service = container.get(UserService);
    const users = await service.getUsers();
    
    expect(users).toHaveLength(1);
    expect(users[0].name).toBe('Test User');
  });
});
bindMock doesn't require a complete implementation - just provide the methods you need for your test.

Container Reference

Access the container itself by injecting it:

import { Container } from '@vercube/di';

export class MyService {
  @Inject(Container)
  private container!: Container;
  
  public doSomething() {
    const otherService = this.container.get(OtherService);
  }
}
Injecting the container directly is an advanced pattern. In most cases, you should inject specific services instead. This keeps your dependencies explicit and your code easier to test.

Common Patterns

Factory Pattern

Create instances dynamically based on runtime conditions:

export class NotificationFactory {
  @Inject(Container)
  private container!: Container;
  
  public create(type: 'email' | 'sms'): NotificationService {
    if (type === 'email') {
      return this.container.resolve(EmailNotificationService);
    }
    return this.container.resolve(SmsNotificationService);
  }
}

Best Practices

Keep constructors clean - Let the container inject dependencies, don't do heavy work in constructors

// ✅ Good
export class UserService {
  @Inject(Database)
  private db!: Database;
}

// ❌ Bad
export class UserService {
  private db: Database;
  
  constructor() {
    this.db = new Database();
    this.db.connect(); // Heavy work in constructor
  }
}

Use interfaces for flexibility - Program to interfaces, not implementations

// ✅ Good
@Inject(ILogger)
private logger!: Logger;

// ❌ Less flexible
@Inject(FileLogger)
private logger!: FileLogger;

One responsibility per service - Keep services focused

// ✅ Good
export class UserService { /* user operations */ }
export class AuthService { /* auth operations */ }

// ❌ Bad
export class UserAuthService { /* users AND auth */ }

Avoid circular dependencies - If Service A needs Service B and Service B needs Service A, rethink your design

// ❌ Bad - circular dependency
class A {
  @Inject(B) b!: B;
}

class B {
  @Inject(A) a!: A;
}

// ✅ Good - extract shared logic
class SharedLogic { }
class A {
  @Inject(SharedLogic) logic!: SharedLogic;
}
class B {
  @Inject(SharedLogic) logic!: SharedLogic;
}

Troubleshooting

"Unresolved dependency" Error

This means you tried to inject a service that wasn't registered in the container.

// Error: Unresolved dependency for [UserService]
@Inject(UserService)
private userService!: UserService;

Solution: Register the service in your setup:

app.container.bind(UserService);

TypeScript Error: Property has no initializer

// Error: Property 'userService' has no initializer
@Inject(UserService)
private userService: UserService;

Solution: Add the ! definite assignment assertion:

@Inject(UserService)
private userService!: UserService;

Service is undefined when accessed

Make sure you're accessing the service after the container has initialized:

// ❌ Bad - accessing in constructor
constructor() {
  console.log(this.userService); // undefined!
}

// ✅ Good - accessing in methods
public getUsers() {
  console.log(this.userService); // works!
}
Previous

Controllers

Building APIs with Controllers in Vercube

Next