TL;DR

Посмотреть код целиком можно тут: https://github.com/kubarskii/simple-di
Статья скорее рассчитана на начинающих разработчиков, но для ее понимания потребуется некоторое время и базовое представление об архитектуре приложений и паттернах. Перед чтением рекомендую познакомиться с принципами DDD и SOLID, особенно с буквой (D).


Я уже давно пытаюсь придумать, как можно сделать достойный DI-контейнер на ванильном JavaScript. Несколько раз я уже подходил к этой задаче, пробовал разные варианты, но отсутствие полноценной рефлексии и строгой типизации каждый раз сильно ограничивало возможные решения. Конечно, можно явно перечислять зависимости, хранить их в статических свойствах классов или использовать отдельный реестр метаданных, даже парсить исходный код классов, но обычно в какой-то момент такой контейнер либо становится слишком многословным, либо начинает напоминать плохо реализованный service locator.

В этот раз мне, кажется, удалось найти более интересный подход (возможно его находили и до меня). Он основан на том, что в JavaScript классы являются обычными значениями: их можно динамически создавать, передавать в функции, дополнять метаданными и использовать как токены. В результате получился небольшой DI-контейнер без TypeScript, декораторов и reflect-metadata.

Пока это эксперимент и попытка проверить саму идею, а не готовая библиотека. Тем не менее результат уже выглядит достойно, и после небольшой доработки я вполне мог бы попробовать использовать такой подход в реальном проекте. В статье разберем, как устроен контейнер, почему я решил отказаться от автоматического анализа конструкторов и как применить его в небольшом DDD-примере с корзиной товаров.


Когда речь заходит о dependency injection в JavaScript, разговор обычно быстро сводится к TypeScript, декораторам и reflect-metadata. Класс помечается как injectable, типы параметров конструктора превращаются в метаданные, а контейнер пытается самостоятельно понять, что именно нужно передать в каждый аргумент.

При этом сам JavaScript уже содержит почти все, что нужно для простого DI-контейнера. Класс в JavaScript является обычным runtime-значением. Его можно создать внутри функции, передать в другую функцию, использовать как ключ в Map, дополнить собственными свойствами и вернуть наружу. Значит, список зависимостей можно объявить прямо в момент создания класса, не пытаясь восстановить его позднее из типов, имен аргументов или результата Function.prototype.toString().

Далее мы соберем небольшой DI-контейнер на чистом JavaScript, добавим singleton, scoped и transient lifetime, а затем применим его в более реалистичном примере с DDD-корзиной. Доменный слой останется независимым от контейнера, а DI будет отвечать только за сборку application и infrastructure слоев.

Почему обычный JavaScript не может вывести зависимости автоматически

Представим простой сервис:

class CartApplicationService {
  constructor(repository, catalog, discountPolicy) {
    this.repository = repository;
    this.catalog = catalog;
    this.discountPolicy = discountPolicy;
  }
}

Во время выполнения JavaScript видит функцию-конструктор, но не знает, что repository должен реализовывать порт репозитория корзин, catalog должен быть каталогом товаров, а discountPolicy должен рассчитывать скидку. Имена аргументов не являются контрактом. После минификации они легко превращаются в a, b и c, а значения по умолчанию, деструктуризация и rest-параметры делают разбор сигнатуры еще менее надежным.

Можно написать собственный аналог reflect-metadata на WeakMap, но он решит только задачу хранения. Кто-то все равно должен положить туда список зависимостей. Поэтому можно убрать промежуточное хранилище и объявить зависимости в функции, которая создает класс.

Желаемый API будет выглядеть примерно так:

const CartApplicationService = component(
  {
    name: "CartApplicationService",
    lifetime: "scoped",
    inject: {
      carts: CART_REPOSITORY,
      catalog: PRODUCT_CATALOG,
      discounts: DISCOUNT_POLICY,
    },
  },
  () => class {
    constructor({ carts, catalog, discounts }) {
      this.carts = carts;
      this.catalog = catalog;
      this.discounts = discounts;
    }
  },
);

