In this post, Andrew Lock investigates experimental dependency injection lifetimes in .NET beyond the standard singleton, scoped, and transient, providing practical implementations and discussing their tradeoffs.

Beyond Singleton, Scoped, and Transient: Tenant, Pooled, and Time-Based Dependency Injection Lifetimes in .NET

Author: Andrew Lock

This post delves into experimental and hypothetical dependency injection (DI) lifetimes beyond the well-established Singleton, Scoped, and Transient scopes in .NET, inspired by an episode of The Breakpoint Show that discussed wishes for more flexible and nuanced lifetimes. Alongside a summary of existing lifetimes, this article explains three new scopes—tenant, pooled, and time-based lifetimes—and provides a hands-on implementation for a time-based (drifter) lifetime.

Standard Lifetimes in Microsoft.Extensions.DependencyInjection

When registering services with the .NET Core DI container, three standard lifetimes are available:

  • Singleton: Only one instance is ever created for the application’s lifetime.
  • Scoped: An instance is created per DI scope (e.g., per web request in ASP.NET Core).
  • Transient: A new instance is created every time the service is requested.

Singleton

Singleton registration ensures a single instance is created and shared:

builder.Services.AddSingleton(new SingletonClass1()); // explicit instance
builder.Services.AddSingleton<SingletonClass2>(); // container creates instance
builder.Services.AddSingleton(serviceProvider => new SingletonClass3()); // factory

Scoped

A scoped service is unique per logical operation (request in ASP.NET Core):

builder.Services.AddScoped<ScopedClass>();
builder.Services.AddScoped(serviceProvider => new ScopedClass2());

using (var scope = app.Services.CreateScope()) {
    var instance1 = scope.ServiceProvider.GetRequiredService<ScopedClass>();
    var instance2 = scope.ServiceProvider.GetRequiredService<ScopedClass>();
    // instance1 == instance2 (same scope)
}

Transient

Transient services provide a new instance for every injection:

builder.Services.AddTransient<TransientClass>();
builder.Services.AddTransient(serviceProvider => new TransientClass2());

using (var scope = app.Services.CreateScope()) {
    var service1 = scope.ServiceProvider.GetRequiredService<TransientClass>();
    var service2 = scope.ServiceProvider.GetRequiredService<TransientClass>();
    // service1 != service2, every time
}

Hypothetical Lifetimes from The Breakpoint Show

The show discussed three non-standard, hypothetical lifetimes, each intended for advanced scenarios:

1. Tenant-Scoped Services

  • Purpose: To have services act as a singleton per tenant, not per application.
  • Use case: Multi-tenant apps that must segregate state or data between tenants.
  • Implementation: Solutions exist (e.g., via tenant-rooted DI containers or libraries), with a notable explanation from Michael McKenna (tenant-scoped services in ASP.NET Core).

2. Pooled Services

  • Idea: Inspired by EF Core’s DbContext pooling, this would reduce allocations and potentially improve performance by recycling rather than always creating new service instances.
  • Tradeoff: While pooling can boost efficiency, resetting state between usages can offset these benefits, so suitability is workload-dependent.
  • Planned Implementation: To be detailed in the next post.

3. Time-Based (Drifter) Services

  • Concept: Services behave like a scoped lifetime, but only within a specified time window—after which a new instance is created.
  • Analogy: Similar to a cache with a sliding expiration.
  • Potential Use Cases: Not always clear; possible applications where data needs to refresh periodically or disposable resources need regular cycling.
  • Caveat: Not recommended where disposables are involved, due to complex lifetime management.

Implementing a Simple Time-Based Lifetime Service

Characteristics

  • Requests within a time window get the same instance.
  • After expiration, new instances are created as needed.

Factory Pattern Implementation

A factory retains the current service instance for its valid period, handing out the same instance until the time expires.

Lock-Free Version (with Lazy)

private class TimedDependencyFactory<T>
{
    private readonly TimeProvider _time;
    private readonly TimeSpan _lifetime;
    private readonly Func<T> _factory;
    private Tuple<Lazy<T>, DateTimeOffset>? _instance;

    public TimedDependencyFactory(TimeProvider time, TimeSpan lifetime, IServiceProvider serviceProvider)
    {
        _lifetime = lifetime;
        _factory = () => ActivatorUtilities.CreateInstance<T>(serviceProvider);
        _time = time;
    }

    public T GetInstance()
    {
        var instance = _instance;
        var now = _time.GetUtcNow();
        if (instance is not null && now < instance.Item2)
            return instance.Item1.Value;

        var newInstance = new Tuple<Lazy<T>, DateTimeOffset>(new Lazy<T>(_factory), now.Add(_lifetime));
        var previous = Interlocked.CompareExchange(ref _instance, newInstance, instance);
        if (ReferenceEquals(previous, instance))
            return newInstance.Item1.Value;
        return GetInstance();
    }
}

Lock-Based Version

For clarity and sometimes better practical performance:

private class TimedDependencyFactory<T>
{
    private readonly TimeProvider _time;
    private readonly TimeSpan _lifetime;
    private readonly Func<T> _factory;
    private readonly object _lock = new();
    private Tuple<T, DateTimeOffset>? _instance;

    public TimedDependencyFactory(TimeProvider time, TimeSpan lifetime, IServiceProvider serviceProvider)
    {
        _lifetime = lifetime;
        _factory = () => ActivatorUtilities.CreateInstance<T>(serviceProvider);
        _time = time;
    }

    public T GetInstance()
    {
        var instance = _instance;
        var now = _time.GetUtcNow();
        if (instance is null || now > instance.Item2)
        {
            lock (_lock)
            {
                instance = _instance;
                if (instance is null || now > instance.Item2)
                {
                    instance = new Tuple<T, DateTimeOffset>(
                        _factory(), now.Add(_lifetime));
                    _instance = instance;
                }
            }
        }
        return instance.Item1;
    }
}

Registering with DI

You can expose this new lifetime via an extension:

public static class TimedScopeExtensions
{
    public static IServiceCollection AddTimed<T>(this IServiceCollection services, TimeSpan lifetime)
        where T : class
    {
        services.AddSingleton(provider => new TimedDependencyFactory<T>(
            TimeProvider.System, lifetime, provider));
        services.AddScoped(provider =>
            provider.GetRequiredService<TimedDependencyFactory<T>>().GetInstance());
        return services;
    }
}

Example Usage

builder.Services.AddTimed<TimedService>(TimeSpan.FromSeconds(5));

app.MapGet("/", (TimedService service) => service.GetValue);

public class TimedService
{
    private static int _id = 0;
    public int GetValue { get; } = Interlocked.Increment(ref _id);
}

Repeated requests within 5 seconds will get the same value; a new instance (with incremented ID) is provided after expiration.

Limitations and Tradeoffs

  • IDisposable Issue: The approach fails if T implements IDisposable, as the DI container will dispose it at the end of the request, even if a subsequent scope should reuse it.
  • Multiple Active Instances: If a request is taking longer than the time window, you may have more than one instance active across requests at a time.
  • Potential Use Cases: Edge cases like short-lived caching or resource throttling, but generally not something widely needed.

Summary

This post first revisited .NET’s built-in service lifetimes and then introduced three proposed DI scopes—tenant, pooled, and time-based. The post detailed how to implement a time-based (drifter) lifetime for experimental purposes and highlighted the limitations, especially with disposables and concurrent scopes. In follow-up content, a practical implementation for pooled lifetimes will be explored.

This post appeared first on “Andrew Lock’s Blog”. Read the entire article here