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