Внедрение зависимостей в TypeScript
DI связывает абстракции с реализациями. Убить нужно связанность вида new ConcreteThing() внутри класса. Внедряй через конструктор, собирай граф в корне композиции, держи ядро чистым и тестируемым фейками — контейнер бери, лишь когда ручная сборка начинает мешать.
Принцип инверсии зависимостей говорит, на что опираться: на абстракцию, а не на конкретный класс. Но он не говорит, как абстракция вообще встречается с реальной реализацией. Кто-то где-то всё равно должен набрать new PostgresUserRepo(). Вопрос, который DIP оставляет открытым, — где это происходит, и неверный ответ тихо обнуляет весь принцип.
Помести new PostgresUserRepo() внутрь класса, который им пользуется, — и ты не инвертировал ничего: высокоуровневая политика приварена к низкоуровневой детали, и протестировать её без базы данных нельзя. Внедрение зависимостей — это та проводка, что держит сборку вне ядра. Это не фреймворк, который ты ставишь; это дисциплина о том, кто вызывает new и где.
После этого урока ты можешь убрать new ConcreteThing() из класса, внедрив зависимость через его конструктор; объяснить, почему именно корень композиции — одно место на краю приложения, которое собирает граф объектов, — делает ядро чистым и тестируемым фейками в юнит-тестах; собрать небольшой граф вручную и судить, когда DI-контейнер оправдывает себя, а когда он избыточен; и распознать антипаттерн service locator, где конструктор класса врёт о том, что ему на самом деле нужно.
Связанность, которую надо убрать, — это new внутри класса; внедрение через конструктор её выносит. Когда класс конструирует собственных коллабораторов, он зависит от их конкретных типов и деталей конструирования (строки подключения, конфиг ретраев, часы). Он не может работать без них и не может быть переключён на другой. Лекарство — перестать конструировать и начать получать: объяви то, что тебе нужно, как параметр конструктора, типизированный интерфейсом.
// BEFORE — the class reaches out and builds the world
class OrderService {
private readonly repo = new PostgresOrderRepo(process.env.DB_URL!);
private readonly mailer = new SmtpMailer(process.env.SMTP!);
place(order: Order) { /* uses this.repo, this.mailer */ }
}
// AFTER — the class declares what it needs and receives it
interface OrderRepo { save(order: Order): Promise<void>; }
interface Mailer { send(to: string, body: string): Promise<void>; }
class OrderService {
constructor(
private readonly repo: OrderRepo,
private readonly mailer: Mailer,
) {}
place(order: Order) { /* uses this.repo, this.mailer */ }
}OrderService теперь зависит только от интерфейсов OrderRepo и Mailer. Он понятия не имеет, что Postgres или SMTP существуют. В этом неведении весь смысл: высокоуровневая политика (оформление заказа) больше не знает о низкоуровневом механизме.
Кто-то всё равно должен вызвать new — вытолкни это в корень композиции на краю. Внедрение перемещает конструирование вверх, а не прочь. Конечная остановка — корень композиции: единственное место, максимально близкое к точке входа программы (main, бутстрап HTTP-сервера, старт воркера), где каждый конкретный класс конструируется и связывается воедино. Ядро его никогда не видит.
// main.ts — the composition root: the ONLY place that knows the concretes
function buildApp() {
const repo = new PostgresOrderRepo(process.env.DB_URL!);
const mailer = new SmtpMailer(process.env.SMTP!);
const orders = new OrderService(repo, mailer); // wire the graph
return new HttpServer(orders);
}
buildApp().listen(3000);Теперь стрелка зависимости направлена правильно: детали (PostgresOrderRepo) зависят от абстракций (OrderRepo), определённых рядом с политикой, и только main.ts зависит от деталей. Всё ниже — чистая логика без проводки. Вот что значит «держать ядро в неведении о том, как оно сконструировано» на практике: конструирование — это листовая забота на границе, а не обязанность, размазанная по домену.
Именно это делает ядро тестируемым фейками в юнит-тестах. Поскольку OrderService получает своих коллабораторов, тест может вручить ему тривиальные in-memory-заглушки вместо настоящей базы данных или SMTP-сервера. Ни сети, ни контейнера, ни мокающего фреймворка, переписывающего модули, — просто передай другой объект, удовлетворяющий интерфейсу.
test("place() emails the customer after saving", async () => {
const saved: Order[] = [];
const sent: string[] = [];
const fakeRepo: OrderRepo = { save: async (o) => { saved.push(o); } };
const fakeMailer: Mailer = { send: async (to) => { sent.push(to); } };
const service = new OrderService(fakeRepo, fakeMailer);
await service.place({ id: "1", email: "a@b.com" /* … */ });
expect(saved).toHaveLength(1);
expect(sent).toEqual(["a@b.com"]);
});Фейк занимает несколько строк, потому что шов — это интерфейс, а не конкретный класс, который пришлось бы наследовать или патчить на лету. Если ты ловишь себя на том, что тянешься к тяжёлому мокингу модулей ради теста юнита, — это обычно запах того, что зависимость зашита внутри, а не внедрена. DI превращает «нетестируемо без инфраструктуры» в «тестируемо обычным объектом».
Ручной DI против контейнера — и почему контейнер обычно избыточен на старте. Для небольшого графа корень композиции и есть несколько вызовов new в порядке зависимостей. Это «DI для бедных», и это правда нормально: явно, проверено типами, нулевые зависимости, а стектрейс указывает прямо на проводку. DI-контейнер это автоматизирует — ты один раз регистрируешь OrderRepo → PostgresOrderRepo и просишь контейнер разрешить OrderService, а он конструирует транзитивный граф за тебя.
// manual: explicit, no magic, type-checked by the compiler
const orders = new OrderService(new PostgresOrderRepo(url), new SmtpMailer(smtp));
// container (e.g. tsyringe): a registry resolves the graph for you
container.register<OrderRepo>("OrderRepo", { useClass: PostgresOrderRepo });
const orders = container.resolve(OrderService); // graph built by reflectionКонтейнер начинает окупаться, когда граф большой, когда важны времена жизни (синглтоны против per-request-областей) и когда ручная связка тех же десятков сервисов сама становится бременем сопровождения, — то, с чем сталкиваешься в крупном приложении на NestJS или Spring. Ниже этого порога контейнер добавляет зависимость, настройку декораторов/рефлексии и слой косвенности, который затемняет граф, а не проясняет его. Когда НЕ применять: не хватайся за контейнер-фреймворк в маленьком или раннем приложении только потому, что «настоящие приложения используют DI-контейнеры», — ручная проводка в корне композиции уже даёт тебе DIP. Контейнер — это оптимизация под масштаб графа, а не предпосылка для внедрения.
Вынеси new наружу и посмотри, как граф пересобирается на краю. Модуль уведомлений, который строит собственный мир:
// BEFORE — every dependency hardcoded inside; untestable without Redis + Twilio
class NotificationService {
private readonly queue = new RedisQueue(process.env.REDIS_URL!);
private readonly sms = new TwilioClient(process.env.TWILIO_KEY!);
private readonly clock = Date; // implicit time dependency
async remind(userId: string) {
const at = this.clock.now();
await this.queue.push({ userId, at });
await this.sms.send(userId, "reminder");
}
}Ты не можешь протестировать remind без живого Redis, аккаунта Twilio и недетерминированных часов. Теперь внедри швы:
interface Queue { push(job: Job): Promise<void>; }
interface Sms { send(userId: string, body: string): Promise<void>; }
interface Clock { now(): number; }
class NotificationService {
constructor(
private readonly queue: Queue,
private readonly sms: Sms,
private readonly clock: Clock,
) {}
async remind(userId: string) {
const at = this.clock.now();
await this.queue.push({ userId, at });
await this.sms.send(userId, "reminder");
}
}
// composition root (main.ts) — the concretes live here, and only here
const notifications = new NotificationService(
new RedisQueue(process.env.REDIS_URL!),
new TwilioClient(process.env.TWILIO_KEY!),
{ now: () => Date.now() },
);
// a test — three plain objects, deterministic, no infrastructure
const jobs: Job[] = [];
const svc = new NotificationService(
{ push: async (j) => { jobs.push(j); } },
{ send: async () => {} },
{ now: () => 1_700_000_000_000 }, // frozen time
);
await svc.remind("u1");
// jobs[0].at === 1_700_000_000_000 — exactly, every runВ самой логике ничего не изменилось. Мы лишь переместили new из нутра класса в корень композиции и назвали каждого коллаборатора интерфейсом. Выигрыш: класс теперь чистая функция своих входов, часы детерминированы в тестах, а проводка прода — это один очевидный блок на краю.
▸Почему это работает
Почему конструирование должно жить в одном корне, а не там, где удобно? Потому что ценность DI в том, что ядро не знает о конкретике, — и это свойство держится, лишь пока ни одна часть ядра их не конструирует. В тот миг, когда какой-то сервис где-то вызывает new PostgresRepo(), эта ветвь графа заваривается наглухо: её нельзя подменить фейком, её время жизни нельзя контролировать централизованно, а конфиг просачивается в домен. Концентрация всех new в корне композиции сводит поверхность «знает о конкретике» к единственному заменяемому файлу — так что приложение можно переконфигурировать (тестовые дублёры, другая БД, in-memory-режим для локальной разработки), правя только корень, а не ядро.
▸Частая ошибка
Соблазнительный режим отказа — service locator / глобальный контейнер: вместо объявления зависимостей в конструкторе код лезет в глобальный container.get("Mailer") всюду, где что-то нужно. Выглядит как DI — вон же контейнер! — но не инвертирует ничего полезного. Конструктор теперь врёт: new OrderService() не принимает аргументов, а класс тайком вытаскивает мейлер, репозиторий и часы из глобала в рантайме. Реальные зависимости класса не видны из его сигнатуры, тесты обязаны заполнить глобал перед конструированием чего бы то ни было, а пропущенная регистрация падает в рантайме, а не при компиляции. Лекарство — держать зависимости явными в конструкторе: даже если их разрешает контейнер, он должен делать это, читая параметры конструктора, а не отвечая на запрос изнутри класса. Конструктор, говорящий правду о том, что ему нужно, — в этом весь смысл.
Коллега заменяет зашитые `new` на глобальный `container.get('Mailer')`, вызываемый внутри каждого сервиса, и называет это «внедрением зависимостей». Почему это не даёт того, ради чего нужен DI?
Внедрение зависимостей — это проводка, которая делает принцип инверсии зависимостей реальным: оно убирает new ConcreteThing() из классов, заставляя их получать коллабораторов (типизированных интерфейсами) через конструктор, и поднимает всё конструирование вверх, к единственному корню композиции на краю приложения. Это держит ядро чистым и в неведении о том, как оно собрано, — а именно это делает его тестируемым в юнит-тестах через передачу обычных фейков в шов интерфейса, без всякой инфраструктуры. Начинай с ручной проводки (несколько вызовов new в корне); она явна и проверена компилятором. Бери контейнер, лишь когда масштаб графа и управление временами жизни делают ручную связку настоящим бременем. И избегай ловушки service locator: класс, вытаскивающий зависимости из глобального контейнера, имеет конструктор, который врёт о том, что ему нужно, — держи зависимости явными в сигнатуре, где их видят и компилятор, и следующий читатель.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.