Di Ioc

Microsoft DI: IServiceCollection та IServiceProvider

Глибокий розбір Microsoft.Extensions.DependencyInjection: IServiceCollection, IServiceProvider, реєстрація сервісів, Extension Methods, ValidateOnBuild, а також інтеграція з ASP.NET Core та Worker Services.

Microsoft DI: IServiceCollection та IServiceProvider

Вступ: Стандарт де-факто у .NET

У попередній статті ми побудували власний IoC-контейнер і зрозуміли механіку роботи. Тепер розглянемо офіційне рішення MicrosoftMicrosoft.Extensions.DependencyInjection. Це той самий контейнер, що використовується у:

  • ASP.NET Core
  • Worker Services
  • Console Apps з GenericHost
  • .NET MAUI
  • Blazor
  • Azure Functions

Пакет: Microsoft.Extensions.DependencyInjection (вбудований у .NET 6+).

Якщо ви вже читали статтю «Сучасний .NET Host», ви знайомі з базовим використанням DI. Ця стаття — глибокий розбір всіх деталей.

Частина 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. Фреймворк автоматично:

  1. Створює новий scope на початку запиту
  2. Resolve-ить всі Scoped-сервіси в цьому scope
  3. Знищує 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 знищується тут ←
        }
    }
}
Типова помилка: Inject-ити Scoped сервіс напряму у Singleton (включаючи BackgroundService). Це призводить до "Captive Dependency" — Scoped сервіс живе стільки ж, скільки Singleton, що порушує весь lifecycle. Детально — у наступній статті!

Підсумок

IServiceCollection

Простий список ServiceDescriptor. Реєструємо сервіси через Add*, TryAdd* методи. Організовуємо через Extension Methods для чистоти коду.

IServiceProvider

Resolve залежностей. GetRequiredService<T>() — основний метод. GetServices<T>() — для множинних реалізацій. ValidateOnBuild — виявляє помилки при старті.

Scope в ASP.NET Core

Автоматичний scope = HTTP-запит. Middleware InvokeAsync отримує Scoped через параметр. BackgroundService використовує IServiceScopeFactory.

📝 Завдання

Завдання 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>>:

  1. MinimumOrderAmountValidator — мінімальна сума 100 грн
  2. MaximumItemCountValidator — не більше 50 товарів
  3. ShippingAddressValidator — обов'язкові поля адреси

Реєстрація: services.AddTransient<IValidator<Order>, ...>() для кожного. OrderValidationService отримує всі через конструктор і виконує послідовно.

Завдання 3: Worker з Scope (Hard)

Реалізуйте BackgroundService DatabaseCleanupWorker що:

  • Запускається кожну годину
  • Видаляє замовлення старші 30 днів через IOrderRepository
  • Логує кількість видалених записів
  • Правильно використовує IServiceScopeFactory для отримання Scoped IOrderRepository
  • Коректно обробляє CancellationToken
Copyright © 2026