Для чего нужно внедрение зависимостей в JavaScript?

Дата публикации: 2026-07-21

Я время от времени сталкиваюсь в Сети с непониманием, для чего нужна такая технология, как внедрение зависимостей, в таком языке, как JavaScript. Мол, в JS всё строится на прототипах, объекты можно изменять во время выполнения, а DI — какая-то ООП-технология из мира Java.

Какую проблему решает DI, если нужный объект можно изменить как угодно прямо в процессе выполнения?

Вот браузерный код, который можно выполнить прямо в консоли и заставить console.log() дополнительно отправлять сообщения на сервер:

const originalLog = console.log.bind(console);

console.log = (...args) => {
  navigator.sendBeacon("/api/log", JSON.stringify(args));
  originalLog(...args);
};

Никакого контейнера, интерфейсов или специальных архитектурных конструкций. Мы просто заменили метод объекта.

Мой ответ такой: внедрение зависимостей помогает снизить стоимость верификации сложного приложения. Оно отделяет компонент от выбора конкретного окружения и позволяет проверять его относительно явных ожиданий, не поднимая каждый раз весь граф реальных зависимостей.

Само по себе DI ещё не гарантирует независимую проверку. Для этого нужны достаточно полные контракты и отдельная верификация потребителей и имплементаций. Но именно DI создаёт границу, на которой такое разделение становится возможным.

Попробуем получить этот вывод из нескольких строк обычного JavaScript. Без классов, наследования, декораторов и DI-контейнера.

Код без внешних зависимостей

Начнём с чистой функции:

export function normalizeName(name) {
  return name.trim().toLowerCase();
}

Она зависит только от входного значения и проверяется напрямую:

import assert from "node:assert/strict";
import { normalizeName } from "./normalizeName.mjs";

assert.equal(normalizeName(" Alex "), "alex");
assert.equal(normalizeName("BOB"), "bob");

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

Зависимость от внешнего мира

Добавим хранилище:

// storage.mjs
const values = [];

export async function save(value) {
  values.push(value);
}

export async function loadAll() {
  return [...values];
}

Функция saveName() будет нормализовывать имя и сохранять результат:

// saveName.mjs
import { save } from "./storage.mjs";

export async function saveName(name) {
  const value = name.trim().toLowerCase();
  await save(value);
  return value;
}

Нормализацию можно проверить отдельно:

assert.equal(await saveName(" Alex "), "alex");

Хранилище тоже можно проверить отдельно:

await save("alex");

assert.deepEqual(await loadAll(), ["alex"]);

Но два таких теста ещё не проверяют связь между компонентами.

В saveName() легко допустить ошибку:

export async function saveName(name) {
  const value = name.trim().toLowerCase();
  await save(name);
  return value;
}

Нормализация работает. Хранилище корректно сохраняет переданное ему значение. Однако вместе компоненты ведут себя неправильно: вместо "alex" сохраняется " Alex ".

Чтобы обнаружить ошибку, приходится проверять их в связке:

await saveName(" Alex ");

assert.deepEqual(await loadAll(), ["alex"]);

С этого момента единицей верификации становится уже не одна функция, а связка saveName() и конкретного storage.mjs.

Если хранилище само зависит от файловой системы, конфигурации и логгера, тест верхнего компонента начинает поднимать весь транзитивный граф.

Представим компонент, который зависит от трёх модулей, а каждый из них — ещё от трёх:

1 + 3 + 9 = 13 модулей

Пока это не мультипликативный рост числа конфигураций. Растёт сама единица тестирования: локальная проверка одного компонента постепенно превращается в интеграционную проверку связанного графа.

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

Где возникает интерфейс

Посмотрим на выражение:

await storage.save(value);

В нём уже присутствует интерфейс зависимости.

Компонент ожидает, что внешний объект имеет метод save(), этот метод принимает значение, а результат его работы можно ожидать через await.

JavaScript не требует объявлять такой интерфейс отдельно. Но отсутствие объявления не означает отсутствия ожиданий между компонентами.

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

