Di Ioc

DI Анти-паттерни та Найкращі Практики

Найпоширеніші помилки при використанні Dependency Injection у C# та .NET. Анти-паттерни Service Locator, Captive Dependency, Constructor Over-injection, Static Coupling та рекомендації щодо чистої архітектури DI.

DI Анти-паттерни та Найкращі Практики

Вступ: Чому DI може стати проблемою?

DI — потужний інструмент. Але як і будь-який інструмент, його можна використовувати неправильно. Дивно, що помилки з DI часто роблять досвідчені розробники, особливо при роботі з незнайомим кодом або при перенесенні legacy-рішень.

Ця стаття — збірник найпоширеніших анти-паттернів, кожен з яких пояснений: чому це погано, і як це виправити.


Анти-паттерн 1: Service Locator у бізнес-логіці

Проблема: Впровадження IServiceProvider у бізнес-сервіси та виклик GetRequiredService<T>() всередині методів.

// ❌ Анти-паттерн
public class OrderService
{
    private readonly IServiceProvider _sp;

    public OrderService(IServiceProvider sp)
    {
        _sp = sp;
    }

    public void PlaceOrder(Order order)
    {
        // Service Locator у бізнес-логіці!
        var repo = _sp.GetRequiredService<IOrderRepository>();
        var email = _sp.GetRequiredService<IEmailService>();
        repo.Save(order);
        email.Send(order.CustomerEmail, "...");
    }
}

Чому погано:

  • Приховані залежності (конструктор не відображає реальних потреб)
  • Важко тестувати (треба мокати IServiceProvider)
  • Порушення SOLID
// ✅ Правильно: Constructor Injection
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly IEmailService _email;

    public OrderService(IOrderRepository repo, IEmailService email)
    {
        _repo = repo;
        _email = email;
    }

    public void PlaceOrder(Order order)
    {
        _repo.Save(order);
        _email.Send(order.CustomerEmail, "...");
    }
}

Анти-паттерн 2: Captive Dependency

Проблема: Singleton тримає посилання на Scoped або Transient сервіс.

// ❌ Анти-паттерн: Singleton захоплює Scoped
services.AddSingleton<BackgroundJobService>();
services.AddScoped<IJobRepository, SqlJobRepository>();

public class BackgroundJobService // Singleton
{
    private readonly IJobRepository _repo; // Scoped — CAPTIVE!

    public BackgroundJobService(IJobRepository repo)
    {
        _repo = repo;  // Захоплено назавжди з першого scope
    }
}

Чому погано:

  • Scoped сервіс живе назавжди замість одного scope
  • DbContext закривається але посилання лишається → ObjectDisposedException
  • Дані "протікають" між HTTP-запитами
// ✅ Правильно: IServiceScopeFactory для Scoped залежностей у Singleton
public class BackgroundJobService // Singleton
{
    private readonly IServiceScopeFactory _scopeFactory;

    public BackgroundJobService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public void ProcessJobs()
    {
        using var scope = _scopeFactory.CreateScope();
        var repo = scope.ServiceProvider.GetRequiredService<IJobRepository>();
        var jobs = repo.GetPending();
        // ...
    } // ← repo та DbContext знищуються тут
}
Увімкніть ValidateScopes = true у BuildServiceProvider. У режимі Development в ASP.NET Core — це автоматично. Не вимикайте це!

Анти-паттерн 3: Constructor Over-injection

Проблема: Конструктор з 6+ параметрами — ознака порушення SRP.

// ❌ Анти-паттерн: занадто багато залежностей
public class OrderService
{
    public OrderService(
        IOrderRepository orderRepo,
        IProductRepository productRepo,
        IUserRepository userRepo,
        IEmailService emailService,
        ISmsService smsService,
        IPushService pushService,
        IPaymentGateway payment,
        IInventoryService inventory,
        IShippingService shipping,
        IAuditService audit,
        ILogger<OrderService> logger)
    { }
}

Чому погано:

  • Клас має занадто багато відповідальностей (порушення SRP)
  • Важко тестувати (потрібно 11 моків!)
  • Ознака "God Class"
// ✅ Правильно: виділяємо поддомени у окремі сервіси

// Сервіс сповіщень
public class NotificationService
{
    public NotificationService(IEmailService email, ISmsService sms, IPushService push) { }
    public void NotifyCustomer(Order order) { /* ... */ }
}

