Dependency Injection in TypeScript: Stop Hardcoding Your Dependencies
Every time you write new DatabaseClient() inside a service, you are welding that service to a specific implementation. Testing requires a real database. Swapping providers means rewriting code. Dependency injection fixes this.
Constructor Injection
interface Logger {
info(msg: string): void;
error(msg: string, err?: Error): void;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
class UserService {
constructor(
private readonly users: UserRepository,
private readonly logger: Logger
) {}
async getUser(id: string): Promise<User> {
this.logger.info(`Fetching user ${id}`);
const user = await this.users.findById(id);
if (!user) throw new Error("User not found");
return user;
}
}
UserService does not know if it is talking to Postgres, MongoDB, or an in-memory store. It does not care.
Wiring It Together
// Production
const logger = new PinoLogger();
const userRepo = new PostgresUserRepository(pool);
const userService = new UserService(userRepo, logger);
// Test
const fakeLogger = { info: jest.fn(), error: jest.fn() };
const fakeRepo = { findById: jest.fn(), save: jest.fn() };
const testService = new UserService(fakeRepo, fakeLogger);
No DI framework needed. Just pass dependencies through the constructor.
When to Use a DI Container
Manual wiring works for small apps. When you have 50+ services with deep dependency trees, a container like tsyringe or inversify helps:
import { container, injectable, inject } from "tsyringe";
@injectable()
class UserService {
constructor(
@inject("UserRepository") private users: UserRepository,
@inject("Logger") private logger: Logger
) {}
}
container.register("UserRepository", { useClass: PostgresUserRepository });
container.register("Logger", { useClass: PinoLogger });
const service = container.resolve(UserService);
Rules of Thumb
- Inject interfaces, not implementations
- Keep the composition root (where you wire things) in one place
- If a dependency is used everywhere (like a logger), a container is worth it
- If a dependency is used in 2-3 places, manual injection is fine
- Never inject dependencies into utility functions. Only services and handlers
Part of my Production Backend Patterns series. Follow for more practical backend engineering.
Top comments (0)