Впровадження залежностей
Як ASP.NET Core створює об’єкти й передає їм залежності: навіщо потрібен контейнер, чим відрізняється час життя сервісів, звідки беруться captive dependency та витоки і коли вбудованого контейнера замало. Помилки з часом життя часто проявляються лише під навантаженням, тому про них люблять питати на співбесідах.
Перед цією темою: Мова C#.
Що таке впровадження залежностей і навіщо потрібен контейнер?
Section titled “Що таке впровадження залежностей і навіщо потрібен контейнер?”Що це
Впровадження залежностей — клас не створює свої залежності сам, а отримує їх параметрами конструктора. Залежність — будь-який об’єкт, яким клас користується: репозиторій, 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?
Section titled “Чим відрізняються 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 і як її виявити?
Section titled “Що таке 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 або фонової задачі?
Section titled “Як використати 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 не ієрархічні.
Як зареєструвати кілька реалізацій одного інтерфейсу й обрати потрібну?
Section titled “Як зареєструвати кілька реалізацій одного інтерфейсу й обрати потрібну?”Що це
Інтерфейс може мати кілька реалізацій: 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? — Зареєструвати внутрішню реалізацію під конкретним типом і зібрати декоратор фабрикою.
Хто звільняє сервіси, чим небезпечні фабрики й коли потрібен сторонній контейнер?
Section titled “Хто звільняє сервіси, чим небезпечні фабрики й коли потрібен сторонній контейнер?”Що це
Сервіс часто тримає ресурс: з’єднання, файл, сокет. Звільняти його — робота контейнера, а не вашого коду.
Як влаштовано
| Реєстрація | Коли контейнер викликає 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вбудованого провайдера.