В этой статье я постарался собрать краткий гайд по Singleton, Transient и Scoped. Статья рассчитана на тех, кто хотя бы немного знаком с DI в .NET и уже умеет добавлять/запрашивать сервисы через startup/controller или иным образом.


Стандартный DI контейнер в .NET, представленный интерфейсом IServiceCollection имеет 3 способа регистрации зависимостей: Singleton, Transient и Scoped:

services.AddSingleton<ISingletonService, SingletonService>();
services.AddTransient<ITransientService, TransientService>();
services.AddScoped<IScopedService, ScopedService>();

Способ регистрации влияет на время жизни, момент создания, момент уничтожения, вызов Dispose, а так же накладывает некоторые ограничения при внедрении зависимостей между сервисами с разными жизненными циклами.


Singleton - создает один единственный экземпляр зависимости, который передается при каждой инъекции. Это аналогично созданию одного new SingletonService() и сохранению его в глобальную переменную. Может быть полезно для производительности, если нет необходимости каждый раз создавать новый экземпляр.

// singletonService1 и singletonService2 - это ссылки на ОДИН и тот же объект
var singletonService1 = serviceProvider.GetService<ISingletonService>();
var singletonService2 = serviceProvider.GetService<ISingletonService>();

Transient - создает новый экземпляр зависимости при каждой инъекции. То есть, при каждом запросе ITransientService, мы будем получать только что созданный new TransientService()

// transientService1 и transientService2 - это ссылки на РАЗНЫЕ объекты
var transientService1 = serviceProvider.GetService<ITransientService>();
var transientService2 = serviceProvider.GetService<ITransientService>();

Scoped - это что-то среднее между предыдущими двумя вариантами. Глобально - мы будем многократно создавать новый scope, но внутри каждого scope будет создан только 1 ScopedService.

// scopedService1 и scopedService2 - это ссылки на ОДИН и тот же объект, созданный внутри scope1
var scope1 = serviceProvider.CreateScope();
var scopedService1 = scope1.ServiceProvider.GetService<IScopedService>();
var scopedService2 = scope1.ServiceProvider.GetService<IScopedService>();

// НО scopedService3 и scopedService4 - это ссылки на другой объект, созданный внутри scope2
var scope2 = serviceProvider.CreateScope();
var scopedService3 = scope2.ServiceProvider.GetService<IScopedService>();
var scopedService4 = scope2.ServiceProvider.GetService<IScopedService>();

Это полезно, когда нужно выделить изолированные рабочие сессии. В ASP.NET Core с использованием контроллеров, при каждом http-запросе автоматически создается новый scope:

public class MyController : Controller
{
    // контроллеры, пришедшие из MVC, регистрируются как Scoped
    // при каждом http запросе они создаются в новом scope вместе с новыми экземплярами scoped зависимостей
    public MyController(IScopedService scopedService)
    {
    }
}

Ограничения Scoped

Все Scoped, а так же Transient сервисы со scoped зависимостями должны создаваться ТОЛЬКО внутри конкретного scope, иначе будет выброшено исключение:

Cannot resolve scoped service from root provider

// 🚫 если ISomeService является scoped или transient со scoped-зависимостю внутри - упадет ошибка
var someService = serviceProvider.GetService<ISomeService>();
        
// ✅ правильный вариант, если ISomeService является scoped или transient со scoped-зависимостю внутри
var scope = serviceProvider.CreateScope();
var someService = scope.ServiceProvider.GetService<ISomeService>();

Ограничения Singleton.

Внутрь Singleton нельзя инжектить сервисы Scoped и Transient (со Scoped зависимостями внутри), иначе будет ошибка:

Cannot consume scoped service ScopedService from singleton ISingletonService

Такая ошибка возникнет если SingletonService явно или транзитивно зависит от Scoped сервиса:

public class SingletonService : ISingletonService
{
    // 🚫 так нельзя: у Singleton в качестве зависимостей не могут быть Scoped сервисы
    public SingletonService(IScopedService scopedService, ITransientService transientService)
    {
    }
}

При этом, Transient сервисы без транзитивных Scoped зависимостей все еще допустимы

public class SingletonService : ISingletonService
{
    // ⚠️ так можно, но только до тех пор, пока ITransientService не имеет транзитивных Scoped зависимостей
    public SingletonService(ITransientService transientService)
    {
    }
}

Для лучшего контроля, в конструктор Singleton сервиса желательно передавать только другие синглтоны.

Так например, для использования Transient и Scoped сервисов внутри Singleton, можно передать в качестве зависимости IServiceProvider и вручную вызывать нужные сервисы через GetService/CreateScope:

public class SingletonService : ISingletonService
{
    private readonly IServiceProvider _serviceProvider;

    // ✅ IServiceProvider - это Singleton
    public SingletonService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task ExecuteWorkflow()
    {
        await using var scope = _serviceProvider.CreateAsyncScope();

        var scopedService = scope.ServiceProvider.GetService<IScopedService>();
        var transientService = scope.ServiceProvider.GetService<ITransientService>();

        // workflow...
    }
}

Краткая таблица ограничений:

Что инжектим --->

---> Куда инжектим

Singleton

Transient

Scoped

Singleton

✅ можно

✅ можно

✅ можно

Transient

⚠️ можно, но осторожно

✅ можно

✅ можно

Scoped

❌ ошибка

✅ можно

✅ можно

⚠️ - Transient не должен иметь транзитивных Scoped зависимостей чтобы инжектиться в Singleton


Контроль освобождения ресурсов через Dispose в DI

Некоторые сервисы могут реализовывать IDisposable (или IAsyncDisposable) и требовать явного освобождения ресурсов. В этом случае, для сервисов, созданных вне scope, требуется явно вызывать Dispose():

// если это многократно порождаемые IDisposable Transient сервисы, созданные "из корня"
var someService = serviceProvider.GetService<ISomeService>();
var otherService = serviceProvider.GetService<IOtherService>();

// Dispose необходимо вызывать явно
someService.Dispose();
otherService.Dispose();

Однако, если мы создаем Scope, мы можем вызвать Dispose() на нем, чтобы "задиспозить" сразу все зависимости, которые он породил:

// если сервисы создаются внутри Scope
var scope = serviceProvider.CreateScope();
var someService = scope.ServiceProvider.GetService<ISomeService>();
var otherService = scope.ServiceProvider.GetService<IOtherService>();
        
// Dispose скоупа таким образом так же вызовет Dispose() на всех IDisposable сервисах, которые были в нем созданы
scope.Dispose();

// 🚫 повторный вызов Dispose() в таком случае может привести к ошибке
someService.Dispose();
otherService.Dispose();

CreateAsyncScope

Для наглядности, почти во всех примерах выше, scope создавался следующим образом:

var scope = serviceProvider.CreateScope();

В современных версиях dotnet рекомендуется использовать новый метод CreateAsyncScope(), который возвращает расширенный AsyncScope, реализующий IAsyncDisposable вместо IDisposable, что улучшает работу с зависимостями, реализующими IAsyncDisposable:

await using var scope = serviceProvider.CreateAsyncScope();

Надеюсь, материал был хоть немного полезен :-)