Времена жизни сервисов

Времена жизни: Singleton, Scoped, Transient — и почему их путаница ломает приложения.

Суть: при регистрации сервиса вы задаёте его время жизни: Singleton (один на всё приложение), Scoped (один на HTTP-запрос), Transient (новый при каждом запросе зависимости). Неправильный выбор — частый источник трудноуловимых багов.

Контейнер должен решить: создавать ли объект заново или переиспользовать. Время жизни задаёт это правило. Понять три варианта критично — особенно потому, что с ними связана классическая ошибка captive dependency.

Три времени жизни

Время жизниМетодСколько живётДля чего
SingletonAddSingletonВсё приложениеКэш, конфиг, без состояния запроса
ScopedAddScopedОдин HTTP-запросDbContext, сервисы запроса
TransientAddTransientКаждое получениеЛёгкие, без состояния

Картина в рамках запросов

Запрос A:
  scope A открыт
   |-- Scoped: экземпляр S1 (один на весь запрос A)
   |-- Transient: T1, T2, T3 (новый на каждое внедрение)
  scope A закрыт -> Scoped S1 уничтожается

Запрос B:
  scope B -> Scoped: экземпляр S2 (другой!)

Singleton: один и тот же на A, B и все остальные запросы

Как работает под капотом

На каждый HTTP-запрос ASP.NET Core создаёт scope (область). Scoped-сервис кэшируется внутри этого scope: сколько бы раз его ни попросили в одном запросе — вернётся один и тот же объект. По завершении запроса scope уничтожается, а с ним и Scoped-объекты (включая Dispose). Singleton живёт в корневом контейнере и общий для всех. Transient создаётся каждый раз заново.

Главная ловушка — captive dependency: если Singleton зависит от Scoped, то Scoped-объект «застрянет» в Singleton навсегда, переживёт свой запрос и сломает логику (например, один DbContext на всё приложение — катастрофа). ASP.NET Core в Development специально проверяет это и бросает ошибку при старте.

# Имитация трёх времён жизни на счётчиках создания
created = {"singleton": 0, "scoped": 0, "transient": 0}
singleton_instance = None

def get_singleton():
    global singleton_instance
    if singleton_instance is None:
        created["singleton"] += 1
        singleton_instance = object()
    return singleton_instance

def handle_request(scoped_cache):
    # scoped: один на запрос
    if "svc" not in scoped_cache:
        created["scoped"] += 1
        scoped_cache["svc"] = object()
    s = scoped_cache["svc"]
    # transient: новый каждый раз
    created["transient"] += 1
    return s

for _ in range(2):           # два запроса
    cache = {}
    handle_request(cache)
    handle_request(cache)    # тот же scoped, но новый transient
    get_singleton()

print(created)  # singleton:1, scoped:2 (по запросу), transient:4

Попробуй сам ▶ — singleton создан 1 раз, scoped — по разу на запрос (2), transient — каждый вызов (4). Это и есть разница времён жизни.

Частые ошибки

  • DbContext как Singleton. Он Scoped по природе: не потокобезопасен и накапливает трекинг. Регистрируйте Scoped (AddDbContext уже делает так).
  • Singleton, зависящий от Scoped. Captive dependency — Scoped застрянет в Singleton.
  • Хранить состояние запроса в Singleton. Оно «протечёт» между пользователями.

Best practices

  • По умолчанию выбирайте Scoped для сервисов, работающих с данными запроса.
  • Singleton — только для потокобезопасных, не зависящих от запроса вещей (кэш, конфиг).
  • Доверяйте проверке контейнера в Development — она ловит captive dependency на старте.

Как scope связан с HTTP-запросом

Слово «scoped» обретает смысл, когда понимаешь, что такое scope в вебе. На каждый входящий HTTP-запрос ASP.NET Core открывает дочернюю область из корневого контейнера — это и есть scope. Все Scoped-сервисы, запрошенные в рамках обработки этого запроса, кэшируются в его пределах: сколько раз ни попроси — вернётся один экземпляр. Когда ответ отправлен, scope закрывается, и все созданные в нём Scoped-объекты освобождаются, включая вызов Dispose у тех, кто реализует IDisposable. Именно поэтому DbContext регистрируют как Scoped: один контекст на запрос, корректно освобождаемый в конце.

Singleton живёт в корневом контейнере и общий для всех запросов и потоков — отсюда требование потокобезопасности. Transient создаётся заново при каждом разрешении, даже несколько раз в пределах одного запроса. Понимание этих трёх режимов и того, что они привязаны к жизненному циклу запроса, — основа корректной работы с состоянием в веб-приложении.

Captive dependency и правило «не длиннее родителя»

Главная ловушка — нарушение правила «зависимость не должна жить дольше того, кто её держит». Если Singleton получает в конструктор Scoped-сервис, этот Scoped будет создан один раз и захвачен Singleton'ом навсегда — он переживёт свой запрос и станет фактически Singleton'ом. Для DbContext это катастрофа: один общий контекст на всё приложение не потокобезопасен, копит трекинг и приводит к трудноуловимым багам и утечкам. ASP.NET Core в Development специально валидирует scope при старте и бросает ошибку, если ловит такую конструкцию.

Практические рекомендации просты. Для сервисов, работающих с данными запроса (репозитории, сервисы поверх DbContext), берите Scoped. Для потокобезопасных, не зависящих от запроса вещей (кэш в памяти, чтение конфигурации) — Singleton. Transient — для лёгких сервисов без состояния. Если Singleton'у всё же нужен Scoped, не внедряйте его напрямую: внедрите IServiceScopeFactory и создавайте короткий scope вручную на время операции. Так вы соблюдаете правило времён жизни и избегаете захвата.

Итог: Singleton/Scoped/Transient задают, как часто пересоздаётся объект; их путаница (особенно captive dependency) — реальный источник багов. Дальше — конфигурация и логирование как сервисы.

Проверьте себя
1. Сколько живёт Scoped-сервис?
AВсё время работы приложения
BОдин на каждый HTTP-запрос: один и тот же экземпляр в пределах запроса
CНовый при каждом обращении
DОдну минуту
2. Что такое captive dependency?
AСервис без зависимостей
BКогда Singleton зависит от Scoped — Scoped застревает в Singleton и переживает свой запрос
CТранзитный сервис
DОшибка компиляции интерфейса