Введение: что такое Dependency Injection и зачем это нужно
Dependency Injection (DI) — это один из ключевых паттернов проектирования, который реализует принцип инверсии зависимостей (Dependency Inversion Principle) из SOLID. На практике это означает, что объекты не создают свои зависимости самостоятельно, а получают их извне. Это делает код гибким, тестируемым и слабосвязанным.
В экосистеме C# и .NET DI стал стандартом де-факто. Начиная с .NET Core, в платформу встроен собственный контейнер внедрения зависимостей, который используется в ASP.NET Core, фоновых сервисах и даже в консольных приложениях. Без понимания DI невозможно представить современную разработку на C#.
В этой статье мы разберем DI на реальных примерах: от ручного внедрения до использования встроенного контейнера .NET. Вы увидите, как избавиться от жестких связей и сделать код пригодным для модульного тестирования.
Проблема без Dependency Injection
Представим типичный сценарий: класс OrderService обрабатывает заказы и отправляет email-уведомления. Без DI код выглядит так:
public class EmailSender
{
public void Send(string to, string subject, string body)
{
Console.WriteLine($"Отправка email на {to}: {subject}");
}
}
public class OrderService
{
private EmailSender _emailSender;
public OrderService()
{
// Жесткая связь — класс сам создает зависимость
_emailSender = new EmailSender();
}
public void ProcessOrder(int orderId)
{
// Логика обработки заказа...
_emailSender.Send("user@example.com", "Заказ оформлен", $"Ваш заказ #{orderId} принят.");
}
}
В чем проблема? OrderService жестко привязан к конкретной реализации EmailSender. Если мы захотим заменить отправку email на SMS или логгирование, придется менять код OrderService. Тестирование тоже усложняется: невозможно подменить реальный EmailSender на mock-объект.
Решение: внедрение зависимостей через конструктор
Первый шаг к DI — выделить абстракцию (интерфейс) и передавать зависимость через конструктор. Это называется Constructor Injection.
Шаг 1: Создаем интерфейс
public interface INotificationService
{
void Send(string to, string subject, string body);
}
Шаг 2: Реализуем интерфейс
public class EmailSender : INotificationService
{
public void Send(string to, string subject, string body)
{
Console.WriteLine($"Email отправлен: {subject} -> {to}");
}
}
public class SmsSender : INotificationService
{
public void Send(string to, string subject, string body)
{
Console.WriteLine($"SMS отправлено: {subject} -> {to}");
}
}
Шаг 3: Внедряем зависимость через конструктор
public class OrderService
{
private readonly INotificationService _notificationService;
// Зависимость передается извне
public OrderService(INotificationService notificationService)
{
_notificationService = notificationService;
}
public void ProcessOrder(int orderId)
{
// Логика обработки заказа...
_notificationService.Send("user@example.com", "Заказ оформлен", $"Ваш заказ #{orderId} принят.");
}
}
Теперь OrderService не знает, какая именно реализация используется. Мы можем подставить EmailSender, SmsSender или любой другой класс, реализующий INotificationService. Код стал гибким и тестируемым.
Использование встроенного IoC-контейнера в .NET
Вручную создавать все зависимости неудобно. В .NET есть встроенный контейнер (IoC-контейнер), который автоматически управляет жизненным циклом объектов. Рассмотрим пример для консольного приложения.
Шаг 1: Устанавливаем пакет (если нужно)
Для консольных приложений .NET 6+ добавьте ссылку на Microsoft.Extensions.DependencyInjection через NuGet.
Шаг 2: Настраиваем контейнер
using Microsoft.Extensions.DependencyInjection;
class Program
{
static void Main(string[] args)
{
// Создаем коллекцию сервисов
var services = new ServiceCollection();
// Регистрируем зависимости
services.AddTransient<INotificationService, EmailSender>();
services.AddTransient<OrderService>();
// Строим провайдер сервисов
var serviceProvider = services.BuildServiceProvider();
// Получаем экземпляр OrderService с автоматическим внедрением EmailSender
var orderService = serviceProvider.GetRequiredService<OrderService>();
orderService.ProcessOrder(123);
}
}
Шаг 3: Разбираем жизненные циклы
- Transient (AddTransient): новый экземпляр создается при каждом запросе. Идеально для легковесных сервисов без состояния.
- Scoped (AddScoped): один экземпляр на область (например, на один HTTP-запрос в ASP.NET Core).
- Singleton (AddSingleton): один экземпляр на всё приложение. Используется для кэшей, логгеров, конфигураций.
Выбор жизненного цикла важен: неправильный выбор может привести к утечкам памяти или неконсистентным данным. Для EmailSender обычно используют Transient или Singleton (если он потокобезопасен).
Dependency Injection в ASP.NET Core
В ASP.NET Core DI встроен прямо в каркас. Регистрация сервисов происходит в файле Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Регистрируем сервисы
builder.Services.AddScoped<INotificationService, EmailSender>();
builder.Services.AddScoped<OrderService>();
var app = builder.Build();
// Контроллеры автоматически получают зависимости через конструктор
app.MapGet("/order/{id}", (int id, OrderService orderService) =>
{
orderService.ProcessOrder(id);
return Results.Ok("Заказ обработан");
});
app.Run();
Обратите внимание: в минимальных API и контроллерах MVC зависимости внедряются автоматически. Вам нужно только зарегистрировать их один раз.
Заключение
Dependency Injection — это не просто модный паттерн, а практический инструмент для создания качественного кода на C#. Мы разобрали:
- Проблему жестких связей и как DI её ре