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