В этом варианте класс создается динамически, получает стабильное runtime-имя и одновременно сохраняет описание своих зависимостей. Сам класс можно использовать как DI-токен, а для абстрактных портов удобно использовать Symbol.

Создаем компонент

Начнем с минимальной функции component():

const COMPONENT_METADATA = Symbol("di.component.metadata");

const LIFETIMES = new Set([
  "singleton",
  "scoped",
  "transient",
]);

function component(
  {
    name,
    inject = {},
    lifetime = "transient",
  },
  classFactory,
) {
  if (!name) {
    throw new TypeError("Component name is required");
  }

  if (!LIFETIMES.has(lifetime)) {
    throw new TypeError(`Unsupported lifetime: ${lifetime}`);
  }

  const ComponentClass = classFactory();

  if (typeof ComponentClass !== "function") {
    throw new TypeError("classFactory must return a class");
  }

  Object.defineProperties(ComponentClass, {
    name: {
      value: name,
      configurable: true,
    },
    [COMPONENT_METADATA]: {
      value: Object.freeze({
        inject: Object.freeze({ ...inject }),
        lifetime,
      }),
      enumerable: false,
      configurable: false,
      writable: false,
    },
  });

  return ComponentClass;
}

Мы не добавляем ничего в глобальный Reflect и не заводим отдельный реестр метаданных. Описание хранится прямо на функции класса под закрытым Symbol. Снаружи его нельзя случайно получить по строковому имени, оно не появляется при обычном перечислении свойств, но контейнер может прочитать его в момент регистрации.

Имя класса задается явно. Для работы DI оно не требуется, потому что идентичность класса определяется самой функцией, но хорошее имя полезно в stack trace, сообщениях об ошибках и отладчике.

Для получения описания достаточно небольшой функции:

function getComponentMetadata(ComponentClass) {
  return ComponentClass[COMPONENT_METADATA] ?? null;
}

Токены: классы и Symbol

Если зависимость представлена конкретным классом, отдельный токен не нужен:

container.registerClass(Logger);
container.resolve(Logger);

Класс здесь одновременно является конструктором и ключом в Map. Это удобно для конкретных сервисов, которые не имеют нескольких реализаций.

Для портов лучше использовать Symbol:

const CART_REPOSITORY = Symbol("CartRepository");
const PRODUCT_CATALOG = Symbol("ProductCatalog");
const DISCOUNT_POLICY = Symbol("DiscountPolicy");

Application-слой зависит от таких токенов, а composition root связывает их с конкретными реализациями:

container.registerClass(InMemoryCartRepository, {
  token: CART_REPOSITORY,
});

Так доменная и прикладная логика не знают, используется ли память, PostgreSQL, Redis или внешний HTTP API.

Реализуем контейнер

Контейнер будет поддерживать три вида провайдеров:

  1. Готовое значение.

  2. Класс.

  3. Фабрику.

Также добавим три времени жизни:

  • singleton создает один экземпляр на корневой контейнер;

  • scoped создает один экземпляр внутри request scope;

  • transient создает новый экземпляр при каждом разрешении.

Основная часть контейнера выглядит так:

class Container {
  #providers = new Map();
  #singletons = new Map();

  registerValue(token, value) {
    this.#providers.set(token, {
      type: "value",
      value,
      lifetime: "singleton",
      inject: {},
    });

    return this;
  }

  registerClass(
    ComponentClass,
    {
      token = ComponentClass,
      inject,
      lifetime,
    } = {},
  ) {
    const metadata = getComponentMetadata(ComponentClass) ?? {
      inject: {},
      lifetime: "transient",
    };

    this.#providers.set(token, {
      type: "class",
      ComponentClass,
      inject: inject ?? metadata.inject,
      lifetime: lifetime ?? metadata.lifetime,
    });

    return this;
  }

  registerFactory(
    token,
    {
      inject = {},
      lifetime = "transient",
      factory,
    },
  ) {
    this.#providers.set(token, {
      type: "factory",
      inject,
      lifetime,
      factory,
    });

    return this;
  }
}