// Сервіс обробки оплати і доставки
public class FulfillmentService
{
    public FulfillmentService(IPaymentGateway payment, IInventoryService inventory, IShippingService shipping) { }
    public bool Fulfill(Order order) { /* ... */ }
}

// Тепер OrderService — тільки оркестрація
public class OrderService
{
    public OrderService(
        IOrderRepository orderRepo,
        NotificationService notifications,
        FulfillmentService fulfillment,
        IAuditService audit,
        ILogger<OrderService> logger)
    { }
}

Анти-паттерн 4: Static Coupling та new у сервісі

Проблема: Прямий виклик new для залежностей або використання статичних методів.

// ❌ Анти-паттерн: new у сервісі
public class OrderService
{
    private readonly IOrderRepository _repo;

    public OrderService(IOrderRepository repo)
    {
        _repo = repo;
    }

    public void PlaceOrder(Order order)
    {
        // Hard-coded new — порушує DI!
        var emailService = new SmtpEmailService("smtp.gmail.com", 587);         // Статичний виклик — те саме!
        var logger = LoggerFactory.GetLogger<OrderService>(); 
        emailService.Send(order.CustomerEmail, "...");
    }
}

// ✅ Правильно: всі залежності через конструктор
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly IEmailService _email;     private readonly ILogger<OrderService> _logger; 
    public OrderService(         IOrderRepository repo,         IEmailService email,         ILogger<OrderService> logger)     {         _repo = repo;         _email = email;         _logger = logger;     } 
    public void PlaceOrder(Order order)
    {
        _email.Send(order.CustomerEmail, "...");
        _logger.LogInformation("Order placed: {Id}", order.Id);
    }
}

Анти-паттерн 5: Ambient Context (глобальні статичні залежності)

Проблема: Використання static properties або глобальних синглтонів для доступу до "контексту".

// ❌ Анти-паттерн: глобальний статичний контекст
public static class CurrentUser
{
    public static string? UserId { get; set; } // Глобальний стан!
    public static string? Email { get; set; }
}

// Використання — здається зручним, але...
public class OrderService
{
    public void PlaceOrder(Order order)
    {
        var userId = CurrentUser.UserId; // Звідки це значення в тесті?!
        // ...
    }
}

// ✅ Правильно: ICurrentUserService через DI
public interface ICurrentUserService
{
    string? UserId { get; }
    string? Email { get; }
}

public class HttpContextCurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public HttpContextCurrentUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public string? UserId =>
        _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;

    public string? Email =>
        _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.Email)?.Value;
}

// Реєстрація
services.AddHttpContextAccessor();
services.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();

// У тестах — легко мокати!
var mockUser = new Mock<ICurrentUserService>();
mockUser.Setup(u => u.UserId).Returns("test-user-id");

Анти-паттерн 6: Реєстрація всього як Singleton

Проблема: Всі сервіси реєструються як Singleton через "продуктивність".

// ❌ Анти-паттерн: всі Singleton
services.AddSingleton<IOrderRepository, SqlOrderRepository>(); // DbContext — НЕ thread-safe!
services.AddSingleton<AppDbContext>(); // ДУЖЕ ПОГАНО!
services.AddSingleton<IShoppingCart, UserShoppingCart>(); // Спільний для всіх!

Наслідки:

  • AppDbContext як Singleton → спільний для всіх запитів → race conditions → corrupted data
  • IShoppingCart як Singleton → ОДИН кошик для ВСІХ користувачів → дані замовника A у замовника B!
// ✅ Правильно: правильний lifetime для кожного сервісу
services.AddDbContext<AppDbContext>(...); // Автоматично Scoped
services.AddScoped<IOrderRepository, SqlOrderRepository>(); // Scoped (має DbContext)
services.AddScoped<IShoppingCart, UserShoppingCart>(); // Scoped (ізольований до запиту)
services.AddSingleton<ICacheService, MemoryCacheService>(); // Singleton (thread-safe, shared cache)

Анти-паттерн 7: Ін'єкція IConfiguration напряму

Проблема: Inject IConfiguration і читати значення configuration["Key"] у бізнес-класах.

// ❌ Анти-паттерн
public class EmailService
{
    private readonly IConfiguration _config; // Пряма залежність від IConfiguration!

