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.