Интерфейс — это описание ожиданий потребителя на границе с зависимостью.

Именно поэтому duck typing не следует понимать как классификацию объекта целиком. Разные потребители могут ожидать от одного объекта разного поведения. Для одного важна способность утки плавать, для другого — нести яйца. Объект остаётся тем же, но интерфейсы связей различаются.

Выражение storage.save(value) задаёт структурную часть интерфейса: имя метода, аргументы и способ вызова. Но контракт включает и поведение.

Что значит «сохранить»? Когда операция считается завершённой? Должно ли значение затем возвращаться через loadAll()? Какие ошибки допустимы?

Структуру можно описать типами и аннотациями. Поведение приходится фиксировать документацией и проверять тестами.

Внедрение зависимости

Чтобы снова сделать saveName() самостоятельной единицей верификации, уберём из неё знание о конкретном storage.mjs.

Сама зависимость не исчезает. Функция по-прежнему должна сохранить нормализованное имя. Меняется способ связывания компонентов.

// saveName.mjs
export function createSaveName({ storage }) {
  return async function saveName(name) {
    const value = name.trim().toLowerCase();
    await storage.save(value);
    return value;
  };
}

Теперь saveName.mjs не импортирует конкретное хранилище. Он только объявляет ожидание: при создании функции ему должен быть передан объект с методом save().

В работающем приложении связь устанавливается отдельно:

// bootstrap.mjs
import * as storage from "./storage.mjs";
import { createSaveName } from "./saveName.mjs";

const saveName = createSaveName({ storage });

await saveName(" Alex ");

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

Конкретная имплементация выбирается здесь, а не внутри saveName.mjs.

Для теста тот же компонент можно собрать с минимальной тестовой имплементацией:

import assert from "node:assert/strict";
import { createSaveName } from "./saveName.mjs";

const saved = [];

const storage = {
  async save(value) {
    saved.push(value);
  },
};

const saveName = createSaveName({ storage });

assert.equal(await saveName(" Alex "), "alex");
assert.deepEqual(saved, ["alex"]);

Этот тест проверяет только поведение saveName():

имя нормализовано;
нормализованное значение передано в storage.save();
возвращён правильный результат.

Настоящее хранилище и его собственные зависимости поднимать не требуется. Тест сам собирает минимальное окружение, необходимое для проверки компонента.

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

При явном внедрении зависимости вмешательство в модульную систему не требуется. Тест передаёт нужную имплементацию обычным аргументом функции.

Статический import фиксирует конкретную связь внутри компонента. DI переносит выбор имплементации в точку сборки.

Внедрение зависимостей не создаёт принципиально новую возможность тестирования. Оно делает независимую верификацию штатным свойством архитектуры, а не специальным трюком тестовой инфраструктуры.

Интерфейсы в JavaScript

В ECMAScript нет действующей синтаксической конструкции interface. Но реальный язык проекта обычно шире спецификации, которую понимает JavaScript-движок.

Он включает соглашения, документацию, статический анализ, тесты, правила CI и требования к структуре кода.

В нашем случае язык проекта можно определить так:

JavaScript + JSDoc + соглашения об интерфейсах и их имплементациях.

Опишем интерфейс хранилища как структурный тип:

// NameStorage.mjs

/**
 * Хранилище строковых значений.
 *
 * @typedef {object} NameStorage
 * @property {(value: string) => Promise<void>} save
 *   Сохраняет значение.
 * @property {() => Promise<string[]>} loadAll
 *   Возвращает сохранённые значения.
 */

export {};

Теперь ранее неявные ожидания получили имя и структурное описание.

Поскольку мы работаем без классов, имплементации удобно оформлять как фабрики объектов:

// MemoryStorage.mjs

/** @import {NameStorage} from "./NameStorage.mjs" */

/**
 * @returns {NameStorage}
 */
export function createMemoryStorage() {
  const values = [];

  return {
    async save(value) {
      values.push(value);
    },

    async loadAll() {
      return [...values];
    },
  };
}

Файловая имплементация:

// FileStorage.mjs
import { appendFile, readFile } from "node:fs/promises";

