This is the first of two posts, and is largely a result of being nerd-sniped while listening to an episode of The Breakpoint Show which discussed dependency injection (DI) and the possible service lifetimes available. At various points Khalid, Maarten, and Woody discussed hypothetical "additional" lifetimes. This got me wondering how feasible it would be to create practical versions of those lifetimes.
In this post I first briefly describe the standard lifetimes available in the .NET DI container. I then briefly describe the three hypothetical lifetimes described in the podcast. Finally, I show how you could implement one of these lifetimes in practice. In the next post I show a possible implementation for the remaining lifetime.
Service lifetimes available in the .NET Core DI container
Before we look at these additional hypothetical service scopes, let's make sure we understand the existing lifetime scopes that are available in .NET Core.
This post isn't meant to be a full introduction to dependency injection in .NET Core. If you'd like to learn more, the Microsoft documentation contains an introduction to dependency injection. Alternatively, chapters 8 and 9 of my book contain a longer introduction to dependency injection in general, as well as the standard DI lifetimes.
When you register services in the .NET Core DI container, you choose one of three different lifetimes.
- Singleton
- Scoped
- Transient
The lifetime you specify controls how and when the DI container chooses to create a new instance of a given service, and when it instead returns an already-existing instance of the service.
Singleton services
Singleton is the simplest lifetime you can give a service. Singleton services are only ever created once. When you register a service as a singleton you can either explicitly provide the instance the DI container should always return, or you can tell the DI container to create an instance, but then always reuse it.
var builder = WebApplication.CreateBuilder(args);
// Providing an explicit instance of SingletonClass1 to use
builder.Services.AddSingleton(new SingletonClass1());
// Or, allowing the DI container to create the SingletonClass2
builder.Services.AddSingleton<SingletonClass2>();
// Or, providing a "factory function" for the the container to use to create the instance
builder.Services.AddSingleton(serviceProvider => new SingletonClass3());
In the first registration method above, the instance of SingletonClass1
is explicitly provided to the DI container. The container then uses this instance whenever it needs an instance of the type, and will not create a new version itself.
In the second registration method shown above, the DI container is responsible for creating the instance of SingletonClass2
. The first time the SingletonClass2
is requested, the DI container creates an instance. It then reuses the same instance every time it needs an instance of the type.
The final method shown above works pretty much the same as the second method, the only difference is that you're providing an explicit "factory" lambda method which the container invokes to create an instance of SingletonClass3
.
The key point is that in all cases, the container creates a maximum of a single instance, and this same instance is used to fulfil any requests for the type.
Scoped services
Scoped services are arguably the most confusing of the service lifetimes. This is primarily due to the introduction of the concept of a new concept: scope. I think the easiest way to understand the scope concept is to see it in action:
var builder = WebApplication.CreateBuilder(args);
// Allowing the DI container to create the ScopedClass
builder.Services.AddScoped<ScopedClass>();
// Alternatively, providing a "factory function" for the the container to use to create the instance
builder.Services.AddScoped(serviceProvider => new ScopedClass2());
var app = builder.Build();
// Hold a reference to the object _outside_ of the scope, for demo purposes only
// WARNING: you shouldn't do this normally, as the service is disposed when the scope ends
ScopedClass service;
// create a scope
using (var scope = app.Services.CreateScope())
{
// Retreive the first instance
service = scope.ServiceProvider.GetRequiredService<ScopedClass>();
// Request another instance of the ScopedClass
var other = scope.ServiceProvider.GetRequiredService<ScopedClass>();
// The DI container returns the same instance in both cases
Console.WriteLine(service == other); // true
}
using (var scope = app.Services.CreateScope())
{
// In a different scope, the DI container returns a _different_ instance
var other = scope.ServiceProvider.GetRequiredService<ScopedClass>();
Console.WriteLine(service == other); // false
}
The key point is that within a scope, the DI container returns the same instance of ScopedClass
every time it's requested. But for different scopes, the DI container returns a different instance of ScopedClass
.
In the code above I explicitly created the scope, but if you're using ASP.NET Core, then the scope is typically created for you automatically by the framework, and lasts for a single request. That means that all usages of the scoped ScopedClass
service within a given request return the same instance of a ScopedClass
, but you get a different instance of ScopedClass
when you're in different requests.
Transient services
Transient services are again relatively simple: every request for a transient service returns a new instance.
var builder = WebApplication.CreateBuilder(args);
// Allowing the DI container to create the TransientClass
builder.Services.AddTransient<TransientClass>();
// Alternatively, providing a "factory function" for the the container to use to create the instance
builder.Services.AddTransient(serviceProvider => new TransientClass2());
var app = builder.Build();
// create a scope
using (var scope = app.Services.CreateScope())
{
// Request the first instance
var service = scope.ServiceProvider.GetRequiredService<TransientClass>();
// Request another instance of the TransientClass
var other = scope.ServiceProvider.GetRequiredService<TransientClass>();
// Even inside the same scope, the instances are different
Console.WriteLine(service == other); // false
}
Every time you request a TransientService
instance, even if you're in the same request, the DI container creates a new instance of the TransientService
class.
That covers the standard scopes supported by the Microsoft.Extensions.DependencyInjection libraries used by ASP.NET Core . In the next section I describe some of the "hypothetical" scopes that were discussed on The Breakpoint Show.
The Breakpoint Show's additional lifetime scopes
In episode 36 of The Breakpoint Show, Khalid, Maarten, and Woody discuss the three lifetimes I described above, providing some examples of when you might choose each one, problems to watch out for, and other things to consider.
Throughout the show, they also discussed the desire for three "additional" types of services, which didn't quite fit into the standard lifetimes:
- Tenant-scoped services
- Pooled services
- Time-based (drifter) services
In the following sections I provide a brief high-level overview of these theoretical scopes.
Tenant-scoped services
Tenant-scoped services were mentioned by Maarten as a practical existing example, which he has used in real multi-tenant applications. They are useful when you want some services to be "singletons", but you don't want them to be shared between the whole application. Rather, you want them to be "singletons for a given tenant".
As you might expect, multi-tenant applications are relatively common, so there are various packages you can use to help configure singleton services in your application. I wrote about SaasKit back in 2016, but that package hasn't seen many updates since then, and there are some modern alternatives now.
One of these alternatives is explained by Michael McKenna in his blog series. In particular he describes how you can create "tenant-scoped" services by creating a new "tenant-scoped root" container when a request arrives in an ASP.NET Core application, which ensures each tenant's services are isolated from one another. I won't describe the approach further here, as Michael does a great job of explaining how it works on his blog.
Pooled services
Pooled services were mentioned by Woody as a way to reduce allocations in your application in order to improve performance. It was inspired by EF Core's DbContext
pooling feature which can be useful in some high-performance scenarios. The single-threaded benchmark performance comparing DbContext
pooling with no-pooling shows that it can make a significant difference in some cases:
Method | NumBlogs | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|
WithoutContextPooling | 1 | 701.6 us | 26.62 us | 78.48 us | 11.7188 | - | - | 50.38 KB |
WithContextPooling | 1 | 350.1 us | 6.80 us | 14.64 us | 0.9766 | - | - | 4.63 KB |
These numbers look great, and faster is always better right? But it's worth being aware that you won't always see these improvements for pooling. Whenever you return a pooled object to the pool, it must "reset" its state, so that it's safe to reuse in another request. In some cases the time spent resetting an object may outweigh the savings made by not allocating a new object. There are also other subtle potential pitfalls.
Nevertheless, having "native" support for using pooling in the DI container is an interesting idea, so I will show an approach to implementing this in my next post.
Time-based (drifter) services
The final lifetime was described by Khalid as the "drifter" lifetime. He described it as somewhere between a scoped and transient lifetime—it's just in town for a short time before it moves on.
More concretely, I thought of it as a time-based service, as it essentially "lives" for a fixed period of time. For a specific period of time (until the timeout expires), whenever you request an instance of this service, you get the same item, so it behaves a bit like a scoped service. However, once the timeout is exceeded, you get a new instance of the type.
I struggle to think of a good example of when you would want to do this in practice. It sounds a bit like a cache, where you want to "refresh" the data (by getting a new instance) after a given period of time. But I would be more likely to implement that as a singleton type, where the data is refreshed in a background thread. 🤷♂️
Just for fun, I wondered what it would be like to expose this time-based lifetime as a concept in the .NET DI container. As it turns out, creating a naïve implementation is pretty easy, but there are a whole raft of subtleties to making something practical and safe!
Implementing a simple time-based lifetime service
To reiterate, I decided that a useful time-based lifetime service should have the following characteristics:
- All requests within a given scope should use the same instance of the service (so it behaves similarly to a scoped lifetime service).
- After the service timeout expires, a new instance of the type should be created when requested.
The net result is that instances of time-based lifetime services may or may not be reused across requests.
Implementing TimedDependencyFactory<T>
To implement the lifetime, I used a factory pattern. The factory is responsible for creating new instances of the dependency, but also for caching the current instance for as long as the defined time lifespan.
The naïve implementation of the factory is very simple, but to make the factory thread safe and ensure we don't create more than one instance of the dependency at a time, we can use a few interesting approaches.
Another point to note is that I chose to use the TimeProvider
abstraction introduced in .NET 8, which makes it possible to test the behaviour of the factory while avoiding flaky tests. The factory is shown below, and is extensively annotated to explain its behaviour.
private class TimedDependencyFactory<T>
{
// TimeProvider can get the current time, but is also testable
private readonly TimeProvider _time;
// How long should the dependency be kept around
private readonly TimeSpan _lifetime;
// A factory function for creating a new instance
private readonly Func<T> _factory;
// The current cached instance, as a pair of Lazy<T> and the time the instance is valid till
// We can't use a ValueTuple here, because we need reference semantics later
private Tuple<Lazy<T>, DateTimeOffset>? _instance;
public TimedDependencyFactory(TimeProvider time, TimeSpan lifetime, IServiceProvider serviceProvider)
{
_lifetime = lifetime;
// ActivatorUtilities will pull any dependencies in the T constructor
// from the IServiceProvider. Only Singleton or Transient dependencies
// make sense for injecting into the timed-dependency T
_factory = () => ActivatorUtilities.CreateInstance<T>(serviceProvider);
_time = time;
}
/// <summary>
/// Gets or creates an instance of <typeparamref name="T" />
/// </summary>
public T GetInstance()
{
// Store the current instance in a local variable
var instance = _instance;
// Fetch the current time using the time provider
var now = _time.GetUtcNow();
if (instance is not null && now < instance.Item2)
{
// The current item is still valid, so return it
return instance.Item1.Value;
}
// We either don't have an instance yet, or the existing one
// has expired, so create a new instance of the Lazy,
// and calculate the expiry date
var newInstance = new Tuple<Lazy<T>, DateTimeOffset>(
new Lazy<T>(_factory),
now.Add(_lifetime));
// Atomically replace the previous instance with the new one.
// To make this thread safe, we use CompareExchange, which returns
// the original value found in _instance.
var previous = Interlocked.CompareExchange(
ref _instance,
newInstance,
instance);
// We compare the value that was stored in previous with
// the instance we originally fetched, to check whether
// a different thread beat us to the update
if (ReferenceEquals(previous, instance))
{
// We replaced the value we expected, so return
// our new instance by executing the Lazy<T>
return newInstance.Item1.Value;
}
// A different thread replaced the current instance _before_ we did
// so discard our current instance and try again. We could use the
// previous value directly, and assume it's valid, but it's easier/safer
// to simply recurrsively call this method again. Unless we have tiny
// lifetimes, we don't expect more than one iteration here.
return GetInstance();
}
}
The most interesting part of the above code is the effort required to make it thread safe. Broadly speaking this is achieved by
- Copying the
_instance
field to a localinstance
value. - Using
Interlocked.CompareExchange()
to atomically swap-out a new value. This ensures that if a different thread is racing with ours, and both create a new instance of the stored value, both threads will be consistent about which instance they use. - Using
Lazy<T>
with a factory instead ofT
to ensure we don't create more than one instance of the actual dependencyT
at a time.
I haven't fully tested this for concurrency issues, but I think it covers our bases. Let me know in the comments if you see any issues!
Using an alternative Lock
-based implementation
I used the Lazy<T>
approach in the previous section as it provides an interesting lock-free approach*, similar to the approach ASP.NET Core uses with ConcurrentDictionary.GetOrAdd()
to avoid creating multiple instance of the dependency T
.
*This is a bit of a lie, because the
Lazy<T>
uses locking behind the scenes 🙈
However, given that I haven't measured the performance characteristics, jumping straight to the Lazy<T>
approach above is probably overkill. Arguably, a simpler version that simply uses lock(_lock)
is easier to understand, and may actually perform better in many circumstances. We would likely need to measure our actual application to understand how the characteristics of each approach impacts performance.
The following code shows how the implementation could change to use lock()
instead. I've highlighted the differences in this implementation with comments.
private class TimedDependencyFactory<T>
{
private readonly TimeProvider _time;
private readonly TimeSpan _lifetime;
private readonly Func<T> _factory;
private readonly Lock _lock = new();
// The _instance no longer needs to use Lazy<T>, just a T
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)
{
// The current value isn't valid, so create a new one
// using a _lock_ here ensures that no other thread will change this value
lock (_lock)
{
// Check that another thread didn't just create a new instance
// before we entered the lock. If it did, then the new instance
// should be valid.
instance = _instance;
if (instance is null || now > instance.Item2)
{
// Create a new tuple, invoking the factory,
// and calculate the expiry date
instance = new Tuple<T, DateTimeOffset>(
_factory(), now.Add(_lifetime));
_instance = instance;
}
}
}
return instance.Item1;
}
}
The GetInstance()
implementation in this case is much simpler. We simply check if the instance is valid. If it's not, we take a lock
and create a new one instead of using a Lazy<T>
.
Creating the AddTimed<>
extension methods
Now that we have a factory (whichever we choose), we can use it to configure our DI container. First we'll create a helper extension method that takes a type parameter, T
, and a TimeSpan
indicating the minimum lifetime of the dependency:
public static class TimedScopeExtensions
{
public static IServiceCollection AddTimed<T>(this IServiceCollection services, TimeSpan lifetime)
where T : class
{
// Add the factory as a singleton, using the system TimeProvider implementation
services.AddSingleton(provider => new TimedDependencyFactory<T>(
TimeProvider.System, lifetime, provider));
// Add the service itself as a dependency, delegating to the factory
services.AddScoped(provider => provider
.GetRequiredService<TimedDependencyFactory<T>>()
.GetInstance());
return services;
}
}
By using AddScoped
for the dependency in the above extension we ensure that we always use the same instance for a duration of a request. Remember though, that this means we're specifying the minimum lifetime of the dependency. We check whether the dependency is still valid when it's first requested in a request, and it lives for the duration of the request, even if the timespan elapses.
Testing out the timed dependencies
Now that we have our implementation, we can take it for a test drive. I created a simple ASP.NET Core application with an endpoint that takes a dependency on TimedService
. Each instance of this service is given a unique ID, and we simply return the ID from the endpoint
var builder = WebApplication.CreateBuilder(args);
// Add our service with a 5s lifetime
builder.Services.AddTimed<TimedService>(lifetime: TimeSpan.FromSeconds(5));
var app = builder.Build();
app.MapGet("/", (TimedService service) => service.GetValue);
app.Run();
public class TimedService
{
private static int _id = 0;
// Each new instance of TimedService gets a new value
public int GetValue { get; } = Interlocked.Increment(ref _id);
}
Hitting the /
endpoint returns the value 1
for all requests for 5s, after which it returns 2
, and so on. It works!
This test doesn't demonstrate that all instances of
TimedService
within a request are the same instance, so you'll just have to trust me on that one!
As I mentioned before, I'm not sure exactly what the use cases would be here — it behaves a bit like a cache, but not quite. Nevertheless, I can think of a few limitations…
Limitations of the time-based service implementation
One potentially significant issue is that the implementation shown in this post doesn't handle the case where T
is an IDisposable
.
This is actually an interesting issue that's surprisingly tricky to solve. The problem is that the DI container automatically calls Dispose
on any IDisposable
instances it returns when a scope ends. This is a problem for us, because we don't want to dispose the dependency until after we replace it.
I spent quite a long time trying to solve this issue, adding layers of "lease" objects and various approaches, but in all cases I couldn't get rid of the potential race conditions. In the end I realised I was spending far too much time on it and gave up 😅
A different point to consider is that we can still have multiple instances of a service T
alive at one time. I went to some effort to avoid creating multiple instances when the timeout expires and you create a replacement instance, either by using the Lazy<T>
or a lock()
. However, that doesn't mean that there's only ever one "active" instance of the service.
For example, one slow request may be using instance A, during which time, the timeout expires. A second request is received, and as the timeout has expired, the factory creates a new instance, instance B. While the original request is executing, both instances A and B are in active use.
If those issues aren't a problem for you, then you should be able to use the above implementation, but as I don't have a great handle on a real-life use case, I'm not sure whether these limitations are likely to be an issue or not!
Summary
In this post I provided a brief introduction to the lifetimes available in the Microsoft.Extensions.DependencyInjection abstraction used in ASP.NET Core. I then briefly described some additional hypothetical lifetimes that were discussed by Khalid, Maarten, and Woody on The Breakpoint Show: tenant-scoped, pooling, and time-based services.
Finally, I showed two variations of approaches you could use to implement the time-based services. However, both implementations had a large flaw: you can't use this approach with IDisposable
services, as the service may be disposed while it's still in use in a different request. In the next post I show an example implementation for the pooled lifetime instead.