Dependency Injection в JavaScript: практическое руководство для Node.js

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

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

Начать курс

Введение: Проблема жесткой связанности кода

Каждый разработчик Node.js рано или поздно сталкивается с ситуацией, когда изменение в одном модуле тянет за собой цепочку правок в десятке других. Особенно остро это проявляется при написании юнит-тестов: чтобы протестировать один сервис, приходится поднимать базу данных, внешние API и кэш. Корень проблемы — жесткая связанность (tight coupling).

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

В этой статье мы разберем DI на практике: от простой ручной инъекции до использования профессиональных контейнеров в Node.js.

1. Ручное внедрение зависимостей: первый шаг

Плохой подход: жесткая связь

Рассмотрим типичный пример сервиса для отправки email:

// userService.js
const emailService = require('./emailService');

class UserService {
  async registerUser(email) {
    // Жесткая зависимость от конкретной реализации
    await emailService.sendWelcomeEmail(email);
    console.log('Пользователь зарегистрирован');
  }
}

module.exports = UserService;

Проблема: при тестировании UserService невозможно подменить emailService на мок. Придется использовать сложные инструменты вроде proxyquire или jest.mock.

Хороший подход: инъекция через конструктор

Перепишем сервис, передавая зависимость через конструктор:

// userService.js
class UserService {
  constructor(emailService) {
    this.emailService = emailService;
  }

  async registerUser(email) {
    await this.emailService.sendWelcomeEmail(email);
    console.log('Пользователь зарегистрирован');
  }
}

module.exports = UserService;

Теперь мы можем легко тестировать сервис:

// userService.test.js
const UserService = require('./userService');

const mockEmailService = {
  sendWelcomeEmail: jest.fn().mockResolvedValue(true)
};

test('должен отправить приветственное письмо', async () => {
  const service = new UserService(mockEmailService);
  await service.registerUser('test@test.com');
  expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith('test@test.com');
});

2. Типы Dependency Injection в JavaScript

В JavaScript/Node.js существует три основных способа внедрения зависимостей:

2.1. Инъекция через конструктор

Самый распространенный и предсказуемый способ. Зависимости передаются при создании объекта:

class DatabaseService {
  constructor(connection) {
    this.connection = connection;
  }

  async query(sql) {
    return this.connection.execute(sql);
  }
}

// Использование
const db = new DatabaseService(new MySQLConnection());

2.2. Инъекция через сеттеры

Подходит, когда зависимости можно менять в процессе работы объекта:

class LoggerService {
  setLogger(logger) {
    this.logger = logger;
  }

  log(message) {
    this.logger.write(`[${new Date().toISOString()}] ${message}`);
  }
}

const logger = new LoggerService();
logger.setLogger(new ConsoleLogger());
logger.log('Приложение запущено');

2.3. Инъекция через параметры функции

Идеально для функционального подхода и утилит:

function sendNotification(user, emailService, smsService) {
  emailService.send(user.email, 'Уведомление');
  smsService.send(user.phone, 'Уведомление');
}

// Тестирование
const mockEmail = { send: jest.fn() };
const mockSms = { send: jest.fn() };
sendNotification({ email: 'a@b.com', phone: '123' }, mockEmail, mockSms);

3. Контейнеры DI для Node.js: Awilix

Когда приложение разрастается, ручное управление зависимостями становится утомительным. На помощь приходят DI-контейнеры. Один из лучших для Node.js — Awilix.

Установка и настройка

npm install awilix awilix-express

Пример: приложение с Awilix

// container.js
const { createContainer, asClass, asFunction, asValue } = require('awilix');

const container = createContainer();

// Регистрируем зависимости
container.register({
  dbConnection: asFunction(() => require('./db').connect(), {
    lifetime: 'SINGLETON'
  }),
  userRepository: asClass(require('./repositories/userRepository')),
  emailService: asClass(require('./services/emailService')),
  userService: asClass(require('./services/userService'))
});

module.exports = container;

Использование в Express-маршрутах

// app.js
const express = require('express');
const { scopePerRequest } = require('awilix-express');

const app = express();
app.use(scopePerRequest(container));

app.post('/register', (req, res) => {
  const userService = req.container.resolve('userService');
  userService.registerUser(req.body.email);
  res.json({ success: true });
});

Как это работает?

  • asClass — регистрирует класс, Awilix сам создает экземпляры
  • asFunction — регистрирует фабричную функцию
  • asValue — регистрирует готовое значение (например, конфигурацию)
  • lifetime — управляет временем жизни: SINGLETON (один на все приложение), SCOPED (на каждый запрос), TRANSIENT (новый при каждом запросе)

4. Практические преимущества DI

4.1. Тестирование без боли

С DI вы можете подменять любую зависимость, не изменяя код самого сервиса:

// Используем тот же контейнер, но подменяем сервис
container.register({
  emailService: asValue({
    sendWelcomeEmail: jest.fn().mockRejectedValue(new Error('SMTP down'))
  })
});

const userService = container.resolve('userService');
await expect(userService.registerUser('test@test.com')).rejects.toThrow('SMTP down');

4.2. Легкая замена реализаций

Хотите перейти с MySQL на PostgreSQL? Просто замените регистрацию в контейнере:

// Было
container.register({
  dbConnection: asFunction                

Блоги

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