Раздел 9 · Backend

DI и слои данных: кто собирает граф и сколько нужно моделей

middle-senior~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

DI и слои данных: кто собирает граф и сколько нужно моделей

В уроке про стрелки внутрь мы развернули зависимости: ядро объявляет порты, адаптеры их реализуют. Остались два практических вопроса, без которых картинка не работает. Первый: кто и где соединяет порты с адаптерами, то есть кто собирает граф объектов. Второй: в каком виде данные ходят между слоями и почему один тип Order на HTTP, домен и таблицу рано или поздно взрывается.

Сквозная фича прежняя: создание заказа с лимитом и письмом. К концу урока у неё появится полная сборка: composition root, четыре модели данных и транзакция вокруг use-case.

IoC и DI: кто кого создаёт

Мы уже делали DI в прошлом уроке, просто не называли. Вот это место:

// core/create-order.ts: зависимости приходят аргументами фабрики
export function makeCreateOrder(store: OrderStore, notifier: Notifier) {
  return async (userId: string, input: NewOrderInput) => {
    /* ... */
  };
}

Ядро не создаёт store и notifier само, их подают снаружи. Это constructor injection, только на функциях: фабрика играет роль конструктора. Теперь термины, которые вокруг этого приёма постоянно путают.

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

Каноничные виды инъекции:

  • Constructor injection: зависимости в конструкторе или фабрике. Объект невозможно создать недособранным, список зависимостей виден в одной сигнатуре. Вариант по умолчанию.
  • Property injection: зависимость присваивается в поле после создания. Появляется промежуток, когда объект создан, но не готов. Оправдан редко, в основном для опциональных зависимостей с дефолтом.
  • Method injection: зависимость приходит параметром конкретного метода, потому что нужна только ему и меняется от вызова к вызову.

Антипод DI, с которым его путают чаще всего, это Service Locator:

// антипример: зависимости спрятаны внутри
async function createOrder(userId: string, input: NewOrderInput) {
  const store = locator.get<OrderStore>('orderStore'); // сюрприз номер один
  const notifier = locator.get<Notifier>('notifier'); // сюрприз номер два
  /* ... */
}

Снаружи сигнатура врёт: она обещает, что функции хватит двух аргументов, а функция тянет из глобального реестра ещё два сервиса. Тест падает с ошибкой резолва вместо ошибки компиляции, список реальных зависимостей узнаётся чтением тела. Марк Симанн разбирает это в статье из ресурсов и выносит вердикт: антипаттерн. Запомнить просто: DI это честная сигнатура, локатор это скрытый глобал.

Composition Root: одно место сборки

Если каждый объект получает зависимости снаружи, кто-то должен один раз собрать весь граф. Это место называется composition root, и его позиция важнее инструмента: сборка живёт у точки входа, а не размазана по коду.

// main.ts: единственное место, где встречаются конкретные имена
const pool = makePgPool(env.DATABASE_URL);

const orderStore = makePostgresOrderStore(pool); // адаптер
const notifier = makeSmtpNotifier(env.SMTP_URL); // адаптер
const createOrder = makeCreateOrder(orderStore, notifier); // ядро

const app = buildHttpApp({ createOrder }); // driving-сторона
app.listen({ port: 3000 });

Десять строк, ноль магии, и у этого ручного способа есть имя: Pure DI. Для сервиса на пару десятков зависимостей его хватает с запасом, и полный граф читается как оглавление. Контейнер появляется не для красоты, а когда у графа возникают свойства, которые руками поддерживать дорого:

  • Размер: сотни регистраций, многоэтажные графы, автосвязывание по имени или типу экономит страницы фабрик.
  • Lifecycle: контейнер управляет временем жизни каждой регистрации. Три режима:
РежимСколько экземпляровТипичный жилец
singletonодин на процесспул соединений, конфиг, логгер
scopedодин на запросконтекст пользователя, транзакция, request-логгер
transientновый на каждый резолвобъект с внутренним мутабельным состоянием

По-бытовому: singleton это кофемашина на весь офис, scoped это поднос столовой, который выдают на входе и забирают на выходе, transient это одноразовый стакан.

  • Scoped-режим: на каждый HTTP-запрос контейнер открывает дочерний scope, и все scoped-регистрации внутри него резолвятся в одни и те же экземпляры. Так request-логгер с trace-id и открытая транзакция доезжают до репозитория без протаскивания через все сигнатуры.

В Node-мире это awilix (без декораторов, скоупы из коробки, мы подключали его к Fastify в уроке про экосистему), встроенный контейнер Nest на декораторах (урок про IoC-контейнер Nest) и tsyringe. Сигнал перебора тот же, что и с кольцами Clean: если регистраций меньше, чем концепций контейнера, которые пришлось выучить, вернись к Pure DI.

// то же самое на awilix: выигрыш появится на сотне регистраций
container.register({
  pool: asFunction(() => makePgPool(env.DATABASE_URL)).singleton(),
  orderStore: asFunction(makePostgresOrderStore).singleton(),
  notifier: asFunction(makeSmtpNotifier).singleton(),
  requestContext: asFunction(makeRequestContext).scoped(),
  createOrder: asFunction(makeCreateOrder).singleton(),
});

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.