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 один на контейнер перший запит Dispose() кореневого контейнера (зупинка додатку)
Scoped один на «область» (запит) початок scope кінець scope
Transient новий при кожному запиті кожен GetService<T>() IDisposable — разом зі scope, що його створив; інші — збирач сміття, коли зникнуть посилання

Singleton

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

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

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

Небезпеки:

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

Scoped

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

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

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

Небезпеки:

  • Scoped-сервіс, якого запитали з кореневого провайдера (поза scope), кидає InvalidOperationException — якщо увімкнено ValidateScopes (у Generic Host — у Development). Без перевірки контейнер мовчки створить його в кореневій області, і він фактично стане Singleton — помилку буде важко помітити
  • Якщо захопити scoped-сервіс у Singleton — він ніколи не буде знищений після запиту

Transient

Transient — новий екземпляр при кожному запиті. Якщо об'єкт не реалізує IDisposable, контейнер не зберігає на нього посилання — ним опікується збирач сміття. Якщо ж реалізує — контейнер запам'ятовує його у списку scope, що його створив, щоб викликати Dispose() при завершенні цього scope.

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

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

(Часто тут згадують ILogger<T>, але це не так: AddLogging() реєструє відкритий generic ILogger<> як Singleton — кожен тип T отримує свій логер, але один на весь контейнер; перевірено на .NET 10.)

Небезпеки:

  • Якщо Transient-сервіс реалізує IDisposable і його отримують з кореневого провайдера, контейнер тримає посилання на кожен такий екземпляр аж до власного Dispose() при зупинці додатку. Кожен запит додає новий об'єкт у список — це витік пам'яті й ресурсів. Disposable-transient слід брати зі scope (CreateScope()), тоді вони звільняються разом із ним

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

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

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

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

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

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

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

Singleton  → може залежати від: Singleton (+ Transient — із застереженням)
Scoped     → може залежати від: Singleton, Scoped, Transient
Transient  → може залежати від: Singleton, Scoped, Transient

Контейнер забороняє (при ValidateScopes) лише одну комбінацію — Singleton, що залежить від Scoped. Залежність Singleton від Transient дозволена, але transient-об'єкт буде створено один раз і він житиме стільки ж, скільки Singleton. Це нормально, якщо він без стану і потокобезпечний, і є прихованою помилкою, якщо розрахований на короткий час життя (наприклад, тримає з'єднання).

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Порівняння часів життя сервісів: Singleton, Scoped, Transient

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

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

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

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

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

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