Dependency Injection
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.
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.
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.
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();
}
}
! 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
import { Identity } from '@vercube/di';
export const $Database = Identity('Database');
import { $Database } from './symbols';
import { PostgresDatabase } from './services/PostgresDatabase';
app.container.bind($Database, PostgresDatabase);
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
export abstract class Database {
abstract query(sql: string): Promise<any>;
}
export class PostgresDatabase extends Database {
async query(sql: string): Promise<any> {
// Implementation
}
}
app.container.bind(Database, PostgresDatabase);
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.
@Controller('/users')
export class UserController {
@Inject(UserService)
private userService!: UserService;
}
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.
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.
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);
}
}
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!
}