/** @import {NameStorage} from "./NameStorage.mjs" */

/**
 * @param {string} path
 * @returns {NameStorage}
 */
export function createFileStorage(path) {
  return {
    async save(value) {
      await appendFile(path, `${value}\n`);
    },

    async loadAll() {
      const content = await readFile(path, "utf8");
      return content.split("\n").filter(Boolean);
    },
  };
}

Для работы с базой данных тоже опишем используемый контракт:

// DatabaseStorage.mjs

/** @import {NameStorage} from "./NameStorage.mjs" */

/**
 * @typedef {object} NameRow
 * @property {string} value
 */

/**
 * @typedef {object} Database
 * @property {(sql: string, params?: unknown[]) => Promise<void>} execute
 * @property {(sql: string, params?: unknown[]) => Promise<NameRow[]>} select
 */

/**
 * @param {Database} database
 * @returns {NameStorage}
 */
export function createDatabaseStorage(database) {
  return {
    async save(value) {
      await database.execute("INSERT INTO names (value) VALUES (?)", [value]);
    },

    async loadAll() {
      const rows = await database.select("SELECT value FROM names ORDER BY id");

      return rows.map((row) => row.value);
    },
  };
}

Зависимость saveName() также можно описать явно:

// saveName.mjs

/** @import {NameStorage} from "./NameStorage.mjs" */

/**
 * @param {{storage: NameStorage}} dependencies
 * @returns {(name: string) => Promise<string>}
 */
export function createSaveName({ storage }) {
  return async function saveName(name) {
    const value = name.trim().toLowerCase();
    await storage.save(value);
    return value;
  };
}

Для JavaScript-движка все три хранилища остаются обычными объектами. Но на языке проекта они являются имплементациями одного контракта.

const memoryStorage = createMemoryStorage();
const fileStorage = createFileStorage("./names.txt");
const databaseStorage = createDatabaseStorage(database);

const saveToMemory = createSaveName({
  storage: memoryStorage,
});

const saveToFile = createSaveName({
  storage: fileStorage,
});

const saveToDatabase = createSaveName({
  storage: databaseStorage,
});

Само наличие JSDoc-аннотаций ещё ничего не гарантирует. Чтобы JavaScript + JSDoc действительно стал языком проекта, соглашения должны проверяться.

Проект может требовать описывать интерфейсы внедряемых зависимостей, указывать тип результата фабрик, запрещать скрытые внешние зависимости, запускать статический анализ и контрактные тесты, а CI — блокировать интеграцию кода при нарушении этих правил.

Например, JSDoc можно проверять через TypeScript-анализатор:

tsc --allowJs --checkJs --noEmit

JSDoc не добавляет интерфейсы в программу. Они уже возникли на границах между компонентами. Аннотации только переносят их из головы разработчика в явный язык проекта, доступный людям, IDE, анализаторам и агентам.

Верификация интерфейса

Структурное соответствие интерфейсу ещё не означает поведенческого соответствия.

Объект может иметь методы save() и loadAll(), но реализовывать их неправильно:

const brokenStorage = {
  async save(value) {
    // Ничего не сохраняет.
  },

  async loadAll() {
    return [];
  },
};

Форма интерфейса соблюдена. Поведение — нет.

Поэтому имплементации должны проходить общий контрактный тест:

import assert from "node:assert/strict";

/**
 * @param {() => NameStorage | Promise<NameStorage>} createStorage
 */
export async function verifyNameStorage(createStorage) {
  const storage = await createStorage();

  await storage.save("alex");

  assert.deepEqual(await storage.loadAll(), ["alex"]);
}

Один и тот же набор проверок выполняется для разных имплементаций:

await verifyNameStorage(createMemoryStorage);

await verifyNameStorage(() => createFileStorage("./temporary-test-file.txt"));

await verifyNameStorage(() => createDatabaseStorage(testDatabase));

В реальном тесте файловая имплементация должна получать новый временный файл, а база данных — изолированную тестовую схему или транзакцию. Иначе состояние одного запуска может повлиять на следующий.

