Внедрение зависимостей
Как ASP.NET Core создаёт объекты и передаёт им зависимости: зачем нужен контейнер, чем отличаются времена жизни сервисов, откуда берутся captive dependency и утечки и когда встроенного контейнера мало. Ошибки со временем жизни часто проявляются только под нагрузкой, поэтому их любят спрашивать на собеседованиях.
Перед этой темой: Язык C#.
Что такое внедрение зависимостей и зачем нужен контейнер?
Заголовок раздела «Что такое внедрение зависимостей и зачем нужен контейнер?»Что это
Внедрение зависимостей — класс не создаёт свои зависимости сам, а получает их параметрами конструктора. Зависимость — любой объект, которым класс пользуется: репозиторий, HTTP-клиент, логгер.
Зачем
- Реализацию можно заменить, не трогая класс:
SmtpEmailSenderнаFakeEmailSenderв тесте. - Код создания объектов собран в одном месте, а не размазан по приложению.
Как пользоваться
- Объявите зависимость параметром конструктора:
public OrderService(IEmailSender sender). - Зарегистрируйте реализацию в
builder.Services:AddScoped<IEmailSender, SmtpEmailSender>(). - Контейнер ASP.NET Core сам создаст
OrderServiceи передаст в негоSmtpEmailSender.
Частая ошибка
Создавать зависимость через new внутри класса. Тогда её не подменить в тесте, а контейнер не освободит её вовремя.
Как устроено
- DIP. Высокоуровневый
OrderServiceи низкоуровневыйSmtpEmailSenderзависят от абстракцииIEmailSender. Абстракцию определяет тот, кто ею пользуется. - Регистрация.
IServiceCollection— список регистраций: тип сервиса, реализация, время жизни.builder.Build()превращает его вIServiceProvider. - Разрешение. Контейнер выбирает публичный конструктор с наибольшим числом параметров, которые умеет создать, и рекурсивно создаёт каждый параметр. Если таких конструкторов несколько и выбор неоднозначен — исключение.
Когда что выбирать
| Способ получить зависимость | Когда |
|---|---|
| Параметр конструктора | почти всегда |
Параметр обработчика Minimal API, [FromServices] в action |
зависимость нужна одному эндпоинту |
IServiceProvider.GetService |
инфраструктурный код: фабрики, обёртки |
Ловушки
- Service Locator. Внедрённый
IServiceProviderиGetRequiredServiceв методах прячут зависимости. По конструктору не понять, что нужно классу, а забытая регистрация упадёт только при вызове метода. - Статический доступ.
app.Servicesв статическом поле — тот же локатор, только глобальный.
Правило
Зависимость — параметром конструктора, реализация — регистрацией в Program.cs. GetService в бизнес-коде — повод для рефакторинга.
Под капотом
- После
builder.Build()коллекция регистраций становится только для чтения: граф фиксируется при старте. - При первом запросе сервиса контейнер строит план его создания (call site) и кеширует его. Часто используемые планы он компилирует в делегаты, поэтому повторное разрешение дешёвое.
- Цикл A → B → A обнаруживается при построении плана:
InvalidOperationException: A circular dependency was detected.
Компромиссы
- Конструктор против локатора. Внедрение через конструктор делает зависимости явными и проверяемыми. Локатор гибче, но превращает ошибку старта в ошибку посреди запроса.
- Абстракции не бесплатны. Интерфейс для каждого класса с одной реализацией — шум. Интерфейс нужен там, где есть шов: внешняя система, подмена в тесте, несколько реализаций.
- Длинный конструктор. Десяток параметров — признак нарушения SRP. DI проблему не создаёт, а делает видимой.
- Не всё — сервис. Сущности, DTO и данные запроса создают через
new. В контейнере живут объекты с поведением.
Как проверить в проде
- Ошибка конфигурации видна в логе как
Unable to resolve service for type 'X' while attempting to activate 'Y'. - На ревью ищут
IServiceProviderв конструкторах бизнес-классов и статические ссылки на провайдер.
Итог: DI делает зависимости явными и собирает граф в одном месте; всё, что их прячет, возвращает проблемы, которые DI решал.
var builder = WebApplication.CreateBuilder(args);
// Регистрация: абстракция → реализацияbuilder.Services.AddScoped<IEmailSender, ConsoleEmailSender>();builder.Services.AddScoped<OrderService>();
var app = builder.Build();
// Контейнер создаёт OrderService и передаёт в него ConsoleEmailSenderapp.MapPost("/orders/{id:int}", (int id, OrderService orders) => orders.Place(id));
app.Run();
public interface IEmailSender { void Send(string text); }
public class ConsoleEmailSender : IEmailSender{ public void Send(string text) => Console.WriteLine(text);}
// Зависимость приходит через конструктор: класс не знает реализациюpublic class OrderService(IEmailSender sender){ public void Place(int id) => sender.Send($"Order {id} placed"); // Order 42 placed}
// Антипаттерн Service Locator: по конструктору не видно, что нужно классуpublic class BadOrderService(IServiceProvider services){ public void Place(int id) => services.GetRequiredService<IEmailSender>().Send($"Order {id}");}- Чем DI отличается от IoC? — IoC — общий принцип «управление у фреймворка», DI — один из способов его реализовать.
- Почему Service Locator считают антипаттерном? — Он прячет зависимости, переносит ошибки в рантайм и усложняет тесты.
- Какой конструктор выберет контейнер, если их несколько? — Публичный с наибольшим числом разрешимых параметров; при неоднозначности — исключение.
- Обязательны ли интерфейсы? — Нет, контейнер внедряет и конкретные классы; интерфейс нужен там, где нужна подмена.
- Есть ли во встроенном контейнере внедрение через свойства? — Нет, только через конструктор;
[Inject]в компонентах Blazor и параметры обработчиков Minimal API, action иInvokeAsyncобрабатывает сам фреймворк.
Чем отличаются Singleton, Scoped и Transient?
Заголовок раздела «Чем отличаются Singleton, Scoped и Transient?»Что это
| Время жизни | Когда создаётся новый объект |
|---|---|
AddTransient |
при каждом внедрении |
AddScoped |
один раз на scope — в веб-приложении на HTTP-запрос |
AddSingleton |
один раз на всё приложение |
Что такое scope
Scope — область, внутри которой scoped-сервис существует в одном экземпляре. ASP.NET Core открывает scope в начале запроса и закрывает в конце. Контроллер, сервис и репозиторий в одном запросе получают один и тот же DbContext.
Как пользоваться
- Лёгкий объект без состояния — Transient.
- Состояние на запрос:
DbContext, текущий пользователь — Scoped. - Дорогой в создании и общий: кеш в памяти — Singleton.
Частая ошибка
Хранить данные запроса в singleton, например в поле CurrentUser. Его увидят все параллельные запросы.
Как устроено
builder.Build()создаёт корневой провайдер. Singleton-сервисы хранятся в нём до остановки приложения.- На каждый запрос ASP.NET Core создаёт scope и кладёт его провайдер в
HttpContext.RequestServices. Из него создаются контроллеры, обработчики и их зависимости. - Scoped-сервис создаётся при первом обращении в этом scope и дальше переиспользуется. Transient создаётся при каждом разрешении.
- В конце запроса scope освобождается: контейнер вызывает
Disposeу созданных в нёмIDisposable-объектов.
Кто от кого может зависеть
| Сервис | Безопасные зависимости |
|---|---|
| Singleton | Singleton; Transient без состояния и ресурсов |
| Scoped | любые |
| Transient | любые |
Ловушки
- Scoped из корня.
app.Services.GetRequiredService<ScopedService>()вне scope фактически делает сервис singleton: он живёт до остановки. В Development это исключение. - Состояние в singleton. Его поля одновременно читают и пишут параллельные запросы — нужна синхронизация или неизменяемость.
Правило
Сервисы с доступом к данным — Scoped. Singleton — только для дорогого в создании или глобального состояния, и тогда он потокобезопасен. Transient — для лёгких объектов без состояния.
Под капотом
- Scope хранит созданные в нём scoped-экземпляры и список объектов, которые надо освободить. Scopes не иерархичны: новый scope ничего не наследует от текущего, общие у них только singleton из корня.
- Singleton создаётся лениво, при первом запросе. Его фабрику контейнер вызывает ровно один раз и одним потоком, даже при параллельных запросах.
- Границу scope задаёт хост: HTTP-запрос в MVC и Minimal API, circuit (подключение пользователя) в Blazor Server, ручной scope в фоновой задаче.
Цена
- Transient — аллокация и вызов конструктора на каждое разрешение, вместе со всеми transient-зависимостями. На горячем пути тяжёлый граф заметен.
- Singleton держит свой граф до остановки: всё, что он накопил, GC не вернёт.
- Тяжёлый конструктор scoped-сервиса оплачивается в каждом запросе.
Компромиссы
- Microsoft советует singleton только для сервисов с собственным дорогим или глобальным состоянием. Всё, что держит singleton, становится общим, включая случайно захваченные зависимости.
- Состояние «на пользователя» не хранят ни в одном времени жизни: для него есть сессия, кеш или база.
- Scoped в Blazor Server живёт на порядки дольше HTTP-запроса, поэтому
DbContextтам берут черезIDbContextFactory<T>.
Как проверить в проде
- Число экземпляров ваших сервисов в
dotnet-gcdumpрастёт при постоянном трафике — кто-то держит их в singleton или в корневом провайдере. - Скорость аллокаций в
dotnet-countersдо и после смены времени жизни показывает цену transient-графов.
Итог: время жизни — компромисс между общим состоянием, потокобезопасностью и аллокациями, а границу scope определяет хост.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<SingletonDep>();builder.Services.AddScoped<ScopedDep>();builder.Services.AddTransient<TransientDep>();builder.Services.AddTransient<Consumer>();
var app = builder.Build();
// Два потребителя в одном запросеapp.MapGet("/", (Consumer a, Consumer b) => new{ singleton = a.Singleton.Id == b.Singleton.Id, // true: один на приложение scoped = a.Scoped.Id == b.Scoped.Id, // true: один на запрос transient = a.Transient.Id == b.Transient.Id // false: новый при каждом внедрении});
app.Run();
public class Dep { public Guid Id { get; } = Guid.NewGuid(); }public class SingletonDep : Dep { }public class ScopedDep : Dep { }public class TransientDep : Dep { }
public record Consumer(SingletonDep Singleton, ScopedDep Scoped, TransientDep Transient);При следующем запросе Scoped.Id будет другим, а Singleton.Id — тем же.
- Что будет, если запросить scoped-сервис из
app.Services? — В Development исключение, иначе он проживёт до остановки приложения, как singleton. - Где хранится провайдер текущего запроса? — В
HttpContext.RequestServices. - Чем scope в Blazor Server отличается от scope в Web API? — Он живёт всё подключение пользователя (circuit), а не один запрос.
- Должен ли singleton быть потокобезопасным? — Да, его одновременно используют все запросы; контейнер гарантирует только однократное создание.
- Какое время жизни у
DbContextизAddDbContext? — Scoped.
Что такое captive dependency и как её поймать?
Заголовок раздела «Что такое captive dependency и как её поймать?»Что это
Captive dependency — сервис с длинным временем жизни держит в поле сервис с коротким. Классический случай: singleton получает в конструктор scoped-репозиторий с DbContext.
Почему это плохо
Конструктор singleton вызывается один раз, значит, и репозиторий внутри него один на всё приложение:
DbContextне потокобезопасен, и параллельные запросы ломают его состояние;- отслеживаемые им сущности копятся всё время работы приложения.
Как поймать
В Development ASP.NET Core сам проверяет регистрации при запуске и падает с ошибкой Cannot consume scoped service ... from singleton .... Эту проверку не отключают.
Частая ошибка
«Починить» исключение, сделав репозиторий singleton. Ошибка пропадёт из лога, а проблема останется.
Как устроено
| Опция | Что проверяет | Когда |
|---|---|---|
ValidateScopes |
scoped не разрешается из корня и не внедряется в singleton | при разрешении |
ValidateOnBuild |
каждую регистрацию можно создать | при builder.Build() |
Вместе они ловят scoped в singleton уже при старте. WebApplication.CreateBuilder включает обе в окружении Development, в остальных — через builder.Host.UseDefaultServiceProvider.
Ловушки
- Transient в singleton. Контейнер это не проверяет. Typed
HttpClient— transient: захваченный singleton, он перестаёт видеть смену DNS. - Фабрики. В
AddSingleton(sp => new X(sp.GetRequiredService<Repo>()))зависимость спрятана в лямбде, иValidateOnBuildеё не видит.ValidateScopesпоймает её только при первом созданииX. - Middleware. Обычный middleware создаётся один раз. Scoped-сервис берут параметром
InvokeAsync, а не конструктора. - Открытые generic.
AddScoped(typeof(IRepository<>), typeof(Repository<>))приBuild()не проверяется.
Правило
Зависимость живёт не меньше потребителя: singleton принимает только singleton. Scoped из singleton берут через свой scope.
Под капотом
- Для каждой регистрации контейнер строит дерево call site — план создания. С
ValidateScopesвалидатор ищет scoped-узлы в дереве singleton и запрещает разрешать scoped из корня. ValidateOnBuildстроит план для каждой закрытой регистрации приBuild()и собирает ошибки вAggregateException. Правило scoped/singleton он проверяет, только если включён иValidateScopes.- Фабрика в плане — непрозрачный узел: что она разрешит внутри, неизвестно до вызова.
Цена
- Захваченный
DbContextдаётInvalidOperationException: A second operation started on this context before a previous operation completed, устаревшие данные и рост памяти. - Захваченный typed
HttpClientдержит одинHttpMessageHandlerи не замечает смену DNS. - Ошибки плавают: они проявляются под параллельной нагрузкой, а не в тестах с одним запросом.
Компромиссы
ValidateOnBuildв Production удлиняет старт пропорционально числу регистраций. Дешевле интеграционный тест, который собирает хост с обеими проверками и падает в CI.- То, что контейнер не видит, закрывают соглашением: singleton принимает только singleton,
IServiceScopeFactoryили фабрики вродеIHttpClientFactoryиIDbContextFactory<T>.
Как проверить в проде
- Симптомы: ошибки параллельного доступа к
DbContextпод нагрузкой, данные одного пользователя в ответе другому, рост памяти. - В дампе (
dotnet-dump, командаgcroot) видно, чтоDbContextудерживает singleton-сервис из корневого провайдера.
Итог: валидация ловит только scoped в singleton и только в том, что видит; остальное закрывают тест с проверками и правило «singleton зависит от singleton».
var builder = WebApplication.CreateBuilder(args);
// Вне Development проверки выключены: включаем их во всех окруженияхbuilder.Host.UseDefaultServiceProvider(options =>{ options.ValidateScopes = true; options.ValidateOnBuild = true;});
builder.Services.AddScoped<CartRepository>();builder.Services.AddSingleton<PriceService>();
// AggregateException: ... Cannot consume scoped service 'CartRepository'// from singleton 'PriceService'.var app = builder.Build();app.Run();
public class CartRepository{ public decimal GetTotal() => 100m;}
// Captive dependency: singleton держит scoped-репозиторий вечно.// Исправление: сделать PriceService scoped или брать репозиторий из своего scope.public class PriceService(CartRepository repository){ public decimal Total() => repository.GetTotal();}- Поймает ли контейнер transient внутри singleton? — Нет, для него это законно; следить приходится самим.
- Почему в Development приложение падает, а в Production работает? —
ValidateScopesиValidateOnBuildпо умолчанию включены только в Development. - Как получить scoped-сервис в middleware? — Параметром
InvokeAsync: конструктор обычного middleware вызывается один раз. - Можно ли внедрить
IOptionsSnapshot<T>в singleton? — Нет, он scoped; в singleton используютIOptionsMonitor<T>. - Что не проверяет
ValidateOnBuild? — Открытые generic-регистрации и зависимости, которые фабрика разрешает внутри себя.
Как использовать scoped-сервис из singleton или фоновой задачи?
Заголовок раздела «Как использовать scoped-сервис из singleton или фоновой задачи?»Что это
Фоновая задача и singleton создаются один раз, вне HTTP-запроса. Scope запроса им недоступен, а внедрить scoped-сервис в конструктор нельзя: в Development это исключение.
Как пользоваться
- Внедрите в конструктор
IServiceScopeFactory— это singleton, его можно. - На каждую единицу работы создайте scope:
await using var scope = scopeFactory.CreateAsyncScope();. - Возьмите сервис из него:
scope.ServiceProvider.GetRequiredService<AppDbContext>(). - В конце блока scope освободится вместе с
DbContext.
Частая ошибка
Создать один scope перед бесконечным циклом. Тогда DbContext живёт всё время работы приложения, как singleton.
Как устроено
IServiceScopeFactoryзарегистрирован как singleton и создаёт scopes от корневого провайдера.scope.ServiceProvider— провайдер этого scope: scoped-сервисы в нём общие, transient создаются заново, singleton берутся из корня.DisposeилиDisposeAsyncу scope освобождает всё, что в нём создано.
Когда что выбирать
| Способ | Когда |
|---|---|
CreateAsyncScope() и await using (.NET 6+) |
по умолчанию |
CreateScope() и using |
синхронный код, все сервисы реализуют IDisposable |
IDbContextFactory<T> |
нужен только DbContext; контекст освобождаете сами |
Ловушки
- Синхронное освобождение. Если сервис реализует только
IAsyncDisposable,scope.Dispose()выброситInvalidOperationException. - Scope на весь цикл. Отслеживаемые сущности копятся, а ошибка одной итерации портит контекст для следующих.
- Сервисы запроса в фоне. Задача, которая захватила сервисы из
HttpContext.RequestServices, переживёт запрос и получитObjectDisposedException. В фон передают данные, а сервисы берут из нового scope. - Параллельные задачи. Один scope на несколько потоков — один
DbContextна всех. Каждой задаче — свой scope.
Правило
Один scope — одна единица работы: сообщение из очереди, итерация таймера, задание.
Под капотом
AddHostedServiceрегистрирует фоновую службу как singleton, поэтому её конструктор вызывается в корневом провайдере один раз.- Scope хранит созданные в нём объекты и при
DisposeAsyncосвобождает их в порядке, обратном созданию:DisposeAsync, если он есть, иначеDispose. - Scopes не иерархичны: scope фоновой задачи не видит scoped-сервисов запроса, даже если создан во время этого запроса.
Цена
Сам scope дёшев, платят за сервисы внутри него. Для DbContext цену создания снижает пул (AddDbContextPool).
Компромиссы
| Гранулярность scope | Плюс | Минус |
|---|---|---|
| На сообщение | изоляция ошибок, свежий контекст | сервисы создаются на каждое сообщение |
| На пачку | меньше накладных расходов, одна транзакция | ошибка откатывает всю пачку |
| На весь цикл | минимум кода | скрытый singleton, рост памяти |
IDbContextFactory<T> удобен в singleton и Blazor Server, но обходит scope: другие scoped-сервисы этот контекст не увидят.
Как проверить в проде
dotnet-counters monitor --counters Microsoft.EntityFrameworkCoreпоказывает число активныхDbContext. Если оно постоянно растёт, контексты не освобождаются.- Рост памяти фонового процесса при ровной нагрузке — повод проверить, где создаётся scope.
Итог: scope — граница единицы работы; его гранулярность определяет изоляцию ошибок, память и цену создания сервисов.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<OrderStore>(); // как DbContext из AddDbContextbuilder.Services.AddHostedService<OutboxWorker>(); // hosted service — singleton
var app = builder.Build();app.Run();
public class OutboxWorker(IServiceScopeFactory scopeFactory) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { // Новый scope на каждую итерацию: свежий OrderStore await using (var scope = scopeFactory.CreateAsyncScope()) { var store = scope.ServiceProvider.GetRequiredService<OrderStore>(); await store.SendPendingAsync(stoppingToken); } // здесь вызывается OrderStore.DisposeAsync()
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } }}
public sealed class OrderStore : IAsyncDisposable{ public Task SendPendingAsync(CancellationToken ct) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;}- Почему нельзя внедрить
DbContextв конструкторBackgroundService? — Фоновая служба — singleton, это captive dependency; в Development будет исключение. - Чем
CreateAsyncScopeлучшеCreateScope? — ОсвобождаетIAsyncDisposable-сервисы черезDisposeAsync; синхронныйDisposeна них выбрасывает исключение. - Как передать данные запроса в фоновую задачу? — Копией данных (id, DTO), а не сервисами из
RequestServices: они освободятся вместе с запросом. - Когда
IDbContextFactory<T>удобнее scope? — Когда нужен толькоDbContextили несколько единиц работы внутри одного scope, как в Blazor Server. - Наследует ли новый scope сервисы текущего запроса? — Нет, scopes не иерархичны.
Как зарегистрировать несколько реализаций одного интерфейса и выбрать нужную?
Заголовок раздела «Как зарегистрировать несколько реализаций одного интерфейса и выбрать нужную?»Что это
У интерфейса бывает несколько реализаций: EmailNotifier и SmsNotifier для INotifier. Обе регистрируются обычным AddScoped или AddSingleton.
Как пользоваться
- Все сразу. Внедрите
IEnumerable<INotifier>— придут все реализации в порядке регистрации. Так делают рассылку по всем каналам. - Последняя. Внедрите
INotifier— придёт последняя зарегистрированная. - По ключу (.NET 8+). Зарегистрируйте
AddKeyedScoped<INotifier, SmsNotifier>("sms")и внедрите[FromKeyedServices("sms")] INotifier sms.
Частая ошибка
Ожидать, что вторая регистрация заменит первую. Она добавится к списку, и в IEnumerable<T> окажутся обе.
Как устроено
IServiceCollection — список ServiceDescriptor: тип сервиса, реализация или фабрика, время жизни и, с .NET 8, ключ.
| Метод | Что делает |
|---|---|
Add{Lifetime} |
всегда добавляет регистрацию |
TryAdd{Lifetime} |
добавляет, если для сервиса ещё нет регистраций |
TryAddEnumerable |
добавляет, если нет такой же пары «сервис и реализация» |
Replace, RemoveAll |
заменяет или удаляет регистрации сервиса |
Когда что выбирать
- Обработчики, валидаторы, плагины —
IEnumerable<T>и регистрация черезTryAddEnumerable, чтобы повторный вызовAddFeature()не задвоил их. - Умолчание в библиотеке —
TryAdd: если приложение уже зарегистрировало свою реализацию, библиотека её не перекроет. - Стратегия по значению: провайдер оплаты, хранилище по региону — keyed services,
[FromKeyedServices(key)]илиGetRequiredKeyedService<T>(key). - Обёртка над реализацией: кеш, логирование, повторы — декоратор.
Ловушки
- Keyed-регистрации не попадают в обычный
IEnumerable<T>и не разрешаются без ключа. - Декоратор
AddScoped<IRepo>(sp => new CachedRepo(sp.GetRequiredService<IRepo>()))зациклится: фабрика разрешает сама себя. Внутреннюю реализацию регистрируют под конкретным типом. TryAddпослеAddтого же сервиса ничего не делает: порядок вызовов важен.
Правило
Равноправные реализации — IEnumerable<T>, выбор по значению — ключ или фабрика-стратегия, обёртка — декоратор.
Под капотом
- Для
Tконтейнер берёт последнюю регистрацию, дляIEnumerable<T>— все в порядке добавления. Время жизни у каждого элемента своё: в одномIEnumerable<T>бывают singleton и transient. - Ключ — любой объект с корректным
Equals.KeyedService.AnyKeyзадаёт запасную реализацию для любого ключа, а атрибут[ServiceKey]передаёт ключ в конструктор реализации. - С .NET 10
GetKeyedServiceсKeyedService.AnyKeyвыбрасываетInvalidOperationException:AnyKeyне годится для выбора одного сервиса.
Компромиссы
| Способ выбрать реализацию | Плюс | Минус |
|---|---|---|
| Keyed services | встроено, без своего кода | ключ не проверяется компилятором, выбор у потребителя |
| Фабрика-стратегия | выбор в одном месте, легко тестировать | свой код и регистрация |
IEnumerable<T> и свойство Name |
новая реализация без правки фабрики | перебор при каждом выборе |
Декоратор фабрикой явен, но конструкторы приходится вызывать руками. Scrutor (Decorate<TService, TDecorator>()) заменяет уже добавленную регистрацию, поэтому вызов должен идти после неё.
Как проверить в проде
- Какая реализация реально внедряется, фиксирует интеграционный тест: собрать хост и проверить тип
GetRequiredService<T>(). - Событие обработано дважды — признак
AddвместоTryAddEnumerableв методе расширения, который вызвали два раза.
Итог: выбор реализации — решение дизайна: перечисление, ключ или фабрика; декораторы во встроенном контейнере — только руками или через Scrutor.
using Microsoft.Extensions.DependencyInjection.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<INotifier, EmailNotifier>();builder.Services.AddSingleton<INotifier, SmsNotifier>();// Ничего не добавит: пара INotifier + EmailNotifier уже естьbuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<INotifier, EmailNotifier>());// Keyed services (.NET 8+): реализация по ключуbuilder.Services.AddKeyedSingleton<INotifier, SmsNotifier>("sms");builder.Services.AddSingleton<Dispatcher>();
var app = builder.Build();app.MapGet("/", (Dispatcher d) => d.Describe()); // "email,sms | sms | sms"app.Run();
public interface INotifier { string Name { get; } }public class EmailNotifier : INotifier { public string Name => "email"; }public class SmsNotifier : INotifier { public string Name => "sms"; }
public class Dispatcher( IEnumerable<INotifier> all, // email, sms — в порядке регистрации INotifier last, // sms — последняя регистрация [FromKeyedServices("sms")] INotifier byKey) // sms — по ключу{ public string Describe() => $"{string.Join(",", all.Select(n => n.Name))} | {last.Name} | {byKey.Name}";}var builder = WebApplication.CreateBuilder(args);
// Внутренняя реализация — под конкретным типом, интерфейс отдаём декораторуbuilder.Services.AddScoped<SqlOrderRepository>();builder.Services.AddScoped<IOrderRepository>(sp => new LoggingOrderRepository( sp.GetRequiredService<SqlOrderRepository>(), sp.GetRequiredService<ILogger<LoggingOrderRepository>>()));// Со Scrutor: AddScoped<IOrderRepository, SqlOrderRepository>(),// затем Decorate<IOrderRepository, LoggingOrderRepository>()
var app = builder.Build();app.MapGet("/orders/{id:int}", (int id, IOrderRepository repo) => repo.Find(id));app.Run();
public interface IOrderRepository { string Find(int id); }
public class SqlOrderRepository : IOrderRepository{ public string Find(int id) => $"order-{id}";}
public class LoggingOrderRepository(IOrderRepository inner, ILogger<LoggingOrderRepository> logger) : IOrderRepository{ public string Find(int id) { logger.LogInformation("Find {Id}", id); return inner.Find(id); }}- Что придёт в конструктор
INotifier, если зарегистрировано две реализации? — Последняя зарегистрированная. - Чем
TryAddотличается отTryAddEnumerable? — Первый проверяет только тип сервиса, второй — пару «сервис и реализация». - Попадают ли keyed-регистрации в
IEnumerable<T>? — Нет, только обычные; keyed запрашивают с ключом. - Как получить ключ внутри keyed-реализации? — Параметр конструктора с атрибутом
[ServiceKey]. - Как сделать декоратор без Scrutor? — Зарегистрировать внутреннюю реализацию под конкретным типом и собрать декоратор фабрикой.
Кто освобождает сервисы, чем опасны фабрики и когда нужен сторонний контейнер?
Заголовок раздела «Кто освобождает сервисы, чем опасны фабрики и когда нужен сторонний контейнер?»Что это
Сервис часто держит ресурс: соединение, файл, сокет. Освобождать его — работа контейнера, а не вашего кода.
Как устроено
| Регистрация | Когда контейнер вызывает Dispose |
|---|---|
| Transient, Scoped | в конце scope, в вебе — в конце запроса |
| Singleton | при остановке приложения |
AddSingleton(new X()) |
никогда: экземпляр создали вы |
Как пользоваться
- Не вызывайте
Disposeи не пишитеusingдля внедрённых сервисов. - Если ваш сервис держит ресурс, реализуйте
IDisposable— контейнер сам вызоветDispose.
Частая ошибка
Обернуть внедрённый DbContext в using. После выхода из блока другие сервисы этого запроса получат освобождённый контекст и ObjectDisposedException.
Как устроено
- Контейнер запоминает каждый созданный объект с
IDisposableилиIAsyncDisposableв списке своего scope. Остальные объекты не запоминаются. - При закрытии scope он освобождает их в порядке, обратном созданию: потребитель раньше своих зависимостей.
- Результат фабрики
sp => new X()тоже считается созданным контейнером и освобождается.
Ловушки
- Disposable transient из корня.
app.Services.GetRequiredService<Connection>()в цикле: каждый экземпляр остаётся в списке корня до остановки. - Фабрика возвращает чужой объект.
AddTransient<IFoo>(_ => sharedFoo)— контейнер освободитsharedFooв конце первого же scope. - Асинхронность в фабрике. Фабрики синхронны, и
GetAsync().Resultвнутри — это sync-over-async с риском дедлока. Асинхронную инициализацию выносят в отдельный метод или вIHostedService.
Когда что выбирать
| Нужно | Решение |
|---|---|
| Ресурс, который надо освободить раньше конца scope | своя фабрика с методом Create и using у вызывающего |
| Общий ресурс на ограниченное время | scoped-сервис и явный scope |
| Ресурс на всё приложение | singleton, созданный контейнером |
Правило
Не регистрируйте IDisposable как transient, если его будут разрешать вне короткого scope.
Чего нет во встроенном контейнере
Microsoft прямо перечисляет: внедрение через свойства, дочерние контейнеры, свои времена жизни, Func<T> для ленивого создания, регистрация по соглашениям. Добавим декораторы и перехватчики. Именованные регистрации закрыли keyed services в .NET 8.
Когда что выбирать
| Задача | Решение |
|---|---|
| Сканирование сборок, декораторы | Scrutor поверх встроенного контейнера |
Модули, Owned<T>, Func<T>, scope с тегами, перехватчики |
Autofac |
| Всё остальное | встроенный контейнер |
Компромиссы
- Autofac подключают через
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()). Регистрации изbuilder.Servicesон забирает, ноValidateScopesиValidateOnBuildбольше не действуют. - Документация Autofac прямо говорит, что он не всегда защищает от captive dependency.
- Сканирование сборок и перехватчики — рефлексия при старте и регистрации, которые не найти поиском по коду.
Как проверить в проде
- Утечку disposable transient видно в
dotnet-gcdump: число экземпляров сервиса растёт, а путь удержания ведёт к корневомуServiceProvider. - На ревью ищут
GetServiceуapp.Servicesвне scope и.Resultв фабриках регистраций.
Итог: освобождает тот, кто создал; сторонний контейнер берут ради конкретной возможности, помня, что проверки встроенного провайдера пропадут.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<Connection>();builder.Services.AddSingleton(new Cache()); // создан не контейнером: Dispose не вызоветbuilder.Services.AddSingleton(sp => new Pool()); // создан фабрикой: освободит при остановке
var app = builder.Build();
// Утечка: transient IDisposable из корня живёт до остановки приложенияfor (int i = 0; i < 1_000; i++) app.Services.GetRequiredService<Connection>();
// Правильно: scope освобождает всё, что в нём созданоawait using (var scope = app.Services.CreateAsyncScope()){ scope.ServiceProvider.GetRequiredService<Connection>();} // Connection.Dispose()
app.Run();
public sealed class Connection : IDisposable{ public void Dispose() => Console.WriteLine("Connection.Dispose()");}public sealed class Cache : IDisposable { public void Dispose() { } }public sealed class Pool : IDisposable { public void Dispose() { } }- Освободит ли контейнер объект из
AddSingleton(new X())? — Нет, его создали вы; объект из фабрикиAddSingleton(sp => new X())освободит. - В каком порядке освобождаются сервисы scope? — В обратном порядке создания.
- Почему disposable transient из корня — утечка? — Контейнер держит ссылку на каждый экземпляр до остановки приложения.
- Как создать объект с зависимостями из контейнера, но владеть им самому? —
ActivatorUtilities.CreateInstance<T>(provider)иusingу вызывающего. - Что теряется при переходе на Autofac? —
ValidateScopesиValidateOnBuildвстроенного провайдера.