Back to Blog
Dependency InjectionTypeScriptTestingArchitecture

Dependency Injection Patterns in TypeScript

Write testable, maintainable code with dependency injection. From constructor injection to IoC containers to practical patterns.

B
Bootspring Team
Engineering
March 12, 2025
6 min read

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.

Share this article

Help spread the word about Bootspring