Теперь верификация разделена на две задачи.

Компонент saveName() проверяется относительно интерфейса NameStorage.

Каждая конкретная имплементация отдельно проверяется на соответствие тому же контракту.

DI выносит выбор реализации за пределы компонента. Интерфейс описывает границу. Контрактные тесты проверяют поведение имплементаций. Только вместе эти механизмы создают основание для взаимозаменяемости.

Множественные зависимости

Одна зависимость ещё не показывает, насколько быстро растёт пространство возможных конфигураций.

Расширим пример. Кроме хранилища, функции понадобятся часы и логгер.

/**
 * @typedef {object} NameRecord
 * @property {string} name
 * @property {string} createdAt
 */

/**
 * @typedef {object} NameRecordStorage
 * @property {(value: NameRecord) => Promise<void>} save
 */

/**
 * @typedef {object} Clock
 * @property {() => string} now
 */

/**
 * @typedef {object} Logger
 * @property {(message: string) => void} write
 */

Компонент получает три зависимости:

/**
 * @param {{
 *   storage: NameRecordStorage,
 *   clock: Clock,
 *   logger: Logger
 * }} dependencies
 * @returns {(name: string) => Promise<NameRecord>}
 */
export function createSaveName({ storage, clock, logger }) {
  return async function saveName(name) {
    const record = {
      name: name.trim().toLowerCase(),
      createdAt: clock.now(),
    };

    logger.write("Saving name");
    await storage.save(record);

    return record;
  };
}

Допустим, у каждого интерфейса есть по три имплементации:

Storage:
MemoryStorage
FileStorage
DatabaseStorage

Clock:
SystemClock
FixedClock
OffsetClock

Logger:
ConsoleLogger
FileLogger
RemoteLogger

Количество возможных сборок определяется произведением:

3 × 3 × 3 = 27

Если появляется четвёртая зависимость с тремя имплементациями, количество вариантов увеличивается до 81:

3 × 3 × 3 × 3 = 81

DI не уменьшает пространство возможных сборок. Все 27 или 81 вариант продолжают существовать.

Изменяется способ их верификации.

Компонент createSaveName() можно проверить относительно трёх интерфейсов, передав ему минимальные тестовые имплементации:

import assert from "node:assert/strict";
import { createSaveName } from "./saveName.mjs";

const saved = [];
const messages = [];

const storage = {
  async save(value) {
    saved.push(value);
  },
};

const clock = {
  now() {
    return "2026-07-21T12:00:00.000Z";
  },
};

const logger = {
  write(message) {
    messages.push(message);
  },
};

const saveName = createSaveName({
  storage,
  clock,
  logger,
});

const result = await saveName(" Alex ");

assert.deepEqual(result, {
  name: "alex",
  createdAt: "2026-07-21T12:00:00.000Z",
});

assert.deepEqual(saved, [result]);
assert.deepEqual(messages, ["Saving name"]);

Этот тест проверяет собственную логику компонента и его взаимодействие с тремя интерфейсами. Конкретные MemoryStorage, SystemClock и ConsoleLogger в нём не участвуют.

Затем отдельно проверяются реальные имплементации каждого интерфейса.

Если пытаться доказать корректность системы через перебор реальных сборок, количество комбинаций растёт как произведение:

k₁ × k₂ × ... × kₙ

При интерфейсном разложении базовая стоимость проверки выглядит иначе:

проверки потребителей
+ проверки имплементаций
+ выбранные интеграционные сценарии

Для одного потребителя и трёх интерфейсов с тремя имплементациями это приблизительно:

1 проверка компонента
+ 3 Storage
+ 3 Clock
+ 3 Logger
+ значимые интеграционные связки

Речь не идёт буквально о десяти тестовых функциях. Один компонент или контракт может потребовать много сценариев.

Важно другое: значительную часть полного перебора можно заменить независимой проверкой компонентов и имплементаций. Базовая стоимость такой проверки растёт ближе к сумме, а не к произведению.