Самая важная часть находится в resolve(). Контейнер получает описание провайдера, рекурсивно разрешает зависимости, после чего создает экземпляр:

#resolve(token, scope, path) {
  if (scope?.hasOverride(token)) {
    return scope.getOverride(token);
  }

  const provider = this.#providers.get(token);

  if (!provider) {
    throw new Error(`Provider is not registered: ${String(token)}`);
  }

  if (
    provider.lifetime === "singleton" &&
    this.#singletons.has(token)
  ) {
    return this.#singletons.get(token);
  }

  if (provider.lifetime === "scoped") {
    if (!scope) {
      throw new Error("Scoped provider must be resolved from a scope");
    }

    if (scope.hasCached(token)) {
      return scope.getCached(token);
    }
  }

  if (path.includes(token)) {
    throw new Error("Circular dependency detected");
  }

  const nextPath = [...path, token];
  const dependencies = {};

  for (const [name, dependencyToken] of Object.entries(provider.inject)) {
    dependencies[name] = this.#resolve(
      dependencyToken,
      scope,
      nextPath,
    );
  }

  const instance = provider.type === "class"
    ? new provider.ComponentClass(dependencies)
    : provider.type === "factory"
      ? provider.factory(dependencies)
      : provider.value;

  if (provider.lifetime === "singleton") {
    this.#singletons.set(token, instance);
  }

  if (provider.lifetime === "scoped") {
    scope.setCached(token, instance);
  }

  return instance;
}

Здесь же проверяется циклическая зависимость. В полной версии из примера ошибка содержит всю цепочку токенов, например:

Circular dependency detected: CartService -> PricingService -> CartService

Почему зависимости передаются объектом

Контейнер мог бы хранить массив токенов и вызывать класс так:

new Service(repository, logger, clock);

Но объект с именованными зависимостями удобнее:

new Service({ repository, logger, clock });

Порядок полей перестает быть частью контракта. При добавлении новой зависимости не нужно синхронно менять позицию в массиве и позицию аргумента конструктора. Кроме того, описание компонента читается как небольшая схема:

inject: {
  carts: CART_REPOSITORY,
  catalog: PRODUCT_CATALOG,
  discounts: DISCOUNT_POLICY,
}

Контейнер при этом не выполняет property injection. Все зависимости по-прежнему приходят через конструктор, поэтому экземпляр нельзя получить в частично инициализированном состоянии.

Добавляем request scope

Для веб-приложения одного singleton и transient обычно недостаточно. Например, application service, unit of work или контекст текущего пользователя должны жить в пределах одного запроса.

Scope хранит собственный кеш и набор переопределений:

class Scope {
  #cache = new Map();

  constructor(container, overrides) {
    this.container = container;
    this.overrides = overrides;
  }

  resolve(token) {
    return this.container.resolveFromScope(token, this);
  }

  hasCached(token) {
    return this.#cache.has(token);
  }

  getCached(token) {
    return this.#cache.get(token);
  }

  setCached(token, value) {
    this.#cache.set(token, value);
  }
}

Контекст запроса не нужно регистрировать глобально. Его можно передать при создании scope:

const request = container.createScope([
  [REQUEST_CONTEXT, { id: "request-42" }],
]);

const service = request.resolve(CartApplicationService);

Любой scoped-компонент будет повторно использоваться внутри этого request scope, но другой запрос получит другой экземпляр.

Посмотрим на примере

Теперь перейдем к простому вполне прикладному примеру. В нем есть aggregate Cart, value object Money, application service, репозиторий, каталог товаров, скидочная политика и event bus. Если на этом моменте воозникают вопросы, то рекомендую глянуть DDD. DDD делят обычно на тактический и стратегический, сейчас нас интересует тактический.

Важно, что Cart и Money ничего не знают о component(), контейнере и lifetime. DI не должен проникать в каждую сущность проекта. Доменная модель создается обычным JavaScript-кодом и защищает свои инварианты самостоятельно.

