Microsoft DI: IServiceCollection та IServiceProvider
Microsoft DI: IServiceCollection та IServiceProvider
Вступ: Стандарт де-факто у .NET
У попередній статті ми побудували власний IoC-контейнер і зрозуміли механіку роботи. Тепер розглянемо офіційне рішення Microsoft — Microsoft.Extensions.DependencyInjection. Це той самий контейнер, що використовується у:
- ASP.NET Core
- Worker Services
- Console Apps з GenericHost
- .NET MAUI
- Blazor
- Azure Functions
Пакет: Microsoft.Extensions.DependencyInjection (вбудований у .NET 6+).
Частина 1: IServiceCollection — колекція дескрипторів
1.1. Що таке IServiceCollection?
IServiceCollection — це звичайний список (IList<ServiceDescriptor>). Він не містить жодної логіки з resolve чи lifecycle. Він просто накопичує описи сервісів (ServiceDescriptor).
// IServiceCollection насправді дуже простий
public interface IServiceCollection : IList<ServiceDescriptor>, ICollection<ServiceDescriptor>,
IEnumerable<ServiceDescriptor>, IEnumerable
{
// Саме так: це просто List<ServiceDescriptor> з інтерфейсом!
}
// ServiceDescriptor — це та сама концепція що в нашому SimpleContainer
public class ServiceDescriptor
{
public Type ServiceType { get; }
public Type? ImplementationType { get; }
public Func<IServiceProvider, object>? ImplementationFactory { get; }
public object? ImplementationInstance { get; }
public ServiceLifetime Lifetime { get; }
// ...
}
1.2. Три методи реєстрації
Microsoft DI має три базових методи реєстрації (відповідно до Lifetime):
var services = new ServiceCollection();
// Transient: новий екземпляр кожного разу
services.AddTransient<IEmailService, SmtpEmailService>();
services.AddTransient<IEmailService>(sp =>
{
var config = sp.GetRequiredService<EmailConfig>();
return new SmtpEmailService(config.Host, config.Port);
});
// Scoped: один на scope (в ASP.NET Core = один на HTTP-запит)
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<AppDbContext>();
// Singleton: один на весь час роботи застосунку
services.AddSingleton<ILogger, ConsoleLogger>();
services.AddSingleton<SmtpConfig>(new SmtpConfig { Host = "smtp.gmail.com" });
// Singleton через готовий екземпляр
var config = new AppConfiguration { Theme = "dark" };
services.AddSingleton(config); // Тип визначається автоматично
1.3. Варіанти реєстрації
// Варіант 1: Interface → Implementation (найпоширеніший)
services.AddScoped<IOrderRepository, SqlOrderRepository>();
// Варіант 2: Concrete type тільки (без інтерфейсу)
services.AddScoped<OrderService>(); // Можна запросити тільки OrderService
// Варіант 3: Factory функція
services.AddScoped<IEmailService>(serviceProvider =>
{
// Маємо доступ до вже зареєстрованих сервісів!
var logger = serviceProvider.GetRequiredService<ILogger<SmtpEmailService>>();
var config = serviceProvider.GetRequiredService<IConfiguration>();
return new SmtpEmailService(config["Smtp:Host"]!, logger);
});
// Варіант 4: Готовий екземпляр (тільки Singleton)
services.AddSingleton<ILogger>(new ConsoleLogger(LogLevel.Debug));
// Варіант 5: Open Generic types
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
// Тепер IRepository<User>, IRepository<Order> і т.д. — всі автоматично вирішуються!
1.4. TryAdd методи — умовна реєстрація
// TryAdd: додає тільки якщо тип ще НЕ зареєстровано
services.AddScoped<IEmailService, SmtpEmailService>();
services.TryAddScoped<IEmailService, FakeEmailService>(); // ← НЕ замінить! Вже є.
// TryAddEnumerable: додає тільки якщо такий ServiceDescriptor ще немає
services.TryAddEnumerable(ServiceDescriptor.Scoped<IEventHandler, OrderCreatedHandler>());
services.TryAddEnumerable(ServiceDescriptor.Scoped<IEventHandler, OrderCreatedHandler>()); // ← Dup! Не додасть.
// Практичний приклад: фреймворкова бібліотека не перезаписує реєстрації користувача
public static IServiceCollection AddMyFrameworkServices(this IServiceCollection services)
{
services.TryAddScoped<IEmailService, DefaultEmailService>(); // Default
services.TryAddSingleton<ICacheService, MemoryCacheService>(); // Default
return services;
}
// Користувач може перевизначити до або після:
services.AddMyFrameworkServices();
services.AddScoped<IEmailService, CustomEmailService>(); // ← Перезапише!
// АБО:
services.AddScoped<IEmailService, CustomEmailService>(); // Спочатку
services.AddMyFrameworkServices(); // TryAdd не перезапише!
Частина 2: Extension Methods — розширення реєстрації
Кращою практикою є організація реєстрацій через Extension Methods. Це дозволяє групувати пов'язані реєстрації та покращує читабельність:
// OrderingModule.cs — реєстрації для модуля замовлень
public static class OrderingServiceExtensions
{
public static IServiceCollection AddOrderingModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Конфігурація
services.Configure<OrderingOptions>(configuration.GetSection("Ordering"));
// Repositories
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<IOrderItemRepository, SqlOrderItemRepository>();
// Domain Services
services.AddScoped<IOrderPricingService, OrderPricingService>();
services.AddScoped<IOrderValidationService, OrderValidationService>();
// Application Services
services.AddScoped<OrderService>();
services.AddScoped<OrderCommandHandler>();
// Infrastructure
services.AddScoped<IOrderEventPublisher, RabbitMqOrderEventPublisher>();
return services; // Fluent API
}
}
// EmailModule.cs
public static class EmailServiceExtensions
{
public static IServiceCollection AddEmailModule(
this IServiceCollection services,
IConfiguration configuration)
{
var smtpConfig = configuration.GetSection("Smtp").Get<SmtpConfig>()!;
services.AddSingleton(smtpConfig);
services.AddTransient<IEmailService, SmtpEmailService>();
services.AddTransient<IEmailTemplateEngine, RazorEmailTemplateEngine>();
return services;
}
}
// Program.cs — чистий і читабельний!
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOrderingModule(builder.Configuration)
.AddEmailModule(builder.Configuration)
.AddInventoryModule(builder.Configuration)
.AddAuthenticationModule(builder.Configuration);
var app = builder.Build();
Частина 3: IServiceProvider — провайдер сервісів
3.1. Отримання сервісів
IServiceProvider — це результат BuildServiceProvider(). Він вирішує (resolve) залежності:
var services = new ServiceCollection();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<IEmailService, SmtpEmailService>();
services.AddScoped<OrderService>();
// Будуємо провайдер
IServiceProvider provider = services.BuildServiceProvider();
// GetService<T>: повертає null якщо не знайдено
var emailService = provider.GetService<IEmailService>(); // IEmailService? — може бути null
// GetRequiredService<T>: кидає exception якщо не знайдено — РЕКОМЕНДОВАНИЙ!
var orderService = provider.GetRequiredService<OrderService>();
// GetServices<T>: повертає ВСІ реалізації
services.AddTransient<IEventHandler, EmailEventHandler>();
services.AddTransient<IEventHandler, SmsEventHandler>();
services.AddTransient<IEventHandler, PushEventHandler>();
IEnumerable<IEventHandler> handlers = provider.GetServices<IEventHandler>();
// = [ EmailEventHandler, SmsEventHandler, PushEventHandler ]
3.2. GetServices — множинні реалізації
Це потужна можливість: один інтерфейс — кілька реалізацій:
// Реєстрація кількох реалізацій одного типу
services.AddTransient<IValidator<Order>, OrderAmountValidator>();
services.AddTransient<IValidator<Order>, OrderItemCountValidator>();
services.AddTransient<IValidator<Order>, ShippingAddressValidator>();
// Сервіс, що використовує всі валідатори
public class OrderValidationService
{
// IEnumerable<IValidator<Order>> — отримуємо ВСІ валідатори!
private readonly IEnumerable<IValidator<Order>> _validators;
public OrderValidationService(IEnumerable<IValidator<Order>> validators)
{
_validators = validators;
}
public ValidationResult Validate(Order order)
{
var errors = _validators
.SelectMany(v => v.Validate(order).Errors)
.ToList();
return new ValidationResult(errors);
}
}
3.3. ValidateOnBuild — виявлення помилок при старті
Одна з найважливіших можливостей Microsoft DI — валідація графу залежностей при старті застосунку:
// Без валідації: помилки виявляються в runtime при першому зверненні
var provider = services.BuildServiceProvider();
// З валідацією: помилки виявляються ВІДРАЗУ при старті
var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateOnBuild = true, // Перевірити граф при побудові
ValidateScopes = true, // Перевірити Captive Dependency (про це в наступній статті!)
});
// У ASP.NET Core Development режим — це вмикається автоматично!
// WebApplication.CreateBuilder вже налаштовує ValidateOnBuild та ValidateScopes
// у режимі Development.
Що перевіряє ValidateOnBuild?
services.AddScoped<OrderService>();
// OrderService залежить від IOrderRepository, але ми ЗАБУЛИ зареєструвати його!
var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateOnBuild = true
});
// BOOM! При BuildServiceProvider():
// InvalidOperationException: "Cannot resolve service 'IOrderRepository'
// while attempting to activate 'OrderService'."
// ← Виявлено на старті, а не в runtime!
Частина 4: Робота в ASP.NET Core
4.1. WebApplication.CreateBuilder та WebApplicationBuilder
// Program.cs в ASP.NET Core
var builder = WebApplication.CreateBuilder(args);
// builder.Services — це IServiceCollection
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Наші сервіси
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();
// builder.Configuration — IConfiguration (автоматично завантажено з appsettings.json)
var connectionString = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// builder.Build() — фіналізація, будує IServiceProvider
var app = builder.Build();
// app.Services — це IServiceProvider (root provider)
// НЕ використовуйте його для Scoped сервісів напряму!
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
4.2. Scope у ASP.NET Core
В ASP.NET Core кожен HTTP-запит = окремий Scope. Фреймворк автоматично:
- Створює новий scope на початку запиту
- Resolve-ить всі Scoped-сервіси в цьому scope
- Знищує scope після завершення запиту (викликає
Dispose()у зворотному порядку)
// Controller отримує сервіси через DI автоматично!
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
private readonly ILogger<OrdersController> _logger;
// Constructor Injection — ASP.NET Core викличе це автоматично!
public OrdersController(OrderService orderService, ILogger<OrdersController> logger)
{
_orderService = orderService;
_logger = logger;
}
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
_logger.LogInformation("Creating order for {Email}", request.CustomerEmail);
var orderId = _orderService.PlaceOrder(request);
return Ok(new { orderId });
}
}
// Кожен запит до POST /api/orders:
// 1. Новий scope
// 2. Новий OrdersController
// 3. Новий OrderService (якщо Scoped)
// 4. Новий IOrderRepository (якщо Scoped)
// 5. ... після відповіді: Dispose() у зворотньому порядку
4.3. Middleware та DI
// Middleware з Constructor Injection (для Singleton залежностей)
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger; // Singleton OK в конструкторі
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
// Scoped залежності — через параметр методу InvokeAsync!
public async Task InvokeAsync(HttpContext context, ICurrentUserService currentUser)
{
_logger.LogInformation("Request: {Method} {Path} by {User}",
context.Request.Method,
context.Request.Path,
currentUser.UserId);
await _next(context);
}
}
// Реєстрація
app.UseMiddleware<RequestLoggingMiddleware>();
Частина 5: Worker Services та Generic Host
// Worker Service: DI без ASP.NET Core
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
// Конфігурація прочитана автоматично з appsettings.json
services.Configure<BackgroundJobOptions>(
context.Configuration.GetSection("BackgroundJob"));
// Реєстрація наших сервісів
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddTransient<IEmailService, SmtpEmailService>();
// BackgroundService — Hosted Service, що запускається автоматично
services.AddHostedService<OrderProcessingWorker>();
})
.Build();
await host.RunAsync();
// OrderProcessingWorker.cs
public class OrderProcessingWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory; // НЕ IServiceProvider!
private readonly ILogger<OrderProcessingWorker> _logger;
// ВАЖЛИВО: BackgroundService — це Singleton!
// Scoped сервіси НЕ можна inject-ити напряму — тільки через IServiceScopeFactory
public OrderProcessingWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessingWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Кожна ітерація — новий scope!
using var scope = _scopeFactory.CreateScope();
// Отримуємо Scoped сервіси із scope
var repository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
var pendingOrders = repository.GetPending();
foreach (var order in pendingOrders)
{
emailService.Send(order.CustomerEmail, "Ваше замовлення обробляється!");
_logger.LogInformation("Processed order {Id}", order.Id);
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
// Scope знищується тут ←
}
}
}
Підсумок
IServiceCollection
Add*, TryAdd* методи. Організовуємо через Extension Methods для чистоти коду.IServiceProvider
GetRequiredService<T>() — основний метод. GetServices<T>() — для множинних реалізацій. ValidateOnBuild — виявляє помилки при старті.Scope в ASP.NET Core
📝 Завдання
Завдання 1: Організація реєстрацій (Easy)
Перепишіть такий код Program.cs використовуючи Extension Methods:
// До рефакторингу:
builder.Services.AddScoped<IUserRepository, SqlUserRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ILogger, ConsoleLogger>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
Розбийте на AddUserModule(), AddOrderModule(), AddInfrastructure().
Завдання 2: Конвеєр валідаторів (Medium)
Реалізуйте систему валідації замовлення через IEnumerable<IValidator<Order>>:
MinimumOrderAmountValidator— мінімальна сума 100 грнMaximumItemCountValidator— не більше 50 товарівShippingAddressValidator— обов'язкові поля адреси
Реєстрація: services.AddTransient<IValidator<Order>, ...>() для кожного. OrderValidationService отримує всі через конструктор і виконує послідовно.
Завдання 3: Worker з Scope (Hard)
Реалізуйте BackgroundService DatabaseCleanupWorker що:
- Запускається кожну годину
- Видаляє замовлення старші 30 днів через
IOrderRepository - Логує кількість видалених записів
- Правильно використовує
IServiceScopeFactoryдля отримання ScopedIOrderRepository - Коректно обробляє
CancellationToken
Паттерни Dependency Injection
Три способи впровадження залежностей у C#: Constructor, Property та Method Injection. Глибокий розбір кожного підходу, аналогії з реального світу, порівняння, паттерни Factory та Decorator у контексті DI.
Service Lifetimes та Scopes
Глибокий розбір трьох часів життя сервісів у DI: Transient, Scoped та Singleton. Як розуміти Scope, IDisposable і прибирання за сервісами. Критичний анти-паттерн Captive Dependency.