Dependency Injection (DI) — это популярный механизм внедрения зависимостей, который идеально соответствует принципам SOLID (Dependency Inversion Principle). В .NET использование DI (Microsoft.Extensions.DependencyInjection) стало стандартом де-факто.
Однако у DI есть важный недостаток: при создании корневого объекта (например, контроллера) контейнер резолвит всё дерево зависимостей, включая глубоко вложенные. В крупных системах с микросервисами или тяжелыми сервисами (БД, внешние API, ML-модели) это приводит к:
Замедлению startup времени — создание объекта сервиса, даже если он не будет использоваться - не будет вызван по условиям в коде;
Увеличению потребления памяти — все без исключения объекты создаются заранее.
Решение: использование DI с Lazy<T> — ленивая инициализация зависимостей
Класс Lazy<T> создает объект только при первом обращении к свойству .Value,
Microsoft.Extensions.DependencyInjection.ServiceProvider нативно НЕ поддерживает инъекцию Lazy<T>, но это не проблема.
Всё что нужно сделать это зарегестрировать в контейнере Lazy<T> вместе с оборачиваемым сервисом.
builder.Services.AddScoped<IDatabaseService, SqlDatabaseService>();
builder.Services.AddScoped(sp => new Lazy<IDatabaseService>(() => sp.GetRequiredService<IDatabaseService>()));
После этого можно внедрять обёрнутый сервис.
public class ProductController : ControllerBase
{
private readonly Lazy<IDatabaseService> _db;
public ProductController(Lazy<IDatabaseService> db)
{
_db = db;
}
}
Lazy vs Func
Есть большая разница, если вместо Lazy<T> использовать Func<T>.
При использовании Lazy<T> создание обёрнутого объекта произойдёт один раз при первом вызове .Value, повторные вызовы .Value всегда будут возвращать готовый объект из Lazy<T>.
Не забываем, что сам Lazy<T> является контейнером и его тип lifetime повлияет, какой экземпляр объекта получат сервисы, например:
builder.Services.AddScoped<IDatabaseService, SqlDatabaseService>();
builder.Services.AddScoped(sp => new Lazy<IDatabaseService>(() => sp.GetRequiredService<IDatabaseService>()));
Тут оба сервиса имеют lifetime scoped и во всех внедрениях внутри одного scope будет внедрён один и тот же экземпляр Lazy<IDatabaseService> и это ожидаемое поведение.
Следующий пример сложнее:
builder.Services.AddTransient<IDatabaseService, SqlDatabaseService>();
builder.Services.AddScoped(sp => new Lazy<IDatabaseService>(() => sp.GetRequiredService<IDatabaseService>()));
Тут очевидно, что Lazy<IDatabaseService> инкапсулирует созданный объект IDatabaseService внутри scope. Такое поведение может оказатся неожиданным, так как отличается от указанного жизненного цикла transient при регистрации IDatabaseService. Будьте внимательны.
Func<T> ведёт себя совершенно иначе - он будет запрашивать получение объекта из контейнера при каждом вызове () и конечное поведение будет зависить с каким lifetime был зарегистрирован обёрнутый тип - AddScoped, AddSingleton или AddTransient.
Это принципиально разное поведение, которое нужно учитывать.
Func<T> регистрируется аналогично:
builder.Services.AddScoped<Func<IDatabaseService>>(sp => () => sp.GetRequiredService<IDatabaseService>());
Вот хороший пример, поясняющий разницу в поведении и показывающий преимущества использования Lazy<T>.
public class ProductController : ControllerBase
{
private readonly Func<IDatabaseService> _db;
public ProductController(Func<IDatabaseService> db)
{
_db = db;
}
public void DoSomthing()
{
_db().CheckConnection();
_db().SelectSomthing();
}
}
Как будет работать этот код, если IDatabaseService зарегистрирован в контейнере через AddTransient? Каждый вызов _db() будет возвращать новый экземпляр объекта IDatabaseService. Скорее всего это не то поведение, которое вам нужно.
Использование Lazy<T> лишено такого недостатка, так как он сам является контейнером.
Практические рекомендации
-
Используйте
Lazy<T>для:Сервисов с тяжёлой инициализацией затрагивающей DB, FileSystem, External API;
Опциональных зависимостей, что часто актуально для бизнес логики.
-
Опционально избегайте использование в:
Singleton без опциональной зависимости, тут ленивость теряет функциональный смысл, но для чистого кода использовать её - ок;
Критических путях, где ленивость добавляет небольшой overhead в виде вызова
.Value, но обычно в бизнес логике это не оказывает существенного влияния на производительность, но в отдельных критических к скорости алгоритмах нужно взвесить за и против.
Вывод: Lazy<T> — простой и мощный инструмент для оптимизации DI. Он не требует сторонних библиотек и работает из коробки с Microsoft DI. Для Autofac или Unity доступны расширения вроде LazyProxy.
Код с примером использования Lazy<T> с DI можно посмотреть на GitHub
Комментарии (21)

