Введение в Dependency Injection в TypeScript
Dependency Injection (DI) — это один из ключевых паттернов проектирования, который позволяет создавать слабосвязанные, тестируемые и легко поддерживаемые приложения. В контексте TypeScript, благодаря строгой типизации и возможностям декораторов, реализация DI становится особенно элегантной и безопасной.
В этой статье мы рассмотрим, что такое Dependency Injection, как его реализовать на TypeScript, и какие инструменты могут упростить этот процесс. Вы узнаете, как DI помогает бороться с жесткими связями между классами и делает ваш код более гибким.
Что такое Dependency Injection и зачем он нужен?
Dependency Injection (внедрение зависимостей) — это паттерн, при котором объект получает свои зависимости извне, а не создает их самостоятельно. Это реализует принцип Inversion of Control (IoC) — инверсии управления.
Проблема без DI:
class Logger {
log(message: string): void {
console.log(message);
}
}
class UserService {
private logger: Logger;
constructor() {
this.logger = new Logger(); // Жесткая связь
}
createUser(name: string): void {
this.logger.log(`User ${name} created`);
}
}
В этом примере UserService жестко привязан к конкретной реализации Logger. Если мы захотим заменить логгер на, скажем, файловый или удаленный, нам придется изменять код UserService.
Решение с DI:
interface ILogger {
log(message: string): void;
}
class ConsoleLogger implements ILogger {
log(message: string): void {
console.log(message);
}
}
class FileLogger implements ILogger {
log(message: string): void {
// Запись в файл
}
}
class UserService {
constructor(private logger: ILogger) {} // Зависимость внедряется
createUser(name: string): void {
this.logger.log(`User ${name} created`);
}
}
// Использование
const service = new UserService(new ConsoleLogger());
Преимущества DI:
- Тестируемость — легко подменить реальные зависимости на mock-объекты.
- Гибкость — можно менять поведение системы без изменения кода классов.
- Разделение ответственности — классы не занимаются созданием своих зависимостей.
- Переиспользование — зависимости можно использовать в разных частях приложения.
Реализация Dependency Injection в TypeScript вручную
Самый простой способ — внедрять зависимости через конструктор или свойства. TypeScript с его интерфейсами и модификаторами доступа делает это очень удобно.
Внедрение через конструктор (Constructor Injection)
interface IRepository<T> {
getAll(): Promise<T[]>;
getById(id: number): Promise<T | null>;
}
class UserRepository implements IRepository<User> {
async getAll(): Promise<User[]> {
// Реальная логика работы с БД
return [];
}
async getById(id: number): Promise<User | null> {
return null;
}
}
class UserController {
constructor(
private repository: IRepository<User>,
private logger: ILogger
) {}
async getUsers(): Promise<User[]> {
this.logger.log('Fetching users...');
return this.repository.getAll();
}
}
// Создание графа зависимостей
const logger = new ConsoleLogger();
const repository = new UserRepository();
const controller = new UserController(repository, logger);
Внедрение через свойства (Property Injection)
class EmailService {
private sender!: ISender;
setSender(sender: ISender): void {
this.sender = sender;
}
sendEmail(to: string, message: string): void {
this.sender.send(to, message);
}
}
// Использование
const emailService = new EmailService();
emailService.setSender(new SmtpSender());
Хотя ручное внедрение работает, в больших проектах оно становится громоздким. Здесь на помощь приходят DI-контейнеры.
Использование DI-контейнера: InversifyJS
InversifyJS — это мощный IoC-контейнер для TypeScript, который использует декораторы для автоматического разрешения зависимостей.
Установка
npm install inversify reflect-metadata --save
Не забудьте включить в tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Пример использования
import { Container, injectable, inject } from 'inversify';
import 'reflect-metadata';
// Определяем типы для идентификации
const TYPES = {
Logger: Symbol.for('Logger'),
UserService: Symbol.for('UserService'),
Database: Symbol.for('Database')
};
// Регистрируем классы как injectable
@injectable()
class Logger {
log(msg: string): void {
console.log(msg);
}
}
@injectable()
class Database {
constructor(@inject(TYPES.Logger) private logger: Logger) {}
connect(): void {
this.logger.log('Connected to database');
}
}
@injectable()
class UserService {
constructor(
@inject(TYPES.Logger) private logger: Logger,
@inject(TYPES.Database) private db: Database
) {}
createUser(name: string): void {
this.logger.log(`Creating user: ${name}`);
this.db.connect();
}
}
// Настройка контейнера
const container = new Container();
container.bind<Logger>(TYPES.Logger).to(Logger);
container.bind<Database>(TYPES.Database).to(Database);
container.bind<UserService>(TYPES.UserService).to(UserService);
// Получаем экземпляр
const userService = container.get<UserService>(TYPES.UserService);
userService.createUser('Alice');
Преимущества InversifyJS
- Автоматическое разрешение — контейнер сам строит граф зависимостей.
- Управление жизненным циклом — можно задать singleton, transient или request scope.
- Чистая архитектура — код не загромождается ручным созданием объектов.
- Поддержка асинхронных фабрик — для сложных сценариев инициализации.
Продвинутые техники и лучшие практики
1. Использование абстракций
Всегда программируйте на уровне интерфейсов, а не конкретных реализаций. Это позволяет легко подменять зависимости.
interface IPaymentGateway {
processPayment(amount: number): Promise<boolean>;
}
@injectable()
class StripeGateway implements IP