Упрощенный value object денег:

class Money {
  constructor(cents, currency = "EUR") {
    if (!Number.isSafeInteger(cents)) {
      throw new TypeError("Money must be stored as integer cents");
    }

    if (cents < 0) {
      throw new Error("Money cannot be negative");
    }

    this.cents = cents;
    this.currency = currency;
    Object.freeze(this);
  }

  add(other) {
    if (this.currency !== other.currency) {
      throw new Error("Currency mismatch");
    }

    return new Money(this.cents + other.cents, this.currency);
  }

  multiply(quantity) {
    return new Money(this.cents * quantity, this.currency);
  }
}

Aggregate корзины отвечает за добавление товаров, изменение количества, применение купона и checkout. Он не позволяет добавлять товар после оформления и не разрешает оформить пустую корзину:

class Cart {
  #items = new Map();
  #events = [];

  addProduct({ sku, name, unitPrice, quantity = 1 }) {
    this.#assertOpen();
    this.#assertQuantity(quantity);

    const current = this.#items.get(sku);
    const nextQuantity = (current?.quantity ?? 0) + quantity;

    this.#assertQuantity(nextQuantity);

    this.#items.set(sku, {
      sku,
      name,
      unitPrice,
      quantity: nextQuantity,
    });
  }

  checkout({ discountPolicy, now }) {
    this.#assertOpen();

    if (this.#items.size === 0) {
      throw new Error("Cannot checkout an empty cart");
    }

    const subtotal = this.subtotal();
    const discount = discountPolicy.calculate({ cart: this, subtotal });
    const total = subtotal.subtract(discount);

    this.status = "checked-out";

    this.#events.push({
      type: "CartCheckedOut",
      occurredAt: now.toISOString(),
      payload: {
        cartId: this.id,
        total: total.toJSON(),
      },
    });

    return { subtotal, discount, total };
  }
}

Скидочная политика передается в доменную операцию явно. Это доменный сервис, который может иметь несколько реализаций. Сам aggregate не обращается к контейнеру и не ищет сервис через service locator.

Application service как компонент

Application service координирует сценарий. Он загружает aggregate, получает товар из каталога, вызывает доменную операцию и сохраняет новое состояние:

const CartApplicationService = component(
  {
    name: "CartApplicationService",
    lifetime: "scoped",
    inject: {
      carts: CART_REPOSITORY,
      catalog: PRODUCT_CATALOG,
      discounts: DISCOUNT_POLICY,
      events: EVENT_BUS,
      createId: ID_GENERATOR,
      clock: CLOCK,
      request: REQUEST_CONTEXT,
    },
  },
  () => class {
    constructor({
      carts,
      catalog,
      discounts,
      events,
      createId,
      clock,
      request,
    }) {
      this.carts = carts;
      this.catalog = catalog;
      this.discounts = discounts;
      this.events = events;
      this.createId = createId;
      this.clock = clock;
      this.request = request;
    }

    async addProduct(cartId, sku, quantity = 1) {
      const cart = await this.carts.getById(cartId);
      const product = await this.catalog.getBySku(sku);

      cart.addProduct({
        ...product,
        quantity,
      });

      await this.carts.save(cart);
      return cart.toView(this.discounts);
    }

    async checkout(cartId) {
      const cart = await this.carts.getById(cartId);
      const totals = cart.checkout({
        discountPolicy: this.discounts,
        now: this.clock(),
      });

      await this.carts.save(cart);
      await this.events.publishAll(cart.pullDomainEvents());

      return totals;
    }
  },
);

Scoped lifetime здесь выбран не потому, что сам сервис хранит важное состояние, а потому, что позднее в этот же scope можно добавить unit of work, transaction context или identity map. В небольшом примере сервис можно сделать transient, но request scope показывает, как контейнер будет вести себя в настоящем приложении.

Infrastructure-реализации

Репозиторий можно зарегистрировать под абстрактным токеном:

