Узнайте, как внедрение зависимостей делает код проще для тестирования, рефакторинга и расширения. Практичные паттерны, примеры и типичные ошибки, которых стоит избегать.

Dependency Injection (DI) — это простая идея: вместо того чтобы код сам создавал то, что ему нужно, вы передаёте эти вещи извне.
Эти «вещи, которые нужны» — это его зависимости — например: соединение с базой, платёжный сервис, часы, логгер или отправщик писем. Если ваш код сам строит эти зависимости, он незаметно фиксирует как они устроены.
Представьте кофемашину в офисе. Она зависит от воды, кофейных зёрен и электричества.
DI — это второй подход: «кофемашина» (ваш класс/функция) фокусируется на приготовлении кофе (своей задаче), а «поставки» (зависимости) предоставляет тот, кто её настраивает.
DI не требует использования конкретного фреймворка и не равен DI-контейнеру. Вы можете делать DI вручную, передавая зависимости как параметры (или через конструктор).
DI также не равно «мокированию». Моки — это способ использовать DI в тестах, но DI сам по себе — это просто решение о том, где создаются зависимости.
Когда зависимости передаются извне, код становится проще запускать в разных контекстах: в проде, в unit-тестах, в демо или для будущих фич.
Та же гибкость делает модули чище: части можно заменить без перевязки всей системы. В результате тесты становятся быстрее и понятнее (потому что можно подставить простые заглушки), а кодовая база — проще для изменений (части меньше переплетены).
Тугую связанность получают, когда одна часть кода прямо решает, какие другие части ей нужны. Самая распространённая форма — вызов new внутри бизнес-логики.
Представьте checkout-функцию, которая внутри делает new StripeClient() и new SmtpEmailSender(). Сначала это удобно — всё, что нужно, прямо рядом. Но это фиксирует поток checkout на конкретные реализации, детали конфигурации и даже правила создания (API-ключи, таймауты, сетевое поведение).
Эта связанность «скрыта», потому что её не видно в сигнатуре метода. Функция выглядит как обработка заказа, но тайно зависит от платёжных шлюзов, провайдеров почты и, возможно, базы данных.
Когда зависимости захардкожены:
Хардкоднутые зависимости заставляют unit-тесты выполнять реальную работу: сетевые вызовы, работу с файлами, часы, случайные идентификаторы или общие ресурсы. Тесты становятся медленными, потому что не изолированы, и нестабильными, потому что результат зависит от времени, внешних сервисов или порядка выполнения.
Если вы замечаете эти шаблоны, тугая связанность уже стоит вам времени:
new повсюду в ключевой логике\n- код, который нельзя протестировать без БД, веб-сервера или реального API-ключаDependency Injection решает это, делая зависимости явными и заменяемыми — без переписывания бизнес-правил при каждой смене окружения.
Inversion of Control (IoC) — это сдвиг ответственности: класс должен фокусироваться на чем он должен заниматься, а не как получить то, что ему нужно.
Когда класс создаёт свои зависимости (например, new EmailService() или напрямую открывает соединение с базой), он тихо берёт на себя две работы: бизнес-логику и настройку. Это делает класс сложнее менять, реиспользовать и тестировать.
С IoC ваш код зависит от абстракций — интерфейсов или небольших контрактов — вместо конкретных реализаций.
Например, CheckoutService не должен знать, обрабатываются ли платежи через Stripe, PayPal или фейковый тестовый процессор. Ему нужно «что-то, что может списать карту». Если CheckoutService принимает IPaymentProcessor, он может работать с любой реализацией, которая следует этому контракту.
Это держит вашу ядровую логику стабильной при изменении инструментов.
Практическая часть IoC — вынести создание зависимостей вне класса и передавать их (часто через конструктор). Здесь на помощь приходит DI: это распространённый способ реализовать IoC.
Вместо того чтобы класс выбирал и строил своих коллег, вы получаете класс, который получает коллег извне.
Результат — гибкость: менять поведение становится решением конфигурации, а не переписыванием.
Если классы не создают зависимости, кто-то другой должен это делать. Этот «кто-то» — composition root: место, где ваше приложение собирается — обычно код запуска.
Composition root — это место, где вы решаете: «В проде использовать RealPaymentProcessor; в тестах — FakePaymentProcessor». Держите проводку в одном месте, чтобы уменьшить сюрпризы и сохранить остальную кодовую базу сфокусированной.
IoC упрощает unit-тесты, потому что вы можете предоставить маленькие, быстрые тестовые дублёры вместо реальных сетей или БД.
Это также делает рефакторинги безопаснее: когда обязанности разделены, изменение реализации редко вынуждает менять классы-потребители — пока абстракция сохраняется.
DI — это не одна техника, а набор способов «подать» классу то, что ему нужно (логгер, клиент базы, платёжный шлюз). Выбор стиля влияет на понятность, тестируемость и насколько легко можно использовать DI неправильно.
С constructor injection зависимости обязательны для создания объекта. Большой плюс: вы не можете случайно забыть их.
Это подходит, когда зависимость:
Constructor injection обычно даёт самый понятный код и простые unit-тесты, потому что тест может передать фейк прямо при создании.
Иногда зависимость нужна только для одной операции — временный форматтер, специальная стратегия или значение области запроса.
В таких случаях передавайте её как параметр метода. Это держит объект компактнее и не повышает постоянную зависимость до поля класса.
Setter injection удобна, когда вы действительно не можете предоставить зависимость при создании (некоторые фреймворки или legacy-код). Но она скрывает требования: класс выглядит пригодным к использованию, даже если не настроен полностью.
Это часто приводит к неожиданностям в runtime («почему это undefined?») и делает тесты более хрупкими, потому что настройку легко пропустить.
Unit-тесты наиболее полезны, когда они быстрые, повторяемые и нацелены на одно поведение. Как только «unit» тест зависит от реальной БД, сетевого вызова, файловой системы или времени, он начинает тормозить и становиться нестабильным. Провалы перестают быть информативными: сломался ли код или окружение подкачало?
Dependency Injection (DI) это исправляет: ваш код принимает зависимости извне (доступ к БД, HTTP-клиенты, поставщики времени). В тестах вы можете заменить эти зависимости на лёгкие аналоги.
Реальная БД или API добавляют время установки и латентность. С DI вы можете внедрить in-memory репозиторий или фейковый клиент с заранее подготовленными ответами. Это значит:
Без DI код часто создаёт свои зависимости, заставляя тесты прогонять весь стек. С DI можно внедрять:
Без костылей и глобальных переключателей — просто передайте другую реализацию.
DI делает настройку явной. Вместо того чтобы копаться в конфигурации, строках соединения или тестовых переменных окружения, вы читаете тест и сразу видите, что реально, а что подставлено.
Типичный DI-дружественный тест выглядит так:
Такая прямота уменьшает шум и облегчает диагностику провалов.
Тестовый seam — преднамеренная «щель» в коде, где вы можете поменять поведение. В проде вы подключаете реальное, в тестах — безопасную быструю замену. DI — один из простейших способов создать такие сёмы без костылей.
Сёмы полезны вокруг частей, которые трудно контролировать в тесте:
Если бизнес-логика вызывает эти вещи напрямую, тесты хрупки: падают по причинам, не связанным с логикой (сбой сети, часовой пояс, отсутствие файлов).
Сём часто представляет интерфейс — или в динамических языках — простой контракт вроде «объект должен иметь метод now()». Главное — зависеть от того, что нужно, а не откуда это приходит.
Например, вместо вызова системных часов внутри сервиса заказов, зависеть от Clock:
SystemClock.now()\n- Тест: FakeClock.now() возвращает фиксированное времяТа же схема для чтения файлов (FileStore), отправки почты (Mailer) или списания карт (PaymentGateway). Ядро логики остаётся одинаковым — меняется только подставляемая реализация.
Когда поведение можно намеренно заменить:\n- тесты становятся менее хрупкими: нет зависимости от реального времени, сети или общего состояния\n- легче покрывать крайние случаи: можно смоделировать «платёж отклонён», «таймаут почтового провайдера» или «конец месяца» без сложных сценариев\n- провалы становятся понятнее: если тест падает, обычно виновата бизнес-правило, а не окружение
Хорошо расположенные сёмы уменьшают потребность во всеобъемлющем мокировании; вместо этого вы получаете несколько чистых точек подстановки, которые держат unit-тесты быстрыми и предсказуемыми.
Модульность — идея, что софт состоит из независимых частей (модулей) с ясными границами: каждый модуль отвечает за узкую задачу и имеет понятный способ взаимодействия с остальной системой.
DI поддерживает это, делая границы явными. Вместо того чтобы модуль сам искал или создавал всё, что ему нужно, он получает зависимости извне. Этот небольшой сдвиг снижает объем знаний одного модуля о другом.
Когда код создаёт зависимости сам (например, new-ит клиента БД внутри сервиса), вызывающий и зависимый сильно связаны. DI поощряет зависеть от интерфейса (или простого контракта), а не от конкретной реализации.
Это значит, что модулю обычно нужно знать:
PaymentGateway.charge())\n- а не как это реализовано (Stripe vs PayPal vs sandbox)В следствие, модули реже меняются вместе, потому что внутренние детали не протекают через границы.
Модульный код должен позволять заменить компонент без переписывания всех потребителей. DI делает это практичным:
В каждом случае вызывающие продолжают работать с тем же контрактом. «Проводка» меняется в одном месте (composition root), а не по всему коду.
Ясные границы зависимостей упрощают параллельную работу: одна команда делает новую реализацию за согласованным интерфейсом, другая продолжает развивать фичи, которые от него зависят.
DI также поддерживает поэтапный рефакторинг: можно извлечь модуль, внедрить его и заменить постепенно — без большого одноразового переписывания.
Понимание DI в коде часто делает идею понятнее. Небольшой «до и после» на примере уведомлений.
Когда класс вызывает new внутри себя, он решает какую реализацию использовать и как её построить.
class EmailService {
send(to, message) {
// talks to real SMTP provider
}
}
class WelcomeNotifier {
notify(user) {
const email = new EmailService();
email.send(user.email, "Welcome!");
}
}
Боль при тестировании: unit-тест рискует запустить реальную отправку письма (или требует awkward глобального патчинга).
test("sends welcome email", () => {
const notifier = new WelcomeNotifier();
notifier.notify({ email: "[email protected]" });
// Hard to assert without patching EmailService globally
});
Теперь WelcomeNotifier принимает любой объект с нужным поведением.
class WelcomeNotifier {
constructor(emailService) {
this.emailService = emailService;
}
notify(user) {
this.emailService.send(user.email, "Welcome!");
}
}
Тест стал маленьким, быстрым и явным.
test("sends welcome email", () => {
const fakeEmail = { send: vi.fn() };
const notifier = new WelcomeNotifier(fakeEmail);
notifier.notify({ email: "[email protected]" });
expect(fakeEmail.send).toHaveBeenCalledWith("[email protected]", "Welcome!");
});
Хотите SMS позже? WelcomeNotifier не трогаем — просто передаём другую реализацию:
const smsService = { send: (to, msg) => {/* SMS provider */} };
const notifier = new WelcomeNotifier(smsService);
Практический выигрыш: тесты перестают бороться с деталями создания, а новое поведение добавляется заменой зависимостей, а не переписыванием существующего кода.
DI может быть простым: «передай то, что нужно туда, где это используется» — это ручной DI. DI-контейнер автоматизирует проводку. Оба подходят — вопрос в степени автоматизации, соответствующей приложению.
С ручным DI вы сами создаёте объекты и передаёте зависимости через конструкторы или параметры. Это просто:
Ручная проводка также заставляет думать о дизайне. Если объект требует семи зависимостей, это сразу чувствуется — сигнал о необходимости разбить обязанности.
Когда число компонентов растёт, ручная проводка может превратиться в рутинную «сантехнику». DI-контейнер помогает:
Контейнеры хороши для веб-приложений, долгоживущих сервисов или систем с общими инфраструктурными зависимостями.
Контейнер может заставить сильно связанную архитектуру казаться аккуратной, потому что проводка исчезает. Но проблемы остаются:
Если контейнер ухудшает читаемость или разработчики перестали понимать, кто от чего зависит, вы, возможно, зашли слишком далеко.
Начинайте с ручного DI, чтобы сохранять явность при формировании модулей. Добавляйте контейнер, когда проводка становится повторяющейся или требуется управление жизненным циклом.
Практическое правило: используйте ручной DI внутри ядра/бизнес-кода, и (опционально) контейнер на границе приложения (composition root) для сборки всего. Это сохраняет дизайн прозрачным и снижает шаблонную работу по мере роста проекта.
DI делает код легче для тестирования и изменений — но только при дисциплине. Вот как чаще всего DI идёт не так и что помогает.
Если класс требует длинного списка зависимостей, он, вероятно, делает слишком много. Это не провал DI — это DI, подсвечивающий запах дизайна.
Правило: если вы не можете описать задачу класса в одном предложении или конструктор растёт, подумайте о разделении класса, выделении меньшего коллеги или группировке связанных операций за одним интерфейсом (но не создавайте «бог-сервисы").
Service Locator выглядит как вызов container.get(Foo) внутри бизнес-кода. Это удобно, но делает зависимости невидимыми: по сигнатуре конструктора не понять, что нужно.
Тестировать сложнее, потому что приходится настраивать глобальное состояние (локатор) вместо передачи явных фейков. Предпочитайте явную передачу зависимостей (constructor injection).
DI-контейнеры могут падать в runtime, когда:
Такие баги неприятны, потому что проявляются только при выполнении проводки.
Держите конструкторы небольшими и сосредоточенными. Если список зависимостей растёт — рефакторьте.
Добавьте интеграционные тесты проводки. Даже лёгкий тест, который собирает контейнер приложения (или вручную проводит всё), ловит пропущенные регистрации и циклы до продакшена.
Наконец, оставляйте создание объектов в одном месте (startup/composition root) и не вызывайте контейнер из бизнес-логики. Это сохраняет основную выгодy DI: прозрачность зависимостей.
DI легче внедрять как серию маленьких, низкорисковых рефакторингов. Начните там, где тесты медленные или хрупкие, и где изменения часто прокатываются по несвязным частям кода.
Ищите зависимости, которые усложняют тестирование или понимание кода:
Если функция не может работать без выхода за процесс, это хороший кандидат.
Этот подход делает каждое изменение обозримым и позволяет остановиться на любом шаге без поломки системы.
DI может случайно превратить код в «каждый зависит от всего», если внедрять слишком много.
Правило: внедряйте возможности, а не детали. Например, внедряйте Clock, а не «SystemTime + TimeZoneResolver + NtpClient». Если классу нужны пять несвязанных сервисов, возможно, он делает слишком много — подумайте о разделении.
Также избегайте прокидывания зависимостей через множество слоёв «на всякий случай». Внедряйте только туда, где они используются; централизуйте проводку в одном месте.
Если вы используете генератор кода или workflow для быстрого прототипирования, DI становится ещё ценнее, потому что сохраняет структуру по мере роста проекта. Например, когда команды используют Koder.ai для генерации React-фронтендов, Go-сервисов и PostgreSQL-бэкендов из чатового специ, ясный composition root и DI-дружественные интерфейсы помогают такому коду оставаться тестируемым, удобным для рефакторинга и лёгким в подмене интеграций (почта, платежи, хранение) без переписывания ядра бизнес-логики.
Правило остаётся: держите создание объектов и окруженческую проводку на границе, а бизнес-код — сосредоточенным на поведении.
Вы должны увидеть конкретные улучшения:
Если нужен следующий шаг — задокументируйте ваш composition root и держите его простым: один файл для проводки зависимостей, а остальной код — про поведение.
Dependency Injection (DI) означает, что ваш код получает нужные ему вещи (база данных, логгер, часы, клиент платежей) снаружи, вместо того чтобы создавать их внутри себя.
Практически это обычно выглядит как передача зависимостей в конструктор или параметр функции, чтобы они были явными и заменяемыми.
Инверсия управления (IoC) — более широкая идея: класс должен фокусироваться на том, что он делает, а не на том, как он получает своих коллег.
DI — это распространённый способ добиться IoC, перемещая создание зависимостей наружу и передавая их внутрь.
Если зависимость создаётся через new внутри бизнес-логики, её трудно заменить.
Это приводит к:
DI помогает тестам оставаться быстрыми и детерминированными, потому что вы можете внедрять тестовые дублёры вместо реальных внешних систем.
Типичные замены:
DI-контейнер — опция, а не требование. Начните с ручного DI (передавайте зависимости явно), когда:
Рассмотрите контейнер, когда проводка становится рутинной или нужно управлять жизненным циклом (singleton/перезапрос и т.п.).
Используйте constructor injection, когда зависимость обязательна для работы объекта и используется в нескольких методах.
Используйте method/parameter injection, когда она нужна только для одного вызова (например, значение на уровне запроса или одноразовая стратегия).
Избегайте setter/property injection, если только вам действительно не нужна поздняя проводка; добавьте проверки, чтобы немедленно падать при отсутствии зависимости.
Composition root — это место, где вы собираете приложение: создаёте реализации и передаёте их в сервисы, которые от них зависят.
Держите его рядом со стартовой точкой приложения (entry point), чтобы остальной код был сосредоточен на поведении, а не на проводке.
Test seam — это преднамеренная точка, где поведение можно заменить.
Хорошие места для сёмов — это трудноконтролируемые в тесте вещи:
Clock.now())DI создаёт сёмы, позволяя в тестах внедрять заменяющую реализацию.
Распространённые ошибки:
container.get() внутри бизнес-кода делает зависимости невидимыми; отдавайте предпочтение явным параметрам.Безопасный способ внедрить DI в существующий код:
Повторяйте для следующего сёма; остановиться можно на любом шаге без полного рефакторинга.