Что такое Dependency Injection в C# и зачем он нужен
В современной разработке на C# и .NET одним из ключевых принципов проектирования является Dependency Injection (DI) — внедрение зависимостей. Этот паттерн позволяет создавать слабосвязанный, тестируемый и легко поддерживаемый код. Вместо того чтобы класс самостоятельно создавал экземпляры необходимых ему объектов (жесткая связь), зависимости внедряются извне.
Основная цель DI — реализация принципа Inversion of Control (IoC), когда управление созданием объектов передается внешнему контейнеру или фреймворку. Это кардинально меняет архитектуру приложения, делая её гибкой и готовой к изменениям.
Проблема жесткой связанности (Tight Coupling)
Рассмотрим типичный пример без DI:
public class OrderService
{
private readonly EmailSender _emailSender;
public OrderService()
{
// Жесткая зависимость — создаем экземпляр конкретного класса
_emailSender = new EmailSender();
}
public void ProcessOrder(Order order)
{
// Логика обработки заказа
_emailSender.Send("Заказ обработан");
}
}
Здесь OrderService напрямую зависит от EmailSender. Если мы захотим заменить отправку email на SMS или логирование, придется изменять код OrderService. Это нарушает Open/Closed Principle и делает модульное тестирование практически невозможным.
Основные способы внедрения зависимостей в C#
В C# существует три основных способа реализации DI. Каждый из них имеет свои сценарии использования и особенности.
1. Внедрение через конструктор (Constructor Injection)
Это самый популярный и рекомендуемый способ в .NET. Зависимости передаются через параметры конструктора и обычно сохраняются в приватных полях, доступных только для чтения (readonly).
public interface INotificationService
{
void Send(string message);
}
public class EmailService : INotificationService
{
public void Send(string message)
{
Console.WriteLine($"Email отправлен: {message}");
}
}
public class OrderService
{
private readonly INotificationService _notificationService;
// Зависимость внедряется через конструктор
public OrderService(INotificationService notificationService)
{
_notificationService = notificationService;
}
public void ProcessOrder(Order order)
{
// Логика...
_notificationService.Send("Заказ обработан");
}
}
Преимущества: явно видно, какие зависимости нужны классу; зависимости неизменяемы после создания объекта; легко тестировать, подставляя mock-объекты.
2. Внедрение через свойство (Property Injection)
Зависимость устанавливается через публичное свойство (сеттер). Используется реже, когда зависимость не является обязательной для работы класса.
public class OrderService
{
public INotificationService NotificationService { get; set; }
public OrderService() { }
public void ProcessOrder(Order order)
{
if (NotificationService != null)
{
NotificationService.Send("Заказ обработан");
}
}
}
Недостаток: можно забыть установить зависимость, что приведет к NullReferenceException во время выполнения. Рекомендуется использовать только для опциональных зависимостей.
3. Внедрение через метод (Method Injection)
Зависимость передается непосредственно в метод, где она используется. Это удобно, когда зависимость требуется только для одного конкретного действия.
public class OrderService
{
public void ProcessOrder(Order order, INotificationService notificationService)
{
// Логика...
notificationService.Send("Заказ обработан");
}
}
Этот подход часто применяется в аспектно-ориентированном программировании или когда зависимость может меняться для каждого вызова метода.
Встроенный IoC-контейнер в .NET Core / .NET 5+
Начиная с .NET Core, в платформу встроен мощный контейнер внедрения зависимостей (IServiceProvider). Он находится в пространстве имен Microsoft.Extensions.DependencyInjection. Для регистрации сервисов используется класс ServiceCollection.
Регистрация сервисов
В файле Program.cs или Startup.cs (для старых версий) вы регистрируете все зависимости:
using Microsoft.Extensions.DependencyInjection;
// Создаем коллекцию сервисов
var services = new ServiceCollection();
// Регистрируем зависимости с разным временем жизни
services.AddTransient<INotificationService, EmailService>(); // Новый экземпляр при каждом запросе
services.AddScoped<IOrderRepository, OrderRepository>(); // Один экземпляр на запрос
services.AddSingleton<ILogger, ConsoleLogger>(); // Один экземпляр на всё приложение
// Регистрируем основной сервис
services.AddTransient<OrderService>();
// Строим провайдер
var serviceProvider = services.BuildServiceProvider();
// Получаем экземпляр OrderService с автоматически внедренными зависимостями
var orderService = serviceProvider.GetRequiredService<OrderService>();
Времена жизни (Lifetimes) объектов
- Transient — создается каждый раз, когда запрашивается. Идеально для легковесных сервисов без состояния.
- Scoped — создается один раз на область (например, на один HTTP-запрос в ASP.NET).
- Singleton — создается один раз и живет всё время жизни приложения. Подходит для кэшей, логгеров.
Пример использования DI в ASP.NET Core Web API
Рассмотрим реальный сценарий — контроллер Web API, который использует сервис для работы с базой данных.
// Интерфейс репозитория
public interface IProductRepository
{
Task<IEnumerable<Product>> GetAllAsync();
}
// Реализация репозитория
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Product>> GetAllAsync()
{
return await _context.Products.ToListAsync();
}
}
// Контроллер
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
// DI через конструктор — фреймворк сам внедрит репозиторий
public ProductsController(IProductRepository repository)
{
_repository = repository;
}
[HttpGet]
public async Task<IActionResult> GetProducts()
{
var products = await _repository.GetAllAsync();
return Ok(products);
}
}