Когда в 2016 году ASP.NET Core 1.0 вышел со встроенным контейнером Microsoft.Extensions.DependencyInjection, Autofac уже почти десять лет был главным именем в мире внедрения зависимостей на .NET. Отсюда и привычка многих команд: проект перерастает пару сервисов, и в обсуждении звучит вопрос, не пора ли подключать взрослый контейнер. Забавно, что за эти годы встроенный контейнер закрыл большую часть причин, по которым на Autofac переходили раньше: в .NET 8 появились ключевые сервисы, а сборка регистраций по сборке делегируется одному расширению. Разбираем, что встроенный контейнер умеет из коробки, за какие возможности на Autofac реально идут, и какой ценой обходится второй контейнер в проекте.
Время жизни сервисов во встроенном контейнере и проверка графа
Встроенный контейнер намеренно прост: три времени жизни, конструкторное внедрение и ничего сверх этого. Синглтон живёт от старта до остановки приложения, объект с областью Scoped создаётся на запрос или на явную область, Transient рождается заново на каждое разрешение:
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IUnitOfWork, SqlUnitOfWork>();
builder.Services.AddTransient<ReportBuilder>();
Для библиотек есть вежливые варианты: семейство TryAdd регистрирует сервис только если он ещё не зарегистрирован, поэтому библиотека не перетирает настройки приложения:
services.TryAddSingleton<IIdGenerator, GuidGenerator>();
Внутри контейнер не медленный: при первом обращении он компилирует делегаты разрешения для каждого зарегистрированного типа и дальше строит графы быстро. Именно поэтому резолв после старта дороже тёплого, и поэтому полезен строгий прогон всех регистраций:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
ValidateOnBuild проверяет весь граф при старте и честно падает на ошибке до первого запроса, а не на двадцатой минуте продовой нагрузки. В режиме разработки проверка областей включена по умолчанию, и главная ловушка, пленительная зависимость, ловится сама. Освобождение тоже часть контракта: контейнер помнит созданные им IDisposable-объекты, при закрытии области освобождает их автоматически, а синглтоны живут до остановки провайдера. Поэтому using IServiceScope scope в примере ниже не украшение, а граница ответственности. Синглтон, получивший в конструктор сервис со временем жизни Scoped, затащит его на весь срок своей жизни, а после конца запроса оставит держать мёртвый контекст базы. Разумный обход для фоновых работ выглядит так:
public class ReportService
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task RebuildAsync()
{
using IServiceScope scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
await db.RebuildAsync();
}
}
Экосистема настроек и фоновых служб того же контейнера
Встроенный контейнер это не изолированный ящик, а часть связки, в которую платформа встроила настройки, логирование, HTTP-клиенты и фоновые службы. Паттерн опций связывает конфигурацию с типами и добавляет валидацию:
builder.Services.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.Validate(o => o.Port > 0);
builder.Services.AddHostedService<OutboxProcessor>();
Фоновая служба наследует BackgroundService и получает зависимости из того же контейнера, IHttpClientFactory и ILogger<T> живут там же, и любой метод Add из чужой библиотеки рассчитывает именно на IServiceCollection. Для типов, у которых часть аргументов приходит из окружения, служит ActivatorUtilities.CreateInstance: контейнер достанет зарегистрированное, а недостающее примет из переданных значений, что удобно в фабриках. Autofac в этой картине стоит сбоку: он подключается через реализацию IServiceProviderFactory и принимает уже собранные регистрации, а экосистемные пакеты продолжают смотреть на встроенные абстракции. Написано всё это в расчёте на конструкторное внедрение, и команда, которая пользуется только им, получает предсказуемые тесты: зависимости видны в сигнатуре, скрытых входов нет.
Что .NET 8 закрыл из того, за чем раньше шли в Autofac
Исторически в Autofac шли за четырьмя вещами: именованными сервисами, сборкой регистраций по сборке, декораторами и удобными фабриками. Именованные сервисы стали ключевыми в .NET 8, для остального есть выверенные расширения. Ключи выглядят так:
builder.Services.AddKeyedSingleton<ICache, MemoryCache>("local");
builder.Services.AddKeyedSingleton<ICache, RedisCache>("shared");
public class OrderService
{
public OrderService([FromKeyedServices("shared")] ICache cache)
{
_cache = cache;
}
}
Автозагрузку регистраций и декораторы закрывает популярное расширение Scrutor, несколько строк вместо списка из сорока вызовов Add:
builder.Services.Scan(scan => scan
.FromAssemblyOf<Program>()
.AddClasses(c => c.AssignableTo<IHandler>())
.As<IHandler>()
.WithSingletonLifetime());
builder.Services.Decorate<IHandler, LoggingHandler>();
Обобщённые регистрации работают из коробки: AddTransient(typeof(IRepository<>), typeof(SqlRepository<>)) закрывает целые семейства сервисов. Внедрение IEnumerable<T> даёт все зарегистрированные реализации, что делает пайплайн из обработчиков вопросом одного foreach. Фабрики собираются вручную через делегат: AddTransient<ISession>(sp => sp.GetRequiredService<SessionFactory>().Open()) даёт тот же контроль над созданием, что и готовые фабрики Autofac.
Возможности Autofac, ради которых его реально подключают
Честный список того, чего у встроенного контейнера нет, короткий. Первый пункт это модули: класс, наследуемый от Module, несёт свои регистрации с собой, что удобно для плагинной архитектуры и библиотек, которые сами описывают свои зависимости:
public class LoggingModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<LoggingInterceptor>();
builder.RegisterType<RetryPolicy>().SingleInstance();
}
}
Второй пункт это декораторы и перехватчики на уровне контейнера: RegisterDecorator оборачивает реализации по соглашению, а EnableInterfaceInterceptors с генерацией обёрток на лету превращает логирование, повторы и кэш в код, отделённый от бизнес-логики:
builder.RegisterType<OrderService>().As<IOrderService>()
.EnableInterfaceInterceptors()
.InterceptedBy(typeof(LoggingInterceptor));
builder.RegisterDecorator<RetryHandler, IHandler>();
Третий пункт это мелкие удобства, из которых складывается привычка. Autofac создаёт незарегистрированные конкретные классы, поддерживает внедрение в свойства и события жизненного цикла вроде OnActivated, а фабрики генерирует автоматически:
public class ImportJob
{
public ImportJob(Func<Owned<ISession>> sessionFactory)
{
_sessionFactory = sessionFactory;
}
}
Вызов фабрики открывает новую сессию, а Owned говорит, что за освобождение отвечает вызывающий. Настройка времени жизни у Autofac богаче: InstancePerLifetimeScope, InstancePerMatchingLifetimeScope с метками областей и InstancePerOwned дают управление, которое во встроенном контейнере приходится обходить явными скоупами и фабриками. Внедрение в свойства звучит удобно, но прячет зависимости от конструктора и мешает тестам: чтобы собрать объект, нужно помнить и про свойства, поэтому большинство команд запрещают его соглашением, даже имея Autofac под рукой. Подключение к ASP.NET Core выглядит как смена провайдера сервисов:
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>(container =>
{
container.RegisterModule<LoggingModule>();
container.RegisterType<SqlUnitOfWork>()
.As<IUnitOfWork>()
.InstancePerLifetimeScope();
});
После этого обычные вызовы Add продолжают работать, Autofac стоит в конце конвейера и разрешает граф сам. Свежие версии Autofac поддерживают .NET 8, технического барьера нет, вопрос только в необходимости.
Цена второго контейнера от производительности до обслуживания
Производительность часто поднимают первой, хотя она реже всех остальных причин заслуживает внимания: синтетические бенчмарки разрешения одного графа дают разницу в наносекундах, а реальный запрос тратит время в базе и сети. Встроенный контейнер быстрее и легче, но его преимущество становится заметным только при разрешении тысяч зависимостей в секунду, чего в типовом приложении не бывает.
Реальная цена другая. Второй контейнер это ещё одна внешняя зависимость со своим циклом обновлений: мажорные релизы Autofac требуют проверки совместимости с текущей версией .NET и обновления пакетов интеграции. Это два разных мира диагностики: у Autofac свои сообщения об ошибках, своя логика поиска по соглашениям и свои сюрпризы для разработчика, привыкшего к встроенному поведению. Рефлексионные механизмы перехватчиков и сканирования встречают trimmed и AOT-публикации прохладнее, чем хотелось бы, а встроенный контейнер живёт в том же ритме релизов, что и платформа. Наконец, часть команды теперь обязана знать оба инструмента, и кодовые конвенции закрепляют это знание на годы.
Сценарии, тем временем, раскладываются по полочкам. Новое приложение типичного размера, от REST-сервиса до воркер-сервиса с очередями, не нуждается ни в одной возможности Autofac: ключей, сканирования и декораторов через расширения хватает с запасом. Проект с плагинной архитектурой, где модули приезжают из чужих сборок и несут регистрации с собой, получает от модулей Autofac реальную пользу. Наследуемая кодовая базе, где Autofac стоит уже десять лет, разумно оставить как есть: разбор дороже содержания. Публикация в Native AOT закрывает вопрос в пользу встроенного контейнера, рефлексионным приёмам там тесно. Библиотека, которую отдают наружу, должна регистрировать свои сервисы простыми методами Add и не тащить контейнер вообще: выбор контейнера принадлежит приложению, а не библиотеке.
Практические критерии достаточности встроенного контейнера
Проверка перед тем, как вынести вопрос о втором контейнере на архитектурное ревью:
- выпишите список возможностей, которых не хватает, по пунктам: ключи, модули, декораторы, перехватчики, если пунктов нет, разговор окончен;
- проверьте, не закрыты ли пункты платформой или расширениями: ключи в .NET 8, сканирование и декораторы в Scrutor;
- оцените плагинную архитектуру честно: если модули приезжают из чужих сборок в рантайме, модульность Autofac оправдана;
- посчитайте цену владения: дополнительный пакет, слой диагностики, обучение команды и привязка к циклу чужих релизов;
- вспомните про сценарий AOT и урезание сборки, если они в планах, перехватчики на рефлексии принесут больше боли, чем пользы;
- для библиотеки наружу держите только методы Add, контейнер это выбор приложения, а не библиотеки;
- зафиксируйте решение и его причины в документе по архитектуре, чтобы через год вопрос не подняли заново.
Встроенный контейнер закрывает потребности большинства приложений без единой внешней зависимости, а .NET 8 убил последнюю массовую причину перехода, добавив ключевые сервисы. Autofac остаётся сильным инструментом для плагинных систем, перехватчиков и кодовых баз, где он уже прижился, и зрелость у него настоящая. Решение о втором контейнере имеет смысл принимать от конкретного списка недостающих возможностей, а не от размера проекта или ощущения, что большим системам нужен большой ящик с инструментами. Чаще всего после честного списка оказывается, что размер проекта растёт от домена, а не от контейнера, и встроенных трёх времён жизни хватает даже там, где сервисов на порядок больше.