DEV Community

Young Gao
Young Gao

Posted on

Dependency Injection in TypeScript: Stop Hardcoding Your Dependencies

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)