Внедрение зависимостей (DI)

Внедрение зависимостей: почему классы не должны создавать свои зависимости сами.

Суть: Dependency Injection (DI) — это приём, при котором объект получает свои зависимости извне, а не создаёт их сам через new. ASP.NET Core имеет встроенный DI-контейнер, который собирает граф объектов за вас.

Представьте контроллер, которому нужен сервис для работы с пользователями. Без DI он бы написал new UserService(new AppDbContext(...)) — жёстко связав себя с конкретными классами. Поменять реализацию или подсунуть заглушку в тесте станет невозможно. DI переворачивает это: контроллер просто просит зависимость, а контейнер её предоставляет.

Конструкторное внедрение

public interface IUserService
{
    Task<User?> GetAsync(int id);
}

public class UserService : IUserService
{
    private readonly AppDbContext _db;
    public UserService(AppDbContext db) => _db = db;  // зависимость извне
    public Task<User?> GetAsync(int id) => _db.Users.FindAsync(id).AsTask();
}

public class UsersController : ControllerBase
{
    private readonly IUserService _service;
    public UsersController(IUserService service) => _service = service; // DI
}

Контроллер просит IUserService в конструкторе. Контейнер видит это, создаёт UserService, тому нужен AppDbContext — контейнер создаёт и его. Так выстраивается весь граф.

Регистрация

builder.Services.AddScoped<IUserService, UserService>();

Эта строка говорит: «когда кто-то просит IUserService, дай UserService». Связь интерфейс в реализацию задаётся в одном месте.

Как работает под капотом

DI-контейнер — это, по сути, словарь «тип в способ его создания». При запросе типа он смотрит регистрацию, рекурсивно создаёт все зависимости конструктора и возвращает готовый объект. Это инверсия контроля: не ваш код управляет созданием, а контейнер. Промоделируем простейший контейнер на Python.

# Мини DI-контейнер: регистрация и разрешение зависимостей
class Container:
    def __init__(self):
        self._registry = {}
    def register(self, key, factory):
        self._registry[key] = factory
    def resolve(self, key):
        factory = self._registry[key]
        return factory(self)   # фабрике передаём контейнер для под-зависимостей

c = Container()
c.register("db", lambda _: {"conn": "postgres"})
c.register("user_service", lambda cont: {"db": cont.resolve("db")})
c.register("controller", lambda cont: {"service": cont.resolve("user_service")})

ctrl = c.resolve("controller")     # контейнер собрал весь граф
print(ctrl)

Попробуй сам ▶ — запросив «controller», контейнер сам разрешил «user_service», а тот — «db». Это и есть инверсия контроля.

Частые ошибки

  • Создавать зависимости через new внутри класса. Это убивает тестируемость и гибкость — просите их через конструктор.
  • Забыть зарегистрировать сервис. Тогда при запросе будет исключение «не удалось разрешить».
  • Service Locator-антипаттерн. Тянуть зависимости вручную из контейнера в коде вместо конструктора — прячет зависимости.

Best practices

  • Зависьте от интерфейсов (IUserService), а не от конкретных классов — легче подменять и тестировать.
  • Используйте конструкторное внедрение как основной способ.
  • Держите конструкторы тонкими: много зависимостей — сигнал, что класс делает слишком много.

Инверсия контроля и почему это меняет дизайн

DI — частный случай более широкого принципа инверсии управления (IoC): не ваш код решает, когда и как создавать зависимости, это решает контейнер. Связан DI и с принципом инверсии зависимостей (буква D в SOLID): модули верхнего уровня зависят не от конкретных реализаций, а от абстракций (интерфейсов). Контроллер зависит от IUserService, а какая именно реализация подставится — настоящая, кэширующая или тестовый мок — решается в одном месте регистрации.

Практический выигрыш виден в тестах. Чтобы проверить контроллер, не нужна реальная БД: достаточно подставить заглушку IUserService, возвращающую заранее заданные данные. Без DI, с new внутри, это было бы невозможно — зависимость намертво вшита. Так DI напрямую конвертируется в тестируемость, а тестируемость — в скорость и уверенность при изменениях.

Способы внедрения и анти-паттерны

Основной и рекомендуемый способ — конструкторное внедрение: зависимости приходят параметрами конструктора, объект после создания всегда в рабочем состоянии, а список зависимостей виден прямо в сигнатуре. Есть и внедрение через метод/свойство, но их применяют редко и осознанно. Полезное наблюдение: если конструктор разросся до 7-8 зависимостей, это запах — класс, вероятно, делает слишком много и его стоит разбить.

Главный анти-паттерн вокруг DI — Service Locator: когда вместо честного запроса зависимостей в конструкторе код сам лезет в контейнер (provider.GetService<T>()) посреди логики. Так зависимости становятся скрытыми — по сигнатуре класса не видно, что ему на самом деле нужно, и тесты усложняются. Запускаемая врезка выше показала суть контейнера: словарь «ключ → фабрика» с рекурсивным разрешением. Реальный контейнер ASP.NET Core делает то же, но с учётом времён жизни — а это тема следующего урока.

Итог: DI отдаёт создание зависимостей контейнеру, делая код гибким и тестируемым. Дальше — ключевая тема: времена жизни сервисов.

Проверьте себя
1. В чём идея внедрения зависимостей?
AКласс создаёт свои зависимости через new
BКласс получает зависимости извне (через конструктор), а граф собирает DI-контейнер
CВсе классы становятся статическими
DЗависимости хранятся в базе
2. Почему лучше зависеть от интерфейса (IUserService), а не от класса?
AИнтерфейсы быстрее
BЭто позволяет легко подменять реализацию и подставлять заглушки в тестах
CБез интерфейсов код не компилируется
DИнтерфейсы не нужны при DI