Некоторые реальные сборки всё равно необходимо проверять интеграционно. Например, связку DatabaseStorage с конкретным драйвером базы данных или RemoteLogger с удалённым сервисом.

Ошибки также могут возникать только в определённых сочетаниях реализаций. Поэтому интеграционные тесты не исчезают. Но выбирать можно наиболее рискованные и значимые комбинации, а не механически перебирать всё пространство конфигураций.

При достаточно полных контрактах DI позволяет заменить значительную часть комбинаторной верификации независимой проверкой потребителей и имплементаций.

Чем больше в приложении компонентов и вариантов их реализации, тем заметнее становится эта разница.

Когда DI не нужен

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

Из этого следует и обратное: если конкретная связь не мешает проверять компонент, выносить её в отдельный интерфейс необязательно.

Проще всего это видно на чистых функциях:

export function normalizeName(name) {
  return name.trim().toLowerCase();
}

Все условия выполнения уже находятся в аргументах. У функции нет внешнего окружения, которое пришлось бы подменять.

Внедрять сюда зависимости бессмысленно.

То же относится ко многим внутренним утилитам:

import { normalizeName } from "./normalizeName.mjs";

export function createUserRecord(name, createdAt) {
  return {
    name: normalizeName(name),
    createdAt,
  };
}

normalizeName() — стабильная функция без состояния и внешних эффектов. Её статический импорт не разрастается в дорогую интеграцию и не мешает независимо проверить createUserRecord().

Можно формально внедрить и её:

export function createUserRecordFactory({ normalizeName }) {
  return function createUserRecord(name, createdAt) {
    return {
      name: normalizeName(name),
      createdAt,
    };
  };
}

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

DI может быть лишним и внутри небольшой связки компонентов, которая всегда используется и изменяется как единое целое. Если связь стабильна, её легко поднять в тесте, а отдельная подмена не имеет практического смысла, статический импорт остаётся нормальным решением.

Количество production-имплементаций само по себе не определяет необходимость DI.

У интерфейса может быть одна рабочая имплементация, но несколько значимых контекстов подстановки:

DatabaseStorage — production
MemoryStorage — обычные тесты
FailingStorage — проверка ошибок

Формально в приложении одна production-имплементация, но для верификации связь уже вариативна.

Граница проходит по стоимости связи.

DI не нужен там, где конкретная зависимость стабильна, прозрачна и не мешает независимой проверке компонента.

Статическими импортами обычно можно оставлять чистые функции, локальные преобразования, математические операции и небольшие внутренние утилиты.

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

Задача не в том, чтобы внедрять все зависимости подряд. Нужно разрезать приложение только по тем границам, где это действительно уменьшает стоимость верификации или позволяет управлять конфигурацией системы.

Заключение

Внедрение зависимостей не связано напрямую ни с классами, ни с наследованием, ни с конкретным языком программирования.

В функциональном JavaScript обычная функция может получить обычные объекты с ожидаемыми методами:

const saveName = createSaveName({
  storage,
  clock,
  logger,
});

В этом коде уже есть всё необходимое для DI.

Компонент формулирует ожидания к зависимостям.

Имплементации передаются снаружи.

Конкретная конфигурация выбирается в точке сборки.

DI-контейнер здесь не требуется. Он может автоматизировать сборку большого приложения, но не создаёт само внедрение зависимостей.

Сначала в архитектуре появляются явные границы и правила связывания компонентов. Только потом может возникнуть потребность в инструменте, который обслуживает эти правила.

Поэтому вопрос о применении DI стоит формулировать не так:

Можно ли подменить эту зависимость?

В динамическом языке подменить можно почти всё.

Полезнее спросить:

Мешает ли эта связь независимо проверить компонент?

Если мешает, зависимость имеет смысл вынести за границу компонента и передавать извне.

Если не мешает, статический импорт обычно остаётся более простым решением.

DI не гарантирует корректность архитектуры само по себе. Но вместе с явными интерфейсами, контрактными тестами и выборочной интеграционной проверкой оно позволяет удерживать стоимость верификации под контролем по мере роста приложения.