    public EmailService(IConfiguration configuration)
    {
        _config = configuration;
    }

    public void Send(string to, string body)
    {
        var host = _config["Smtp:Host"]!; // Magic strings!
        var port = int.Parse(_config["Smtp:Port"]!); // Може кинути FormatException!
        // ...
    }
}

Чому погано:

  • "Magic strings" — помилки тільки в runtime
  • Важко тестувати (треба mock IConfiguration)
  • Клас знає про всю конфігурацію, а не тільки про свою частину
// ✅ Правильно: Options Pattern через IOptions<T>
public class SmtpOptions
{
    public string Host { get; set; } = string.Empty;
    public int Port { get; set; } = 587;
    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}

// У Program.cs:
services.Configure<SmtpOptions>(configuration.GetSection("Smtp"));

// У сервісі:
public class EmailService
{
    private readonly SmtpOptions _options;

    // IOptions<T> або IOptionsSnapshot<T> — строго типізований доступ!
    public EmailService(IOptions<SmtpOptions> options)
    {
        _options = options.Value;
    }

    public void Send(string to, string body)
    {
        var host = _options.Host;   // Типобезпечно! Compile-time перевірка!
        var port = _options.Port;
        // ...
    }
}

// У тестах:
var options = Options.Create(new SmtpOptions { Host = "test-smtp", Port = 465 });
var emailService = new EmailService(options); // Легко!

Найкращі Практики: Загальний Чекліст

Реєстрація

  • Організовуйте реєстрації через Extension Methods по модулях
  • Використовуйте TryAdd* у бібліотечному коді
  • Завжди вмикайте ValidateOnBuild та ValidateScopes
  • Реєструйте з найменш привілейованим lifetime (Transient → Scoped → Singleton)

Ін'єкція

  • Constructor Injection — за замовчуванням завжди
  • readonly для всіх полів залежностей
  • Guard clauses (?? throw new ArgumentNullException) для критичних залежностей
  • Уникайте IServiceProvider у бізнес-класах

Lifetimes

  • Перевіряйте Captive Dependency перед реєстрацією
  • Singleton — тільки thread-safe сервіси без DbContext
  • Scoped — для всього пов'язаного з DbContext
  • BackgroundService → IServiceScopeFactory для Scoped

Тестування

  • Тести не потребують DI-контейнера — передавайте моки напряму
  • Перевіряйте коректність реєстрацій інтеграційними тестами
  • Використовуйте WebApplicationFactory<T> для тестів ASP.NET Core

📝 Підсумкове Тестування

Наприкінці курсу перевірте свої знання з усього модулю. Дайте відповідь на наступні питання без допомоги матеріалів, потім перевірте себе:

1. У чому різниця між IoC та DI?
IoC — принцип інверсії управління (хто контролює залежності). DI — конкретний паттерн реалізації IoC (залежності передаються ззовні через конструктор/властивість/метод).
2. Що таке Captive Dependency та як його виявити?
Ситуація, коли сервіс із більшим lifetime (Singleton) використовує сервіс із меншим (Scoped). Виявлення: ValidateScopes = true у BuildServiceProvider. Виправлення: IServiceScopeFactory у Singleton.
3. Коли Service Locator виправданий?
Лише у Composition Root (точка входу), Middleware, Legacy-рефакторингу або при динамічному виборі типу в runtime. У бізнес-логіці — завжди анти-паттерн.
4. Яку роль відіграє рефлексія у DI-контейнері?
Контейнер використовує Type.GetConstructors() та ConstructorInfo.GetParameters() для автоматичного визначення залежностей класу та їх resolve через рекурсивні виклики GetService().
5. У чому різниця між AddScoped та AddTransient?
Scoped: один екземпляр на весь scope (HTTP-запит). Transient: новий екземпляр при кожному GetService<T>(). Обидва знищуються при закритті scope якщо реалізують IDisposable.

Корисні Посилання

Офіційна документація Microsoft DI

Повна документація Microsoft.Extensions.DependencyInjection

Autofac — просунутий DI

Більш потужний контейнер для складних сценаріїв (Decorators, Modules, AOP)

Scrutor — розширення Microsoft DI

Assembly scanning та Decorator support для Microsoft DI

Mark Seemann: Pure DI

Аргументи за та проти DI-контейнерів від автора DI .NET книги
Copyright © 2026