posledam
28.12.2025 14:35А где пример с open generic? Как-то неудобно каждый раз регать сервис и плюс к нему ещё и Lazy-обёртку.
services.AddTransient(typeof(Lazy<>), typeof(LazyService<>)); public sealed class LazyService<T> : Lazy<T> { public LazyService(IServiceProvider provider) : base(provider.GetRequiredService<T>) { } }Проблем с разными lifetime нет, сам Lazy transient. Да, для scoped-зависимостей может появиться несколько контейнеров Lazy, но экземпляр зависимости у них будет один в рамках scope.

antonb73 Автор
28.12.2025 14:35Как это используется?

posledam
28.12.2025 14:35Так и используется:
public class ProductController : ControllerBase { private readonly Lazy<IDatabaseService> _db; public ProductController(Lazy<IDatabaseService> db) { _db = db; } }Не нужно отдельно регистрировать точный generic-тип
// этого делать не требуется! builder.Services.AddScoped(sp => new Lazy<IDatabaseService>(() => sp.GetRequiredService<IDatabaseService>()));
antonb73 Автор
28.12.2025 14:35Не готов погружатся в тему критики open generic, но критика присутствует.
Что вижу я в предложенном подходе - самое главное, скрытая логика регистрации.
В моём примере кода, всё проще - для регистрации типа с поддержкой Lazy используется метод расширения, что делает код не только прозрачным, но и самодокументируемым. Не нужна поддержка Lazy - не используете расширение, нужен разный lifetime - добавляете уникальный метод регистрации и используете. Всё прозрачно и понятно и под контролем.
Хакерские штучки типа - добавить в проект общее поведение для всех типов, меня не впечатляют, а наоборот разочаровывают.
posledam
28.12.2025 14:35Регистрация open generic это не "скрытая логика", а стандартный механизм DI, который уже широко используется в самом Microsoft.Extensions.DependencyInjection.
Примеры:ILogger<T>,IOptions<T>,IOptionsSnapshot<T>,IOptionsMonitor<T>- все они регистрируются как open generic и воспринимаются как нормальный контракт, а не магия.Lazy<T>- это инфраструктурный способ разрешения зависимости, а не бизнес-логика. Его глобальная регистрация делает контейнер более предсказуемым и ортогональным, чем набор частных избыточных регистраций для каждого типа, за которыми ещё нужно дополнительно следить.Разница между подходами не в прозрачности, а в точке ответственности.
Точечные регистрации размазывают знание о Lazy по регистрации сервисов,
open generic концентрирует его в одном месте, ровно как это сделано дляILogger<T>илиIOptions<T>.На мой взгляд, это уменьшает дублирование и делает поведение контейнера единообразным. Я как мог контр-аргументировал, но мне думается, что всё упирается во вкусы и предпочтения. На реальных проектах предложенный подход не создавал никаких проблем.
Более того, Microsoft в своей официальной документации дают ссылки на другие IoC-контейнеры, прямо сообщая, что если вам нужны поддержка
Func<T>, прокси и прочие плюшки, выбирайте. Нет замечаний по поводу "скрытой логики" или какой-то магии.
antonb73 Автор
28.12.2025 14:35Дело не во вкусах, а в Хабре и здешней публике (отдельные индивиидумы).
Я бы мог попытатся разобратся в вашей аргументации, начал бы с вопроса, что такое "ортогональный контейнер" или хотя бы "точка ответственности".Lazy<T>- это инфраструктурный способ разрешения зависимости, а не бизнес-логика. Его глобальная регистрация делает контейнер более предсказуемым и ортогональным...
Разница между подходами не в прозрачности, а в точке ответственности.Но мне тут минусов понаставят, особо пробдвинутые и задвинутые неведомо в какие науки мыслители и борцы за чистоту Хабра. Так что оставим ненужные споры, я себе уже всё доказал.

