open atlas
↑ К треку
Паттерны и качество кода CP · 06 · 04

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

DI связывает абстракции с реализациями. Убить нужно связанность вида new ConcreteThing() внутри класса. Внедряй через конструктор, собирай граф в корне композиции, держи ядро чистым и тестируемым фейками — контейнер бери, лишь когда ручная сборка начинает мешать.

CP Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Принцип инверсии зависимостей говорит, на что опираться: на абстракцию, а не на конкретный класс. Но он не говорит, как абстракция вообще встречается с реальной реализацией. Кто-то где-то всё равно должен набрать new PostgresUserRepo(). Вопрос, который DIP оставляет открытым, — где это происходит, и неверный ответ тихо обнуляет весь принцип.

Помести new PostgresUserRepo() внутрь класса, который им пользуется, — и ты не инвертировал ничего: высокоуровневая политика приварена к низкоуровневой детали, и протестировать её без базы данных нельзя. Внедрение зависимостей — это та проводка, что держит сборку вне ядра. Это не фреймворк, который ты ставишь; это дисциплина о том, кто вызывает new и где.

Цель

После этого урока ты можешь убрать new ConcreteThing() из класса, внедрив зависимость через его конструктор; объяснить, почему именно корень композиции — одно место на краю приложения, которое собирает граф объектов, — делает ядро чистым и тестируемым фейками в юнит-тестах; собрать небольшой граф вручную и судить, когда DI-контейнер оправдывает себя, а когда он избыточен; и распознать антипаттерн service locator, где конструктор класса врёт о том, что ему на самом деле нужно.

1

Связанность, которую надо убрать, — это 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 существуют. В этом неведении весь смысл: высокоуровневая политика (оформление заказа) больше не знает о низкоуровневом механизме.

2

Кто-то всё равно должен вызвать 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 зависит от деталей. Всё ниже — чистая логика без проводки. Вот что значит «держать ядро в неведении о том, как оно сконструировано» на практике: конструирование — это листовая забота на границе, а не обязанность, размазанная по домену.

3

Именно это делает ядро тестируемым фейками в юнит-тестах. Поскольку 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 превращает «нетестируемо без инфраструктуры» в «тестируемо обычным объектом».

4

Ручной 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.