Dependency Injection в TypeScript: простое руководство для начинающих

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Что такое Dependency Injection и зачем он нужен?

В мире разработки программного обеспечения мы часто сталкиваемся с ситуацией, когда один класс (или модуль) зависит от другого. Например, класс UserService может использовать класс DatabaseService для сохранения данных. Традиционный подход — создать экземпляр DatabaseService прямо внутри UserService. Это приводит к сильной связанности кода, что усложняет его тестирование, поддержку и расширение.

Dependency Injection (DI) — это паттерн проектирования, который решает эту проблему. Вместо того чтобы создавать зависимости внутри класса, они «внедряются» извне. Это реализует принцип Инверсии управления (IoC): не класс управляет своими зависимостями, а внешний код (контейнер или вызывающая сторона) предоставляет их.

В этой статье мы разберем, как реализовать Dependency Injection в TypeScript на простых примерах. Вы узнаете, почему DI считается хорошей практикой и как он помогает писать чистый, тестируемый код.

Проблема: Жесткая связанность без DI

Давайте начнем с примера, который показывает, как не нужно писать код. Представьте, что у нас есть приложение, которое отправляет уведомления пользователям.

// Без Dependency Injection
class EmailService {
    sendEmail(to: string, message: string): void {
        console.log(`Отправка email на ${to}: ${message}`);
        // ... реальная логика отправки
    }
}

class NotificationService {
    private emailService: EmailService;

    constructor() {
        // Проблема: жесткая связанность
        this.emailService = new EmailService();
    }

    notifyUser(userEmail: string, message: string): void {
        this.emailService.sendEmail(userEmail, message);
    }
}

// Использование
const notifier = new NotificationService();
notifier.notifyUser('user@example.com', 'Hello!');

В этом коде NotificationService жестко привязан к EmailService. Что если мы захотим отправлять SMS или push-уведомления? Придется изменять код NotificationService. Кроме того, тестировать NotificationService сложно — мы не сможем подменить настоящий EmailService на мок-объект.

Решение: Внедрение зависимостей через конструктор

Самый простой и распространенный способ реализовать DI — передавать зависимости через конструктор. Это позволяет сделать код гибким и легко тестируемым.

Сначала определим интерфейс для нашей службы уведомлений. Это ключевой шаг: мы программируем на уровне абстракций, а не конкретных реализаций.

// Определяем интерфейс для любой службы уведомлений
interface INotificationService {
    send(to: string, message: string): void;
}

// Реализация для Email
class EmailService implements INotificationService {
    send(to: string, message: string): void {
        console.log(`[EMAIL] Отправлено на ${to}: ${message}`);
    }
}

// Реализация для SMS
class SMSService implements INotificationService {
    send(to: string, message: string): void {
        console.log(`[SMS] Отправлено на ${to}: ${message}`);
    }
}

// Класс, который использует DI
class UserNotifier {
    private notifier: INotificationService;

    // Зависимость внедряется через конструктор
    constructor(notifier: INotificationService) {
        this.notifier = notifier;
    }

    notifyUser(userContact: string, message: string): void {
        this.notifier.send(userContact, message);
    }
}

// Использование
const emailNotifier = new UserNotifier(new EmailService());
emailNotifier.notifyUser('alice@example.com', 'Ваш заказ готов!');

const smsNotifier = new UserNotifier(new SMSService());
smsNotifier.notifyUser('+7-999-123-45-67', 'Код подтверждения: 1234');

Теперь UserNotifier не знает, какой именно сервис он использует. Мы можем легко подменить EmailService на SMSService или даже на мок-объект для тестов. Это и есть суть Dependency Injection.

Преимущества такого подхода:

  • Гибкость: Легко менять поведение системы, просто подставляя другие реализации.
  • Тестируемость: Вы можете внедрить мок-объект и проверить, что методы вызываются с правильными аргументами.
  • Слабая связанность: Классы зависят от абстракций (интерфейсов), а не от конкретных классов.
  • Соответствие принципу Open/Closed (SOLID): Класс открыт для расширения (добавления новых сервисов), но закрыт для изменения (его код не нужно трогать).

Продвинутый подход: Контейнер зависимостей (DI Container)

Когда приложение становится больше, управлять всеми зависимостями вручную становится сложно. На помощь приходят контейнеры зависимостей (DI Containers). Это библиотеки, которые автоматически создают и внедряют зависимости. В TypeScript часто используют tsyringe, inversify или typedi.

Рассмотрим пример с использованием tsyringe (предварительно установите его: npm install tsyringe reflect-metadata).

Пример с контейнером tsyringe

import "reflect-metadata";
import { container, injectable, inject } from "tsyringe";

// 1. Помечаем классы как injectable
@injectable()
class Logger {
    log(message: string): void {
        console.log(`[LOG]: ${message}`);
    }
}

@injectable()
class DataProcessor {
    // 2. Внедряем зависимость через декоратор @inject
    constructor(@inject(Logger) private logger: Logger) {}

    process(data: string): void {
        this.logger.log(`Обработка данных: ${data}`);
        // ... логика обработки
    }
}

// 3. Регистрируем классы в контейнере (часто делается автоматически)
container.registerSingleton(Logger);
container.registerSingleton(DataProcessor);

// 4. Получаем экземпляр с уже внедренными зависимостями
const processor = container.resolve(DataProcessor);
processor.process("Тестовые данные");

Контейнер сам заботится о создании экземпляра Logger и передаче его в DataProcessor. Это избавляет от ручного создания цепочек зависимостей и делает код чище.

Заключение

Dependency Injection — это мощный паттерн, который делает ваш код на TypeScript более модульным, тестируемым и поддерживаемым. Мы рассмотрели два подхода: простой (ручное внедрение через конструктор) и продвинутый (использование DI контейнера).

Начните с

Блоги

Рекомендаций по книгам