posledam
28.12.2025 14:35Не знаю, чем уж лично я вас обидел или задел, но действительно, продолжать вести диалог, пока вы отвечаете людям в таком высокомерно-снисходительном тоне, смысла не вижу.
Но остальным читателям, я надеюсь, мои комментарии могут быть полезными.

antonb73 Автор
28.12.2025 14:35Вы меня не обидели, я правда не понимаю, что вы иммели ввиду в терминах "ортогональный контейнер" и "точка ответственности".
Из за этого у меня нет чёткой картины, вашей аргументации, непонятно на что вы опираетесь, кроме схожих примеров с ILogger<> и ссылки на авторитет документации Microsoft.
posledam
28.12.2025 14:35Ортогональность - возможность использовать
Lazy<T>независимо от регистрации других типов. Ещё один пример ортогональности:IEnumerable<T>, который напрямую поддерживается контейнером, без необходимости регистрироватьIEnumerableдля каждого специфичного типа.Точка ответственности - единое место, где описано поведение контейнера. Либо знание о
Lazy<T>размазано по регистрациям, либо оно централизовано и не влияет на читаемость бизнес-регистраций.И при чём тут "авторитет Microsoft"? Озвученные примеры регистрации открытых типов используются не просто широко, а максимально широко. А возможность регистраций открытых generic типов поддерживаются из коробки. Не очень понимаю, почему использование этой фичи вдруг объявлена злом и приводят вас к разочарованию.

antonb73 Автор
28.12.2025 14:35Ок, имеем:
Lazy - это инфраструктурный способ разрешения зависимости, а не бизнес-логика. Его глобальная регистрация делает контейнер более предсказуемым и ортогональным, чем набор частных избыточных регистраций для каждого типа, за которыми ещё нужно дополнительно следить.
Ортогональность - возможность использовать Lazy независимо от регистрации других типов.
У меня получилось:
Lazy - это инфраструктурный способ разрешения зависимости, а не бизнес-логика. Его глобальная регистрация делает контейнер более предсказуемым и даёт возможность использовать Lazy независимо от регистрации других типов, чем набор частных избыточных регистраций для каждого типа, за которыми ещё нужно дополнительно следить.
Потом я долго думал.... Видимо вы хотели сказать, что:
Lazy - это инфраструктурный способ разрешения зависимости, а не бизнес-логика.
С учётом lifetime тут может быть влияние и на бизнес логику.
Если зарегистрировать в контейнере Lazy<>, то не нужно регистриовать Lazy для каждого типа T и это лучше так как меньше кода и следить за каждой регистрацией Lazy для T не нужно.
С этим я не спорил, это же очевидно, но непонятно, какие преимущества кроме "меньше кода" это даёт. Свои возражения о "скрытой регистрации Lazy для каждого типа T" я уже написал ранее. Мы с вами в этом вопросе никуда не продвинулись.
Если я неправильно понял, поправьте.
Идём далее...
Имеем:
Разница между подходами не в прозрачности, а в точке ответственности.
Точечные регистрации размазывают знание о Lazy по регистрации сервисов,
open generic концентрирует его в одном месте, ровно как это сделано для ILogger или IOptions.Точка ответственности - единое место, где описано поведение контейнера. Либо знание о Lazy размазано по регистрациям, либо оно централизовано и не влияет на читаемость бизнес-регистраций.
У меня получилось:
Разница между подходами не в прозрачности, а в едином месте, где описано поведение контейнера. Либо знание о Lazy размазано по регистрациям, либо оно централизовано и не влияет на читаемость бизнес-регистраций.
Плохо понимаю, так как в моём примере все регистрации типов в контейнере для модуля делаются в одном специальном классе `ModuleBootstrapper`, предельно концентрированно в одном месте, в других местах регистрации не делаются. Это и есть пример централизации регистраций без их сокрытия - все регистрации прописаны явно в
ModuleBootstrapper, вы же концентрацию одобряете или нет, я запутался.Точечные регистрации размазывают знание о Lazy по регистрации сервисов, open generic концентрирует его в одном месте, ровно как это сделано для ILogger или IOptions.
Каким образом размазывают? Все регистрации прописаны явно, если регистрация не прописана тут
ModuleBootstrapper, значит её нет - предельно простое правило.
Напротив, open generic Lazy<> скрывают регистрацию Lazy для каждого типа T. Это ли не размазывание логики регистрации типов?Идём далее...
На мой взгляд, это уменьшает дублирование и делает поведение контейнера единообразным.
Поведение контейнера зависит только от реализации его методов, неважно как он был зарегистрирован. А вот логика работы связки Lazy + T может зависеть от lifetime, я это сразу указал в статье. Отсюда мой посыл - важно обеспечить гибкость против унификации.
Дублирование кода отсутствует в ModuleBootstrapper, там столько же строк кода сколько классов T.Я как мог контр-аргументировал, но мне думается, что всё упирается во вкусы и предпочтения.
Нет, я аргументированно показываю нюансы обеих подходов и вкусы здесь непричём.
На реальных проектах предложенный подход не создавал никаких проблем.
Ссылка на некий реальный проект неумесна, так как не добавляет деталей ни к одному из подходов.
Более того, Microsoft в своей официальной документации дают ссылки на другие IoC-контейнеры, прямо сообщая, что если вам нужны поддержка Func, прокси и прочие плюшки, выбирайте. Нет замечаний по поводу "скрытой логики" или какой-то магии.
Снова ссылка на авторитет документации Microsoft, который не добавляет деталей ни к одному из обсуждаемых способов.
И при чём тут "авторитет Microsoft"? Озвученные примеры регистрации открытых типов используются не просто широко, а максимально широко.
Действительно, причём тут авторитет, когда ОХ КАК ШИРОКО, АЖ МАКСИМАЛЬНО ШИРОКО это используется. Ссылка на "это очень широко используется" (или "все так делают") - это логическая ошибка argumentum ad populum, популярность не доказывает правильность или эффективность подхода.
А возможность регистраций открытых generic типов поддерживаются из коробки.
Поддерживается, не спорю, но это не означает, что теперь это стало единственно правильным подходом.
Не очень понимаю, почему использование этой фичи вдруг объявлена злом и разочаровывают.
Аргументацию я привел - скрытая регистрация типов это плохо, это маскирует зависимости, делая код менее читаемым и отлаживаемым, DI-контейнер становится "чёрным ящиком" - без явных registrations сложно понять граф зависимостей.
Резюмирую - мой единственный аргумент, остался для меня не менее значимым, чем до начала дискуссии.

