Dependency injection improves testability and flexibility.
The Problem#
1// ❌ Hard to test
2class UserService {
3 private db = new PostgresDatabase();
4
5 async createUser(data) {
6 return this.db.users.create(data);
7 }
8}Constructor Injection#
1// ✅ Dependencies injected
2interface Database {
3 users: UserRepository;
4}
5
6class UserService {
7 constructor(private database: Database) {}
8
9 async createUser(data) {
10 return this.database.users.create(data);
11 }
12}
13
14// Production
15const service = new UserService(new PostgresDatabase());
16
17// Testing
18const mockDb = { users: { create: jest.fn() } };
19const testService = new UserService(mockDb);Factory Functions#
1function createUserService(deps: { db: Database; email: EmailService }) {
2 return {
3 async createUser(data) {
4 const user = await deps.db.users.create(data);
5 await deps.email.sendWelcome(user.email);
6 return user;
7 },
8 };
9}Composition Root#
1// composition-root.ts
2export function createApp() {
3 const database = new PostgresDatabase();
4 const emailService = new SendGridService();
5
6 const userService = new UserService(database, emailService);
7 const userController = new UserController(userService);
8
9 return createRouter({ userController });
10}Testing with DI#
1describe('UserService', () => {
2 let service: UserService;
3 let mockDb: jest.Mocked<Database>;
4
5 beforeEach(() => {
6 mockDb = { users: { create: jest.fn() } } as any;
7 service = new UserService(mockDb);
8 });
9
10 it('should create user', async () => {
11 mockDb.users.create.mockResolvedValue({ id: '1' });
12 const result = await service.createUser({ email: 'test@test.com' });
13 expect(result.id).toBe('1');
14 });
15});Depend on abstractions, keep composition at top level, and inject interfaces not classes.