Lazy service resolution in MSDI (Lazy<T> with DI)

Introduction

In typical dependency injection scenarios, services are constructed eagerly when a consumer requests them, or when the framework instantiates a controller or middleware. Sometimes this is wasteful: a dependency might be expensive to create, or only used in certain code paths that don't always execute. You might want to defer construction until the service is actually needed, without changing the clean constructor-injection pattern that makes testing and refactoring easy.

Unfortunately, Microsoft.Extensions.DependencyInjection (MSDI) doesn't provide built-in support for resolving services lazily via the common Lazy<T> wrapper out of the box. Fortunately, you can still achieve lazy resolution with a tiny helper type and an open-generic registration. This approach avoids creating the wrapped service until its value is actually accessed and integrates cleanly with scoped and transient lifetimes, letting you write code like private readonly Lazy<MyService> _service and have it just work.

The helper type

Create a LazyInitializer<T> that inherits from Lazy<T> and uses the IServiceProvider to resolve the underlying service when needed. The DI container will inject the IServiceProvider into the wrapper's constructor, allowing the wrapper to defer calling GetRequiredService<T>() until someone accesses the .Value property.

The implementation below includes a factory method that captures the IServiceProvider and returns a lambda that will be called once, on first access to .Value. The simple console logging helps you understand the construction lifecycle and observe when lazy initialization happens. There is also a simpler commented-out version if you don't need logging or want to reduce indirection.

    public class LazyInitializer<T> : Lazy<T>
    where T : class
    {
        /// Simpler version without logging
        // public LazyInitializer(IServiceProvider serviceProvider)
        // : base(() => serviceProvider.GetRequiredService<T>(), isThreadSafe: true)
        // {
        // }

        public LazyInitializer(IServiceProvider serviceProvider)
            : base(CreateValueFactory(serviceProvider), isThreadSafe: true)
        {
            Console.WriteLine($"LazyInitializer<{typeof(T).Name}> instantiated");
        }

        private static Func<T> CreateValueFactory(IServiceProvider serviceProvider)
        {
            return () =>
            {
                Console.WriteLine($"LazyInitializer<{typeof(T).Name}> Value accessed");
                return serviceProvider.GetRequiredService<T>();
            };
        }
    }

Registering the open-generic wrapper

Register the open-generic LazyInitializer<> so MSDI will provide Lazy<T> wherever it's requested. This registration should happen in your application startup configuration, typically in Program.cs (for minimal hosting) or in the Startup.ConfigureServices method (for traditional Startup pattern).

    builder.Services.AddTransient(typeof(Lazy<>), typeof(LazyInitializer<>));

Simplifying with an extension method

To avoid repeating the registration line across multiple projects or to make the intent clearer, create a small extension method on IServiceCollection:

    public static class LazyResolutionExtensions
    {
        public static IServiceCollection AddLazyResolution(this IServiceCollection services)
        {
            services.AddTransient(typeof(Lazy<>), typeof(LazyInitializer<>));
            return services;
        }
    }

Now you can simplify your startup configuration to a single readable line:

    builder.Services.AddLazyResolution();

Example usage

Suppose you have a DataGenerator service that's relatively expensive to construct or you only want to create it when actually needed by a controller action. Register both the concrete service and the lazy resolution:

    var builder = WebApplication.CreateBuilder(args);

    builder.Services.AddScoped<DataGenerator>();
    builder.Services.AddTransient(typeof(Lazy<>), typeof(LazyInitializer<>));
    // Or using the extension method:
    // builder.Services.AddLazyResolution();
    var app = builder.Build();

Now inject Lazy<DataGenerator> into a controller — the DataGenerator instance will not be created until you access .Value.

    public class WeatherForecastController : ControllerBase
    {
        private readonly Lazy<DataGenerator> _dataGenerator;

        public WeatherForecastController(Lazy<DataGenerator> dataGenerator)
        {
            _dataGenerator = dataGenerator;
            Console.WriteLine("WeatherForecastController instantiated");
        }

        [HttpGet]
        [ProducesResponseType(StatusCodes.Status200OK)]
        public IEnumerable<WeatherForecast> Get(int count = 5)
        {
            Console.WriteLine("Get method called.");
            return _dataGenerator.Value.Generate(count);
        }
    }

Observed behavior (logs)

With the console logging in the LazyInitializer and controller constructor you should see output similar to:


LazyInitializer<DataGenerator> instantiated
WeatherForecastController instantiated
Get method called.
LazyInitializer<DataGenerator> Value accessed

Why this works (and why MSDI doesn't do it by default)

The default Microsoft.Extensions.DependencyInjection container will only resolve types it knows how to construct. It understands concrete types, registered open-generics and factory delegates that you explicitly register. It does not have a built-in mapping for the CLR type Lazy<T>, so asking the container for a Lazy<T> will fail unless you register how to create one. The small wrapper shown above tells the container how to create a Lazy<T> instance: construct the wrapper (which receives an IServiceProvider) and defer calling GetRequiredService<T>() until .Value is accessed.

When to use this pattern

Use lazy resolution when the dependency is expensive to construct, rarely used during a request, or you want to avoid work when certain controller actions don't need the service. This keeps constructor signatures clean (you still use constructor injection) but defers the cost until truly required.

Lifetime and registration rationale

The examples in this post register the wrapper as Transient. That is a safe default because it avoids accidentally turning a scoped service into an effectively singleton instance (the classic "captive dependency" problem). When the transient wrapper is injected into a scoped consumer (for example a controller during a request), the wrapper and the resolved inner service are created in the same request scope. Registering the wrapper as Scoped is also acceptable in many cases, but avoid registering the wrapper as Singleton if T or any of its dependencies are scoped.

Thread-safety and ASP.NET Core concerns

The Lazy<T> base supports a thread-safety option (the example uses isThreadSafe: true). However, be careful: ASP.NET Core request-scoped services are typically not safe to use concurrently from multiple threads. If your lazy-initialized service performs thread-affine work or accesses context-specific state, prefer single-threaded initialization or keep isThreadSafe to the default that matches your usage. In most simple web request scenarios, the initialization happens on the request thread when you first access .Value, so it is not an issue — just be mindful if you plan to access the lazy value from background threads.

Alternatives

  • Factory delegate: Inject a factory like Func<T> that resolves T when invoked. This is explicit and simple to test.
  • IServiceProvider: Inject IServiceProvider and call GetRequiredService<T>() yourself. This is powerful but hides dependencies from constructors and is generally discouraged except for composition roots or factory components.
  • Manual Lazy: Construct a Lazy<T> in the consumer using a local factory that calls the container. This works but moves the wiring into the consumer and makes testing harder.

Each alternative has trade-offs: Func<T> is explicit and testable, while a registered LazyInitializer<T> preserves the simple constructor shape and gives you the runtime laziness behaviour without scattering service-location code across the app.

Notes and considerations

  • Using Lazy<T> like this defers construction until needed, which can reduce unnecessary allocations and improve performance for infrequently used services.
  • Keep lifetime rules in mind: in the examples above the LazyInitializer is registered as Transient. When injected into a scoped consumer (for example a controller within a request), the transient wrapper and the resolved T are created within the same request scope. Choose Transient or Scoped depending on your needs, but avoid registering the wrapper as a Singleton when T or its dependencies are scoped to prevent captive dependency issues.
  • If you don't need the logging or factory indirection, a simpler implementation is possible using base(() => serviceProvider.GetRequiredService<T>(), isThreadSafe:true) in the constructor.
  • This pattern is lightweight and works with the default MSDI container; no third-party DI container is required.

Performance impact

Constructor time: Using Lazy<T> shifts initialization cost. The wrapper itself is cheap to construct (just a closure over the IServiceProvider), so controllers and other consumers instantiate faster. You pay no cost until .Value is accessed.

First access: When you first call .Value, the LazyInitializer calls GetRequiredService<T>(). This has the same cost as direct injection — there is no magic or caching overhead, just deferred timing. Subsequent accesses return the cached instance at virtually zero cost (a property read and a null check).

Memory: The wrapper adds a small per-instance overhead (the closure and the Lazy<T> state object), but this is negligible in most scenarios. The real memory win comes from not constructing expensive services until you actually need them.

Bottom line: Lazy resolution is worthwhile when the service is genuinely expensive or rarely used. For lightweight, frequently-accessed services, the overhead of the wrapper may outweigh the benefit — measure in your own application to decide.

Summary

Adding a small LazyInitializer<T> and registering it as an open-generic service lets you inject Lazy<T> across your application and obtain true lazy resolution behavior with MSDI. It's handy for expensive or rarely-used services and fits naturally into existing DI registrations.


Written On: Dec 03, 2025