posledam
28.12.2025 14:35С этим я не спорил, это же очевидно, но непонятно, какие преимущества кроме "меньше кода" это даёт.
Если можно писать меньше кода без каких-либо потерь или жертв, это однозначно - преимущество.
Свои возражения о "скрытой регистрации Lazy для каждого типа T" я уже написал ранее. Мы с вами в этом вопросе никуда не продвинулись.
Возражения строятся на постулате, что регистрация открытого типа это "скрытие". Я так не считаю, поэтому вы не можете продвинуться. Ваше утверждение не подтверждается широкой практикой, значит это строго индивидуальная ваша точка зрения. А против этого не попрёшь.
Cкрытая регистрация типов это плохо, это маскирует зависимости, делая код менее читаемым и отлаживаемым.
Само по себе утверждение верно. Но в случае с Lazy, никакого сокрытия или маскирования нет. Lazy интуитивно понятная инфраструктурная обёртка над зависимостью. И подобные обёртки уже активно используются. Никакой магии, никоим образом это не снижает читаемость, и не мешает отладке.
Каким образом размазывают? Все регистрации прописаны явно, если регистрация не прописана тут ModuleBootstrapper, значит её нет - предельно простое правило.
Явность регистраций никуда не девается. Если мне нужен
IDataBaseService, я только его и регистрирую. Ленивость это не свойство регистрации, а свойство внедрения.Действительно, причём тут авторитет, когда ОХ КАК ШИРОКО, АЖ МАКСИМАЛЬНО ШИРОКО это используется. Ссылка на "это очень широко используется" (или "все так делают") - это логическая ошибка argumentum ad populum, популярность не доказывает правильность или эффективность подхода.
Когда я говорю "широко используется", это не в смысле "все так делают". Если вы инженер и мыслите как инженер, то должны прекрасно понимать что это значит. Это прямо и недвусмысленно означает, что подобный подход с внедрением открытых generic-типов легко будет понятен разработчикам, так как они его уже плотно используют.
Так что ваша "логическая ошибка", вообще мимо кассы, извините. Блеснуть не удалось :)
Резюмирую - мой единственный аргумент, остался для меня не менее значимым, чем до начала дискуссии.
Ну я тоже резюмирую. Ваши рассуждения строятся вокруг тезиса, который вы определили как аксиому: "
Lazy<T>это скрытая хакерская логика регистрации". А я должен исходя из вашей аксиомы построить убедительную аргументацию по принципу "да, но...".Однако, я не разделяю этой точки зрения, не вижу даже близко что там скрытого, хакерского, или чернушного. Это встроенная фича контейнера, она активно применяется, большое число разработчиков так или иначе этим пользуются, а значит никакого эффекта удивления тут ожидать не следует. Это соответствует дизайну DI контейнера, сокращает количество паразитного совершенно не нужного кода.
Такова моя точка зрения. Вашу я также принимаю, хоть и не согласен с ней. Думаю на этом можно закончить.

