Підрозділ 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, TransientIServiceScopeFactory: правильний вихід
Якщо 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 тільки тоді, коли це продиктовано конкретними вимогами до стану або продуктивності.