Dependency Injection в C#: что это и зачем нужно
Dependency Injection (DI) — это один из ключевых паттернов проектирования, который позволяет создавать слабосвязанные и легко тестируемые приложения на C#. Вместо того чтобы класс самостоятельно создавал свои зависимости (например, объекты для работы с базой данных или логирования), эти зависимости передаются (внедряются) извне. Это реализует принцип Inversion of Control (IoC) — управление передаётся внешнему контейнеру.
В экосистеме .NET DI стал стандартом де-факто, начиная с ASP.NET Core. Встроенный контейнер зависимостей (через Microsoft.Extensions.DependencyInjection) позволяет легко регистрировать и разрешать зависимости. В этой статье мы разберём теорию, практические примеры и лучшие практики использования DI в C#.
Основные принципы Dependency Injection
DI строится на трёх базовых способах внедрения зависимостей. Рассмотрим каждый из них.
1. Внедрение через конструктор
Самый распространённый и предпочтительный способ. Зависимости передаются через параметры конструктора класса.
public interface ILogger
{
void Log(string message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}
public class OrderService
{
private readonly ILogger _logger;
// Внедрение зависимости через конструктор
public OrderService(ILogger logger)
{
_logger = logger;
}
public void ProcessOrder(int orderId)
{
_logger.Log($"Обработка заказа {orderId}");
// бизнес-логика
}
}
2. Внедрение через свойство
Зависимость устанавливается через публичное свойство. Менее предпочтительно, так как делает зависимость необязательной и может привести к ошибкам во время выполнения.
public class OrderService
{
public ILogger Logger { get; set; }
public void ProcessOrder(int orderId)
{
if (Logger == null)
throw new InvalidOperationException("Logger не установлен");
Logger.Log($"Обработка заказа {orderId}");
}
}
3. Внедрение через метод
Зависимость передаётся как параметр метода. Используется редко, обычно для временных зависимостей.
public class OrderService
{
public void ProcessOrder(int orderId, ILogger logger)
{
logger.Log($"Обработка заказа {orderId}");
}
}
Регистрация зависимостей в контейнере .NET
В современном .NET (ASP.NET Core, Console, Worker Services) используется встроенный DI-контейнер. Регистрация происходит в методе ConfigureServices или при создании ServiceCollection.
Существует три времени жизни (lifetime) для зарегистрированных сервисов:
- Transient — новый экземпляр создаётся каждый раз при запросе. Подходит для лёгких, недолговечных сервисов.
- Scoped — один экземпляр на один HTTP-запрос (или scope). Используется для Entity Framework DbContext.
- Singleton — один экземпляр на всё приложение. Подходит для кэшей, настроек, логгеров.
// Пример для ASP.NET Core
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
// Регистрация с интерфейсом
services.AddTransient<ILogger, ConsoleLogger>();
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddSingleton<IConfiguration, ConfigurationManager>();
// Регистрация без интерфейса (конкретный класс)
services.AddTransient<OrderService>();
}
}
// Использование в контроллере
public class OrderController : ControllerBase
{
private readonly OrderService _orderService;
public OrderController(OrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public IActionResult CreateOrder(int id)
{
_orderService.ProcessOrder(id);
return Ok();
}
}
Продвинутые техники: фабрики и декораторы
Фабрика для динамического создания зависимостей
Иногда нужно создать экземпляр с дополнительными параметрами, которые известны только во время выполнения. Для этого используется шаблон Фабрика.
public interface IOrderProcessorFactory
{
IOrderProcessor Create(string region);
}
public class OrderProcessorFactory : IOrderProcessorFactory
{
private readonly IServiceProvider _serviceProvider;
public OrderProcessorFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IOrderProcessor Create(string region)
{
return region switch
{
"EU" => _serviceProvider.GetRequiredService<EuOrderProcessor>(),
"US" => _serviceProvider.GetRequiredService<UsOrderProcessor>(),
_ => throw new ArgumentException("Неизвестный регион")
};
}
}
// Регистрация
services.AddTransient<EuOrderProcessor>();
services.AddTransient<UsOrderProcessor>();
services.AddSingleton<IOrderProcessorFactory, OrderProcessorFactory>();
Декоратор для расширения функциональности
Паттерн Декоратор позволяет обернуть существующий сервис, добавив дополнительное поведение (логирование, кэширование, проверка прав).
public class LoggingOrderServiceDecorator : IOrderService
{
private readonly IOrderService _inner;
private readonly ILogger _logger;
public LoggingOrderServiceDecorator(IOrderService inner, ILogger logger)
{
_inner = inner;
_logger = logger;
}
public void ProcessOrder(int orderId)
{
_logger.Log($"Начало обработки заказа {orderId}");
_inner.ProcessOrder(orderId);
_logger.Log($"Завершение обработки заказа {orderId}");
}
}
// Регистрация декоратора (вручную или через библиотеку Scrutor)
services.AddTransient<IOrderService, OrderService>();
services.Decorate<IOrderService, LoggingOrderServiceDecorator>(); // требует Scrutor
Преимущества и недостатки Dependency Injection
Плюсы
- Слабая связанность — классы зависят от абстракций, а не от конкретных реализаций.
- Тестируемость — легко подменять зависимости на моки/стабы в юнит-тестах.
- Упрощение поддержки — изменение реализации требует правки только в одном месте (регистрация контейнера).
- Управление жизненным циклом — контейнер сам управляет временем жизни объектов.
Минусы
- Сложность конфигурации — большое количество сервисов может сделать регистрацию запутанной.