j_shrike
28.12.2025 14:35Из моей практики - если какие-то зависимости нужны "не всегда", то компонент "многовато на себя берёт" и лучше его разбить на более атомарные куски. Тот самый "S" из SOLID. Рано или поздно Lazy цепочки перепутаются, особенно если использовать разное время жизни. Да и читать граф зависимостей, в котором "тут играем, тут не играем" IMHO сложнее.

posledam
28.12.2025 14:35Ведь бывают не только такие ситуации: "тут играем, тут не играем". Что насчёт кеша? Например, только в случае промаха, нужен ещё один граф объектов для извлечения значения и помещения его в кеш. Никакой SOLID не нарушается, а наоборот улучшает эффективность функции кеширования.
Если же Lazy это костыль, пытающийся исправить ситуацию, когда половина методов использует одни зависимости, половина другие, тут вопросов нет, надо пересмотреть ответственность компонента. Но как времянка до рефакторинга может помочь снизить боль.

j_shrike
28.12.2025 14:35С моей точки зрения, с кэшем у Вас скорее анти-пример получился - если компонент является одновременно и потребителем данных из кэша, и фабрикой оных данных - я бы распилил. Но никоим образом не претендую на истину в последней инстанции - тот же опыт показывает, что универсальных рецептов не бывает, серебрянной пули нет, как и ложки :)
И кстати, как альтернатива распилу, если произошел промах мимо кэша, то это повод создать скоуп и в нем уже сгенерить нужные данные пользуюясь всеми необходимыми зависимостями. Но в этом случае читаемость графа зависимостей еще хуже - надо смотреть не только конструктор, в который инжектится IServiceScopeFactory, но и все использования этой зависимости - не айс.
Но как времянка до рефакторинга может помочь снизить боль
Да, в моем случае так и произошло, еще очень помогало разбитие класса на partial-файлы, в каждом из который сидели свои проперти с зависимостями. Но когда их стало 54 (!), тогда я наконец-то продал продакту необходимость рефакторинга :D

antonb73 Автор
28.12.2025 14:35Из моей практики - если какие-то зависимости нужны "не всегда", то компонент "многовато на себя берёт" и лучше его разбить на более атомарные куски
В данном случае речь не об ответствености компонента по SOLID, а об условном ветвлении логики выполнения, при котором не все заивисмые компонены могут быть использованы при конкретном вызове метода.

j_shrike
28.12.2025 14:35О чем идет речь понятно, я пытаюсь сказать, что статья лечит симптомы и лечение само по себе не вызывает вопросов. Главное соблюдать дозировку - со временем жизни компонентов и контейнеров (ссылки на которые захватывает Lazy<>), по мере усложнения системы, могут начаться сложности.
Мой тезис направлен в несколько другую сторону - если проанализировать проблему с архитектурной точки зрения, то возможно не понадобятся трюки с "ленивыми зависимостями".
withkittens
А давайте. Возьмите вот такой код:
Что будет выведено в консоль? Почему
True? ;)antonb73 Автор
Это собеседование?
Я думаю, зависит от того как реализовано свойство IsValueCreated и каким образом ServiceProvider инжектит оборачиваемый объект в Lazy. Угадал?
Какой приз мне полагается?
withkittens
Это я пытаюсь намекнуть на то, что ленивой инициализации в вашем примере не происходит, всё дерево по-прежнему резолвится целиком, даже несмотря на то, что вы что-то где-то обернули в Lazy. И предлагаю подумать, почему так происходит.
antonb73 Автор
Значит ServiceProvider резолвит зависимость из контейнера прежде чем вставить в Lazy :)
Щас статью поправлю :)
Спасибо за тестирование.
antonb73 Автор
Поправил.