Dependency Injection в TypeScript: практическое руководство с примерами

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

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

Начать курс

Dependency Injection в TypeScript: от теории к практике

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

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

Dependency Injection (Внедрение зависимостей) — это паттерн, при котором объект получает свои зависимости извне, а не создает их самостоятельно. Вместо того чтобы писать new SomeService() внутри класса, мы передаем готовый экземпляр через конструктор или метод.

Основные преимущества DI:

  • Снижение связанности (Coupling) — классы не зависят от конкретных реализаций, а только от абстракций (интерфейсов).
  • Упрощение тестирования — мы можем легко подменить реальные сервисы на моки или стабы.
  • Повышение переиспользуемости — зависимости можно заменять без изменения кода потребителя.
  • Соблюдение принципа инверсии зависимостей (DIP из SOLID) — модули верхнего уровня не зависят от модулей нижнего уровня.

Реализация Dependency Injection в TypeScript без фреймворков

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

// Без DI - жесткая связь
class Logger {
    log(message: string): void {
        console.log(`[LOG]: ${message}`);
    }
}

class UserService {
    private logger: Logger;

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

    createUser(name: string): void {
        this.logger.log(`Создан пользователь: ${name}`);
        // ... логика создания
    }
}

// С DI - через конструктор
interface ILogger {
    log(message: string): void;
}

class ConsoleLogger implements ILogger {
    log(message: string): void {
        console.log(`[CONSOLE LOG]: ${message}`);
    }
}

class FileLogger implements ILogger {
    log(message: string): void {
        // Логика записи в файл
        console.log(`[FILE LOG]: ${message}`);
    }
}

class UserServiceWithDI {
    constructor(private logger: ILogger) {} // Зависимость внедряется извне

    createUser(name: string): void {
        this.logger.log(`Создан пользователь: ${name}`);
    }
}

// Использование
const consoleLogger = new ConsoleLogger();
const userService = new UserServiceWithDI(consoleLogger);
userService.createUser('Alice'); // [CONSOLE LOG]: Создан пользователь: Alice

Как видите, теперь UserServiceWithDI не знает, какой именно логгер используется. Мы можем легко подменить его на FileLogger или мок для тестов.

Продвинутый DI: создаем простой IoC-контейнер

Когда зависимостей становится много, ручное внедрение через конструктор становится громоздким. На помощь приходит IoC-контейнер (Inversion of Control Container). Он управляет созданием и жизненным циклом объектов.

Реализуем простой контейнер на TypeScript:

// Тип для фабрики зависимостей
type Factory<T> = (container: Container) => T;

class Container {
    private factories = new Map<string, Factory<any>>();
    private instances = new Map<string, any>();

    // Регистрация зависимости (фабрика вызывается при каждом запросе)
    register<T>(name: string, factory: Factory<T>): void {
        this.factories.set(name, factory);
    }

    // Регистрация синглтона (фабрика вызывается один раз)
    singleton<T>(name: string, factory: Factory<T>): void {
        this.factories.set(name, (container) => {
            if (!this.instances.has(name)) {
                this.instances.set(name, factory(container));
            }
            return this.instances.get(name);
        });
    }

    // Получение зависимости
    resolve<T>(name: string): T {
        const factory = this.factories.get(name);
        if (!factory) {
            throw new Error(`Зависимость "${name}" не зарегистрирована`);
        }
        return factory(this) as T;
    }
}

// Использование контейнера
const container = new Container();

// Регистрируем интерфейсы и их реализации
container.register<ILogger>('Logger', () => new ConsoleLogger());
container.singleton<UserServiceWithDI>('UserService', (c) => {
    const logger = c.resolve<ILogger>('Logger');
    return new UserServiceWithDI(logger);
});

// Получаем готовый сервис со всеми зависимостями
const service = container.resolve<UserServiceWithDI>('UserService');
service.createUser('Bob');

Этот контейнер поддерживает два режима: register (новый экземпляр при каждом запросе) и singleton (один экземпляр на всё приложение).

Dependency Injection с помощью декораторов TypeScript

TypeScript поддерживает декораторы, которые делают DI еще более декларативным. Хотя для этого нужно включить experimentalDecorators в tsconfig.json, результат того стоит.

Пример с использованием популярной библиотеки tsyringe (легковесный DI-контейнер):

import { injectable, inject, container } from 'tsyringe';

// Абстракция
interface IDatabase {
    query(sql: string): any;
}

// Реализация
@injectable()
class PostgresDatabase implements IDatabase {
    query(sql: string): any {
        console.log(`Выполняю SQL: ${sql}`);
        return { data: 'result' };
    }
}

// Класс, который использует DI
@injectable()
class UserRepository {
    constructor(@inject('IDatabase') private db: IDatabase) {}

    findUser(id: number): any {
        return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
    }
}

// Регистрация и использование
container.registerSingleton<IDatabase>('IDatabase', PostgresDatabase);
const repo = container.resolve(UserRepository);
repo.findUser(1);

Декоратор @injectable() помечает класс как готовый к внедрению, а @inject('IDatabase') указывает контейнеру, какую именно зависимость нужно передать. Это избавляет нас от написания фабрик вручную.

Практический пример: приложение с DI

Рассмотрим более сложный сценарий — сервис отправки уведомлений, который использует несколько каналов.

// Интерфейсы
interface INotifier {
    send                

Блоги

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