Skip to main content

Dependency Injection

What is Dependency Injection?

Dependency Injection (DI) is a design pattern where dependencies are provided to a class from the outside, rather than the class creating them internally. This promotes loose coupling, testability, and maintainability. Think of it as having a Stargate that connects you to exactly what you need, when you need it.

Core Principles

1. Inversion of Control (IoC)

Traditional approach (Control inside the class):

class UserService {
private database = new PostgresDatabase(); // Class controls its dependencies
private logger = new ConsoleLogger();
}

DI approach with tokens (Control outside the class):

export const DATABASE = new Token<DataSource>('DATABASE');
export const LOGGER = new Token<ILogger>('LOGGER');

@Service(USER_SERVICE)
class UserService implements IUserService {
constructor(
@Inject(DATABASE) private database: DataSource, // Dependencies injected from outside
@Inject(LOGGER) private logger: ILogger
) {}
}

2. Dependency on Abstractions

Always depend on interfaces, not concrete implementations. This principle is fundamental to creating flexible, testable, and maintainable code.

Why Interfaces and Tokens Are Better

1. Loose Coupling

// ❌ Bad - tight coupling to concrete implementation
class UserService {
constructor(private database: PostgresDatabase) {} // Bound to PostgreSQL
}

// If you want to switch to MongoDB, you must change this class
class UserService {
constructor(private database: MongoDBDatabase) {} // Now bound to MongoDB
}

With interfaces and tokens - loose coupling:

// ✅ Good - depends on interface with token
interface IUserService {
getUser(id: string): Promise<User>;
}

interface IDatabase {
query(sql: string, params: any[]): Promise<any>;
}

export const USER_SERVICE = new Token<IUserService>('USER_SERVICE');
export const DATABASE = new Token<IDatabase>('DATABASE');

@Service(USER_SERVICE)
class UserService implements IUserService {
constructor(@Inject(DATABASE) private database: IDatabase) {} // Depends on interface

getUser(id: string): Promise<User> {
return this.database.query(`SELECT * FROM users WHERE id = ?`, [id]);
}
}

// Switch implementations without changing UserService
nexus.set(DATABASE, { useClass: PostgresDatabase }); // PostgreSQL
nexus.set(DATABASE, { useClass: MongoDBDatabase }); // MongoDB
nexus.set(DATABASE, { useClass: InMemoryDatabase }); // In-memory for tests

2. Easier Testing

// ❌ Bad - hard to test with concrete dependencies
class UserService {
constructor(private database: PostgresDatabase) {}

async getUser(id: string) {
return this.database.query(`SELECT * FROM users WHERE id = '${id}'`);
}
}

// Testing requires real PostgreSQL connection
const userService = new UserService(new PostgresDatabase()); // Expensive!

With interfaces - easy testing:

// ✅ Good - easy to test with mocks
@Service(USER_SERVICE)
class UserService implements IUserService {
constructor(@Inject(DATABASE) private database: IDatabase) {}

async getUser(id: string) {
return this.database.query(`SELECT * FROM users WHERE id = ?`, [id]);
}
}

// Testing with simple mocks
const mockDatabase = {
query: vi.fn().mockResolvedValue({ id: '123', name: 'John' }),
};

nexus.set(DATABASE, { useValue: mockDatabase });
const userService = nexus.get(USER_SERVICE);

// Test without any real database
const user = await userService.getUser('123');
expect(mockDatabase.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = ?',
['123']
);

3. Runtime Flexibility

// ❌ Bad - compile-time decisions
class EmailService {
constructor(private provider: SendGridProvider) {} // Always SendGrid
}

// Can't change email provider without code changes

With interfaces - runtime decisions:

// ✅ Good - runtime configuration
interface IEmailProvider {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}

@Service(EMAIL_SERVICE)
class EmailService implements IEmailService {
constructor(@Inject(EMAIL_PROVIDER) private provider: IEmailProvider) {}

async sendWelcomeEmail(user: User) {
await this.provider.sendEmail(
user.email,
'Welcome!',
'Welcome to our platform!'
);
}
}

// Choose provider at runtime based on configuration
if (process.env.EMAIL_PROVIDER === 'sendgrid') {
nexus.set(EMAIL_PROVIDER, { useClass: SendGridProvider });
} else if (process.env.EMAIL_PROVIDER === 'mailgun') {
nexus.set(EMAIL_PROVIDER, { useClass: MailgunProvider });
} else {
nexus.set(EMAIL_PROVIDER, { useClass: ConsoleEmailProvider }); // For development
}

NexusDI promotes using tokens with interfaces rather than direct class references. This approach provides better flexibility and maintainability:

// ✅ Recommended: Token + Interface pattern
export const USER_SERVICE = new Token<IUserService>('USER_SERVICE');
export const DATABASE = new Token<DataSource>('DATABASE');

@Service(USER_SERVICE)
class UserService implements IUserService {
constructor(@Inject(DATABASE) private database: DataSource) {}

getUser(id: string): Promise<User> {
return this.database.query(`SELECT * FROM users WHERE id = '${id}'`);
}
}

// Usage
const userService = nexus.get(USER_SERVICE); // Type-safe, interface-based

Benefits of this approach:

  • Interface-driven: Dependencies are defined by contracts, not implementations
  • Type safety: Full TypeScript support with generics
  • Flexibility: Easy to swap implementations without changing consumers
  • Explicit: Clear what dependencies are available in your system
  • Testable: Easy to mock with interface-based tokens

Summary

Dependency Injection with interfaces and tokens provides:

  • Loose coupling between components
  • Easy testing with mocks and stubs
  • Runtime flexibility for configuration
  • Better error handling and edge case testing
  • Environment-specific behavior
  • Future-proofing for easy extension

The best systems are the ones that can adapt to whatever the universe throws at them. Your code should be no different! 🚀