Підрозділ 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()
}
}

Підсумок: як обирати lifetime
Singleton — якщо сервіс без стану або з незмінним станом, потокобезпечний, дорогий для ініціалізації.
Scoped — якщо сервіс має стан, специфічний для одного запиту (транзакція, UoW, контекст бази даних). Стандартний вибір для Entity Framework DbContext.
Transient — якщо сервіс легкий, без стану і не потребує спільності між різними споживачами. Стандартний вибір для дрібних утилітарних класів та валідаторів.
Коли маєте сумніви — починайте з Transient. Це найбезпечніший вибір з точки зору ізоляції, хоча й найдорожчий з точки зору алокацій. Перейдіть на Singleton або Scoped тільки тоді, коли це продиктовано конкретними вимогами до стану або продуктивності.