TechLead
Lesson 16 of 27
5 min read
Software Architecture

Dependency Injection

Master IoC containers and constructor injection in TypeScript for building loosely coupled, testable applications

What is Dependency Injection?

Dependency Injection (DI) is a design technique where an object receives its dependencies from the outside rather than creating them internally. This is a specific form of Inversion of Control (IoC) — instead of the class controlling which implementations it uses, that control is inverted to the caller or a container.

DI Benefits

  • Testability: Easily swap real dependencies with mocks and stubs in tests
  • Loose coupling: Classes depend on abstractions (interfaces), not concrete implementations
  • Single responsibility: Classes do not need to know how to create their dependencies
  • Flexibility: Change implementations without modifying the dependent class

Types of Dependency Injection

// 1. Constructor Injection (Preferred)
class OrderService {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly paymentGateway: PaymentGateway,
    private readonly emailService: EmailService
  ) {}

  async placeOrder(dto: PlaceOrderDTO): Promise {
    // Uses injected dependencies
    const order = Order.create(dto);
    await this.orderRepo.save(order);
    await this.paymentGateway.charge(order.total, dto.customerId);
    await this.emailService.sendConfirmation(dto.email, order);
    return order.id;
  }
}

// 2. Property Injection (Less common in TypeScript)
class NotificationService {
  logger!: Logger; // Set after construction

  async notify(userId: string, message: string): Promise {
    this.logger.info(`Notifying user ${userId}`);
    // ...
  }
}

// 3. Method Injection (For per-call dependencies)
class ReportGenerator {
  generateReport(
    data: ReportData,
    formatter: ReportFormatter // Injected per call
  ): string {
    return formatter.format(data);
  }
}

Manual DI (Composition Root)

// Composition root: Wire all dependencies manually
function createApp(): Application {
  // Infrastructure
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const redisClient = new RedisClient(process.env.REDIS_URL);
  const smtpClient = new SmtpClient(process.env.SMTP_URL);

  // Repositories
  const userRepo = new PostgresUserRepository(pool);
  const orderRepo = new PostgresOrderRepository(pool);

  // External services
  const paymentGateway = new StripePaymentGateway(process.env.STRIPE_KEY!);
  const emailService = new SmtpEmailService(smtpClient);
  const cacheService = new RedisCacheService(redisClient);

  // Domain services
  const pricingService = new PricingService(new StandardDiscountPolicy());

  // Use cases
  const createUser = new CreateUserUseCase(userRepo, emailService);
  const placeOrder = new PlaceOrderUseCase(orderRepo, paymentGateway, pricingService);
  const getUser = new GetUserUseCase(new CachedUserRepository(userRepo, cacheService));

  // Controllers
  const userController = new UserController(createUser, getUser);
  const orderController = new OrderController(placeOrder);

  // Express app
  const app = express();
  app.use("/api/users", userController.router);
  app.use("/api/orders", orderController.router);

  return app;
}

IoC Container with tsyringe

// Using tsyringe for automatic DI
import { container, injectable, inject } from "tsyringe";

// Define tokens for interfaces
const TOKENS = {
  UserRepository: Symbol("UserRepository"),
  PaymentGateway: Symbol("PaymentGateway"),
  EmailService: Symbol("EmailService"),
  Logger: Symbol("Logger"),
};

// Register implementations
container.register(TOKENS.UserRepository, { useClass: PostgresUserRepository });
container.register(TOKENS.PaymentGateway, { useClass: StripePaymentGateway });
container.register(TOKENS.EmailService, { useClass: SmtpEmailService });
container.register(TOKENS.Logger, { useClass: WinstonLogger });

// Injectable class with constructor injection
@injectable()
class CreateUserUseCase {
  constructor(
    @inject(TOKENS.UserRepository) private readonly userRepo: UserRepository,
    @inject(TOKENS.EmailService) private readonly emailService: EmailService,
    @inject(TOKENS.Logger) private readonly logger: Logger
  ) {}

  async execute(input: CreateUserInput): Promise {
    this.logger.info("Creating user", { email: input.email });

    const existing = await this.userRepo.findByEmail(input.email);
    if (existing) throw new Error("User already exists");

    const user = User.create(input);
    await this.userRepo.save(user);
    await this.emailService.sendWelcome(user.email, user.name);

    this.logger.info("User created", { userId: user.id });
    return { id: user.id, email: user.email };
  }
}

// Resolve from container
const useCase = container.resolve(CreateUserUseCase);
await useCase.execute({ email: "john@example.com", name: "John" });

Simple DIY Container

// Lightweight container without decorators
type Factory = (container: DIContainer) => T;

class DIContainer {
  private factories = new Map>();
  private singletons = new Map();

  register(token: string, factory: Factory): void {
    this.factories.set(token, factory);
  }

  registerSingleton(token: string, factory: Factory): void {
    this.factories.set(token, (c) => {
      if (!this.singletons.has(token)) {
        this.singletons.set(token, factory(c));
      }
      return this.singletons.get(token)!;
    });
  }

  resolve(token: string): T {
    const factory = this.factories.get(token);
    if (!factory) throw new Error(`No registration for ${token}`);
    return factory(this) as T;
  }
}

// Usage
const container = new DIContainer();

container.registerSingleton("Database", () =>
  new Pool({ connectionString: process.env.DATABASE_URL })
);

container.register("UserRepository", (c) =>
  new PostgresUserRepository(c.resolve("Database"))
);

container.register("CreateUserUseCase", (c) =>
  new CreateUserUseCase(
    c.resolve("UserRepository"),
    c.resolve("EmailService"),
    c.resolve("Logger")
  )
);

// Resolve with all dependencies automatically wired
const useCase = container.resolve("CreateUserUseCase");

DI Best Practices

  • Prefer constructor injection: It makes dependencies explicit and ensures the object is fully initialized
  • Depend on abstractions: Inject interfaces, not concrete classes
  • Single composition root: Wire all dependencies in one place, typically at the application entry point
  • Avoid service locator: Do not pass the container itself as a dependency — that is an anti-pattern
  • Keep constructors simple: Constructors should only assign dependencies — no logic or I/O

Continue Learning