const InMemoryCartRepository = component(
  {
    name: "InMemoryCartRepository",
    lifetime: "singleton",
  },
  () => class {
    #storage = new Map();

    async getById(id) {
      const snapshot = this.#storage.get(id);
      return snapshot ? new Cart(snapshot) : null;
    }

    async save(cart) {
      this.#storage.set(cart.id, cart.toSnapshot());
    }
  },
);

In-memory реализация хранит snapshots, а не ссылки на aggregate. Это небольшая, но полезная деталь. Если репозиторий просто вернет тот же объект из Map, изменение aggregate произойдет еще до вызова save(), и тестовый репозиторий будет скрывать ошибки, которые появились бы при работе с настоящей базой данных.

Скидочная политика также является отдельным компонентом:

const CouponDiscountPolicy = component(
  {
    name: "CouponDiscountPolicy",
    lifetime: "singleton",
  },
  () => class {
    calculate({ cart, subtotal }) {
      if (cart.coupon === "HABR10") {
        return subtotal.percent(10);
      }

      return Money.zero(subtotal.currency);
    }
  },
);

Composition root

Все связи собираются в одном месте:

const container = new Container()
  .registerClass(InMemoryCartRepository, {
    token: CART_REPOSITORY,
  })
  .registerClass(InMemoryProductCatalog, {
    token: PRODUCT_CATALOG,
  })
  .registerClass(CouponDiscountPolicy, {
    token: DISCOUNT_POLICY,
  })
  .registerClass(InMemoryEventBus, {
    token: EVENT_BUS,
  })
  .registerValue(ID_GENERATOR, randomUUID)
  .registerValue(CLOCK, () => new Date())
  .registerClass(CartApplicationService);

Создание request scope выглядит так:

const request = container.createScope([
  [REQUEST_CONTEXT, { id: randomUUID() }],
]);

const carts = request.resolve(CartApplicationService);

После этого можно выполнить прикладной сценарий:

const created = await carts.createCart();
const cartId = created.cart.id;

await carts.addProduct(cartId, "coffee", 2);
await carts.addProduct(cartId, "mug", 1);
await carts.applyCoupon(cartId, "HABR10");

console.dir(await carts.checkout(cartId), {
  depth: null,
});

Для двух чашек кофе по 12,90 евро и одной кружки за 15,90 евро получится subtotal 41,70 евро. Купон HABR10 даст скидку 4,17 евро, итоговая сумма составит 37,53 евро.

Что в итоге

В результате получился небольшой DI-контейнер, который умеет регистрировать классы, значения и фабрики, поддерживает разные lifetime и при этом не требует TypeScript, декораторов или reflect-metadata.

Сам подход довольно простой. Вместо того чтобы пытаться угадать зависимости класса по его конструктору, мы указываем их в момент создания компонента. Класс при этом остается обычным классом: его можно создать через new, проверить через instanceof, увидеть нормальное имя в stack trace и протестировать отдельно от контейнера.

Конечно, это эксперимент, а не библиотека, которую стоит сразу тащить в продакшен. Здесь нет асинхронной инициализации, автоматического освобождения ресурсов, модулей, optional dependencies и еще десятка вещей, которые постепенно появляются в любом взрослом DI-контейнере. Но основная идея уже работает, а дальше все зависит от того, насколько далеко захочется ее развивать.

Мне в первую очередь было интересно проверить, можно ли сделать удобный DI на чистом JavaScript, не пытаясь превратить его в TypeScript без типов. Похоже, что можно. JavaScript позволяет динамически создавать классы, использовать их как токены и хранить описание зависимостей прямо на них. Этого уже достаточно, чтобы собрать вполне рабочий граф объектов.

При этом я бы не стал оборачивать в component() вообще каждый класс приложения. Cart, Money и другие объекты доменной модели отлично живут без контейнера. DI полезнее оставить на внешнем уровне, где приложение связывается с репозиториями, базами данных, очередями, HTTP-клиентами и другими инфраструктурными деталями.

Полный код примера лежит в репозитории:https://github.com/kubarskii/simple-di

Спасибо за внимание :)