OOP Course
Сьогодні

Підрозділ 21.3

Часи життя сервісів — Singleton, Scoped, Transient

21.3. Часи життя сервісів — Singleton, Scoped, Transient Вибір правильного часу життя сервісу ServiceLifetime — одне з найважливіших архітектурних рішень при роботі з DI контейнером. Неправильний вибір не завжд

21.3. Часи життя сервісів — Singleton, Scoped, Transient

Вибір правильного часу життя сервісу (ServiceLifetime) — одне з найважливіших архітектурних рішень при роботі з DI-контейнером. Неправильний вибір не завжди призводить до видимої помилки одразу, але може спричинити тонкі баги: загальний стан там, де його не повинно бути, витоки пам'яті, баги в багатопотоковому середовищі або ObjectDisposedException у найнеочікуванішому місці.

.NET DI підтримує три часи життя. Їхні назви коротко описують правило:

Lifetime Екземпляр Коли створюється Коли знищується
Singleton один на весь процес перший запит завершення додатку
Scoped один на «область» (запит) початок scope кінець scope
Transient новий при кожному запиті кожен GetService<T>() одразу після використання

Singleton

Singleton — контейнер створює рівно один екземпляр і повертає його при кожному запиті. Цей екземпляр живе весь час роботи додатку.

Коли використовувати:

  • Об'єкти, що є дорогими для ініціалізації і можна безпечно розділяти між потоками: кеш, пул з'єднань, клієнт HTTP, конфігурація
  • Сервіси без стану, що однаково поводяться при будь-якому виклику
  • Реєстрація «готового екземпляра»: services.AddSingleton<IConfiguration>(config)

Небезпеки:

  • Singleton повинен бути потокобезпечним (thread-safe), бо кілька потоків можуть одночасно звертатись до нього
  • Singleton не може залежати від Scoped-сервісів — captive dependency (про це далі)
  • Якщо Singleton зберігає змінний стан, може виникати «забруднення» між запитами

Scoped

Scoped — контейнер створює один екземпляр на «область» (scope). У контексті ASP.NET Core один scope = один HTTP-запит. У Worker Service scope створюється вручну.

Коли використовувати:

  • DbContext (Entity Framework) — класичний приклад: один контекст на запит гарантує, що всі операції в межах одного HTTP-запиту входять до одної транзакції
  • UnitOfWork — паттерн, що агрегує кілька репозиторіїв і спільний контекст
  • Будь-який сервіс, що повинен мати спільний стан у межах одного запиту, але незалежний від інших

Небезпеки:

  • Scoped-сервіс, якого запитали поза scope, кидає виняток (InvalidOperationException)
  • Якщо захопити scoped-сервіс у Singleton — він ніколи не буде знищений після запиту

Transient

Transient — новий екземпляр при кожному запиті. DI-контейнер не зберігає жодного посилання на створені об'єкти.

Коли використовувати:

  • Легкі сервіси без стану, що коштують мало для ініціалізації
  • Сервіси, де важлива ізоляція між різними частинами коду (наприклад, ланцюжки обробників подій)
  • ILogger<T> — кожен клас отримує свій власний логер з іменем типу

Небезпеки:

  • Якщо Transient-сервіс реалізує IDisposable, контейнер не викликає Dispose() автоматично для root-scope — це потенційний витік ресурсів

Детальна демонстрація всіх трьох

Captive Dependency — найпоширеніша помилка

Captive dependency — ситуація, коли сервіс з довшим часом життя тримає посилання на сервіс з коротшим часом життя, «захоплюючи» його і не даючи знищитися.

Класичний приклад:

Singleton(AppointmentService)
  └─ Scoped(ClinicUnitOfWork)   ← ПРОБЛЕМА!

AppointmentService живе весь час роботи додатку. Він отримав ClinicUnitOfWork при першому запиті. Але ClinicUnitOfWork мав жити тільки один запит — після його завершення він не знищується, бо Singleton тримає на нього посилання. При другому запиті AppointmentService продовжує використовувати старий ClinicUnitOfWork від першого запиту, замість нового. Якщо UnitOfWork зберігає відкрите з'єднання з базою даних або транзакцію — це буде або витік ресурсів, або баг зі спільним станом між запитами.

Правило: час життя залежності не може бути коротшим за час життя того, хто від неї залежить.

Singleton  → може залежати від: Singleton
Scoped     → може залежати від: Singleton, Scoped
Transient  → може залежати від: Singleton, Scoped, Transient

IServiceScopeFactory: правильний вихід

Якщо Singleton справді потребує scoped-сервісу (наприклад, BackgroundService обробляє один запис з черги і потребує DbContext), правильне рішення — ін'єктувати IServiceScopeFactory і самостійно керувати scope:

class BackgroundProcessor // Singleton (IHostedService)
{
    private readonly IServiceScopeFactory _scopeFactory;

    public BackgroundProcessor(IServiceScopeFactory scopeFactory)
        => _scopeFactory = scopeFactory;

    public async Task ProcessAsync()
    {
        // Новий scope для кожної одиниці роботи
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<ClinicDbContext>();
        // dbContext живе тільки в межах цього using-блоку
        // Після виходу scope.Dispose() → dbContext.Dispose()
    }
}

Порівняння часів життя сервісів: Singleton, Scoped, Transient

Підсумок: як обирати lifetime

Singleton — якщо сервіс без стану або з незмінним станом, потокобезпечний, дорогий для ініціалізації.

Scoped — якщо сервіс має стан, специфічний для одного запиту (транзакція, UoW, контекст бази даних). Стандартний вибір для Entity Framework DbContext.

Transient — якщо сервіс легкий, без стану і не потребує спільності між різними споживачами. Стандартний вибір для дрібних утилітарних класів та валідаторів.

Коли маєте сумніви — починайте з Transient. Це найбезпечніший вибір з точки зору ізоляції, хоча й найдорожчий з точки зору алокацій. Перейдіть на Singleton або Scoped тільки тоді, коли це продиктовано конкретними вимогами до стану або продуктивності.

Розроблено Tomka Yurii · © 2026 ·