Dependency Injection (DI) is a design pattern where objects receive their dependencies from external sources rather than creating them internally. This creates loosely-coupled, testable, and maintainable code.
The Problem#
1// ❌ Tight coupling - hard to test
2class UserService {
3 private db = new DatabaseConnection();
4 private mailer = new EmailService();
5 private logger = new Logger();
6
7 async createUser(data: CreateUserInput): Promise<User> {
8 this.logger.info('Creating user');
9 const user = await this.db.users.create(data);
10 await this.mailer.sendWelcome(user.email);
11 return user;
12 }
13}
14
15// How do you test this without a real database?
16// How do you swap the mailer for a mock?Constructor Injection#
1// ✅ Dependencies injected via constructor
2class UserService {
3 constructor(
4 private db: Database,
5 private mailer: Mailer,
6 private logger: Logger,
7 ) {}
8
9 async createUser(data: CreateUserInput): Promise<User> {
10 this.logger.info('Creating user');
11 const user = await this.db.users.create(data);
12 await this.mailer.sendWelcome(user.email);
13 return user;
14 }
15}
16
17// Easy to test with mocks
18const mockDb = { users: { create: jest.fn() } };
19const mockMailer = { sendWelcome: jest.fn() };
20const mockLogger = { info: jest.fn() };
21
22const service = new UserService(mockDb, mockMailer, mockLogger);Interface-Based Injection#
1// Define interfaces for dependencies
2interface Database {
3 users: UserRepository;
4}
5
6interface Mailer {
7 sendWelcome(email: string): Promise<void>;
8 sendReset(email: string, token: string): Promise<void>;
9}
10
11interface Logger {
12 info(message: string, meta?: object): void;
13 error(message: string, error?: Error): void;
14}
15
16// Implementation depends on interfaces, not concrete classes
17class UserService {
18 constructor(
19 private db: Database,
20 private mailer: Mailer,
21 private logger: Logger,
22 ) {}
23}
24
25// Production implementation
26const productionService = new UserService(
27 new PostgresDatabase(),
28 new SendGridMailer(),
29 new WinstonLogger(),
30);
31
32// Test implementation
33const testService = new UserService(
34 new InMemoryDatabase(),
35 new MockMailer(),
36 new ConsoleLogger(),
37);Factory Pattern#
1// Factory for creating configured instances
2class ServiceFactory {
3 private config: Config;
4 private db: Database;
5 private mailer: Mailer;
6 private logger: Logger;
7
8 constructor(config: Config) {
9 this.config = config;
10 this.db = this.createDatabase();
11 this.mailer = this.createMailer();
12 this.logger = this.createLogger();
13 }
14
15 private createDatabase(): Database {
16 return new PostgresDatabase(this.config.databaseUrl);
17 }
18
19 private createMailer(): Mailer {
20 if (this.config.env === 'test') {
21 return new MockMailer();
22 }
23 return new SendGridMailer(this.config.sendgridKey);
24 }
25
26 private createLogger(): Logger {
27 return new WinstonLogger({ level: this.config.logLevel });
28 }
29
30 createUserService(): UserService {
31 return new UserService(this.db, this.mailer, this.logger);
32 }
33
34 createOrderService(): OrderService {
35 return new OrderService(this.db, this.createPaymentService(), this.logger);
36 }
37}
38
39// Usage
40const factory = new ServiceFactory(config);
41const userService = factory.createUserService();IoC Container (InversifyJS)#
1import { Container, injectable, inject } from 'inversify';
2
3// Define tokens
4const TYPES = {
5 Database: Symbol.for('Database'),
6 Mailer: Symbol.for('Mailer'),
7 Logger: Symbol.for('Logger'),
8 UserService: Symbol.for('UserService'),
9};
10
11// Mark classes as injectable
12@injectable()
13class PostgresDatabase implements Database {
14 // ...
15}
16
17@injectable()
18class SendGridMailer implements Mailer {
19 // ...
20}
21
22@injectable()
23class UserService {
24 constructor(
25 @inject(TYPES.Database) private db: Database,
26 @inject(TYPES.Mailer) private mailer: Mailer,
27 @inject(TYPES.Logger) private logger: Logger,
28 ) {}
29}
30
31// Configure container
32const container = new Container();
33container.bind<Database>(TYPES.Database).to(PostgresDatabase).inSingletonScope();
34container.bind<Mailer>(TYPES.Mailer).to(SendGridMailer).inSingletonScope();
35container.bind<Logger>(TYPES.Logger).to(WinstonLogger).inSingletonScope();
36container.bind<UserService>(TYPES.UserService).to(UserService);
37
38// Resolve dependencies
39const userService = container.get<UserService>(TYPES.UserService);TSyringe (Simpler Alternative)#
1import { container, injectable, inject } from 'tsyringe';
2
3@injectable()
4class UserService {
5 constructor(
6 private db: Database,
7 private mailer: Mailer,
8 private logger: Logger,
9 ) {}
10}
11
12// Register implementations
13container.register<Database>('Database', { useClass: PostgresDatabase });
14container.register<Mailer>('Mailer', { useClass: SendGridMailer });
15container.register<Logger>('Logger', { useClass: WinstonLogger });
16
17// Or register instances
18container.registerInstance('Config', config);
19
20// Resolve
21const userService = container.resolve(UserService);Functional Approach#
1// Dependencies as a context object
2interface ServiceContext {
3 db: Database;
4 mailer: Mailer;
5 logger: Logger;
6}
7
8// Functions receive context
9async function createUser(
10 ctx: ServiceContext,
11 data: CreateUserInput,
12): Promise<User> {
13 ctx.logger.info('Creating user');
14 const user = await ctx.db.users.create(data);
15 await ctx.mailer.sendWelcome(user.email);
16 return user;
17}
18
19// Create context once
20const ctx: ServiceContext = {
21 db: new PostgresDatabase(),
22 mailer: new SendGridMailer(),
23 logger: new WinstonLogger(),
24};
25
26// Use with context
27await createUser(ctx, userData);
28
29// Curried version
30const createUserWithCtx = (ctx: ServiceContext) =>
31 (data: CreateUserInput) => createUser(ctx, data);
32
33const boundCreateUser = createUserWithCtx(ctx);
34await boundCreateUser(userData);React Context for DI#
1// Create context
2const ServiceContext = createContext<Services | null>(null);
3
4// Provider
5function ServiceProvider({ children }: { children: React.ReactNode }) {
6 const services = useMemo(() => ({
7 api: new ApiClient(),
8 analytics: new AnalyticsService(),
9 auth: new AuthService(),
10 }), []);
11
12 return (
13 <ServiceContext.Provider value={services}>
14 {children}
15 </ServiceContext.Provider>
16 );
17}
18
19// Hook
20function useServices() {
21 const services = useContext(ServiceContext);
22 if (!services) {
23 throw new Error('useServices must be used within ServiceProvider');
24 }
25 return services;
26}
27
28// Usage
29function UserProfile() {
30 const { api, analytics } = useServices();
31
32 useEffect(() => {
33 analytics.trackPageView('profile');
34 }, []);
35
36 // ...
37}Testing with DI#
1describe('UserService', () => {
2 let service: UserService;
3 let mockDb: jest.Mocked<Database>;
4 let mockMailer: jest.Mocked<Mailer>;
5 let mockLogger: jest.Mocked<Logger>;
6
7 beforeEach(() => {
8 mockDb = {
9 users: {
10 create: jest.fn(),
11 findById: jest.fn(),
12 },
13 };
14 mockMailer = {
15 sendWelcome: jest.fn(),
16 };
17 mockLogger = {
18 info: jest.fn(),
19 error: jest.fn(),
20 };
21
22 service = new UserService(mockDb, mockMailer, mockLogger);
23 });
24
25 describe('createUser', () => {
26 it('creates user and sends welcome email', async () => {
27 const userData = { email: 'test@example.com', name: 'Test' };
28 const createdUser = { id: '1', ...userData };
29
30 mockDb.users.create.mockResolvedValue(createdUser);
31 mockMailer.sendWelcome.mockResolvedValue(undefined);
32
33 const result = await service.createUser(userData);
34
35 expect(result).toEqual(createdUser);
36 expect(mockDb.users.create).toHaveBeenCalledWith(userData);
37 expect(mockMailer.sendWelcome).toHaveBeenCalledWith('test@example.com');
38 expect(mockLogger.info).toHaveBeenCalled();
39 });
40
41 it('logs error when email fails', async () => {
42 const userData = { email: 'test@example.com', name: 'Test' };
43 mockDb.users.create.mockResolvedValue({ id: '1', ...userData });
44 mockMailer.sendWelcome.mockRejectedValue(new Error('SMTP error'));
45
46 await expect(service.createUser(userData)).rejects.toThrow();
47 expect(mockLogger.error).toHaveBeenCalled();
48 });
49 });
50});Best Practices#
1. Inject Interfaces, Not Implementations#
// ✅ Good
constructor(private logger: Logger) {}
// ❌ Bad
constructor(private logger: WinstonLogger) {}2. Prefer Constructor Injection#
1// ✅ Good - explicit dependencies
2class Service {
3 constructor(private dep: Dependency) {}
4}
5
6// ❌ Bad - hidden dependencies
7class Service {
8 private dep = new Dependency();
9}3. Keep Dependencies Minimal#
1// ❌ Too many dependencies - split the class
2class GodService {
3 constructor(
4 private db: Database,
5 private mailer: Mailer,
6 private sms: SmsService,
7 private push: PushService,
8 private logger: Logger,
9 private cache: Cache,
10 private queue: Queue,
11 ) {}
12}
13
14// ✅ Split into focused services
15class NotificationService {
16 constructor(
17 private mailer: Mailer,
18 private sms: SmsService,
19 private push: PushService,
20 ) {}
21}Conclusion#
Dependency injection creates flexible, testable code. Start with simple constructor injection—you don't always need a full IoC container. The key is depending on abstractions and receiving dependencies from outside.
Choose the approach that fits your project: manual injection for simple apps, factories for medium complexity, and IoC containers for large applications with many dependencies.