Service Lifetimes in ASP.NET Core
In my last post, I explained why using dependency injection to register services in ASP.NET Core apps was needed to design an application that respects the principles of inversion of control and loose coupling, and I gave an example of how to do so using the Microsoft's built in dependency injection container.
There was one crucial aspect of the registration of services in the container that I didn't explain – service lifetimes. These control how long the container will hold onto a resolved object after creating it.
The Dependency Injection container
The dependency injection container is a tool that manages the instantiation and configuration of objects in an application. Even though it is technically possible to build an application without using the principle of inversion of control, the use of a container is always recommended to simplify the management of dependencies within your code.
At startup, services are registered in the container. Whenever these services are required, instances of those services are resolved from the container at runtime. The container is responsible for the creation and disposal of instances of the required services, by keeping track of them and maintaining them for the duration of their lifetime.
Service Lifetimes
There are three lifetimes available with the Microsoft Dependency Injection container: transient, singleton, and scoped. The lifetime of the service is specified when registering the service in the container. As we will see shortly, because a service can be used in different places in the application, the service lifetime will affect whether the same instance of the service is consumed across the application, thus affecting the output.
To understand and illustrate how lifetimes work, we will use an ASP.NET Core application that displays the date and time in the index page. A class will be responsible for providing the date and time, and it will be invoked by methods in two different classes – a middleware component, and the page model of the index page.
The service class, Time
, is an implementation of IDateTime
. It is responsible for capturing the date at the time when it is instantiated by the dependency injection container and setting a private field to the value. The creation of the service (and thus the request and capture of the date) is being delegates to the container. This way, the lifetime used to register the service in the container will affect the output of the service. Finally, the class contains a method that, when called, returns that value in a string format.
The Time
class is the following:
public class Time : IDateTime
{
private readonly string _time;
public Time()
{
_time = DateTime.Now.ToString();
}
public string GetDate()
{
return _time;
}
}
And the IDateTime
interface is:
public interface IDateTime
{
string GetDate();
}
This service is invoked in two different classes.
First, it will be called from a custom middleware component that we have added to the request pipeline.
public class DateCustomMiddleware
{
public const string ContextItemsKey = nameof(ContextItemsKey);
private readonly RequestDelegate _next;
private readonly ILogger<DateCustomMiddleware> _logger;
public CustomMiddleware(RequestDelegate next, ILogger<DateCustomMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IDateTime dateTime)
{
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
var date = dateTime.GetDate();
context.Items.Add(ContextItemsKey, date);
var logMessage = $"Middleware: The Date from Time is {date}";
_logger.LogInformation(logMessage);
await _next(context);
}
}
The InvokeAsync
method calls GetDate()
on the IDateTime
service to get the current date and time.
The line:
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
will stop the code from running for 10 seconds. The idea is that we want the injection of the IDateTime
service in the middleware and the injection in the IndexModel
to be 10 seconds apart. That way, we will be able to tell if the same instance of the method is being used by both classes (the two times displayed match) or if they are each using a unique instance (the times displayed differ by 10 seconds).
Note that we are not using constructor injection to inject an instance of the IDateTime
service in the middleware component. For reasons that will become apparent by the end of this post, injecting services in a middleware component through constructor injection can result in an InvalidOperationException
. Injecting the services through the InvokeAsync method is safer. I'll explain why once we have been through the different lifetimes.
The other place where we request the Time
service is the IndexModel
class.
public class IndexModel : PageModel
{
private readonly IDateTime _date;
public IndexModel(IDateTime date)
{
_date = date;
}
public string DateFromDependency { get; set; }
public string DateFromMiddleware { get; set; }
public void OnGet()
{
if (HttpContext.Items.TryGetValue(DateCustomMiddleware.ContextItemsKey,
out var mwDate) && mwDate is string contextItemsKey)
{
DateFromMiddleware = contextItemsKey;
}
var date = _date.GetDate();
DateFromDependency = date;
}
}
We populate the DateFromMiddleware
property with the value that the ContextItemsKey
put in the HttpContext.Items
dictionary.
We call GetDate
to populate the DateFromDependency
variable. The service DateTime
is injected in the constructor of the class, which need to be created in the container, once we register it.
We now have dates coming from places with different injections of the DateTime
service.
Now that the application is set up, we can register the service in the container with the different lifetimes and see how they each behave.
Transient Lifetime
The transient lifetime is the most straightforward to understand, and usually the safest to use. Instances of services registered with a transient lifetime are created every time that their injection into a class is required.
To register a service with the transient lifetime, use the AddTransient
extension method on the IServiceCollection
:
builder.Services.AddTransient<IDateTime, Time>();
In our example, the instance of the IDateTime
service injected into the middleware and the IndexModel
will be different. Note how the two dates are 10 seconds apart, because a new instance of the service was created every time that it was required.
If we refresh the page, the two times displayed are updated, and are still 10 seconds apart.
Singleton Lifetime
Services registered with a singleton lifetime are only instantiated once during the lifetime of the application. This means that any injection of the service during the lifetime of the application will be done with the same instance of the service.
To register a service with the singleton lifetime, use the AddSingleton
extension method on the IServiceCollection
:
builder.Services.AddSingleton<IDateTime, Time>();
In our example both classes will receive the same instance of the Time service. Because the middleware component is the first one to require an instance of the service, the instance will be created before the middleware's InvokeAsync
is called. After that, it will be reused wherever needed during the lifetime of the application.
When running the application, both times displayed are the same.
When refreshing the page, the same times will be displayed. Sending a new request doesn't restart the application, so the same instance of the service will be reused with new requests.
Scoped Lifetime
When an HTTP request is sent to the server, an HttpContext
is created, which holds information on the request. This is when a scope for the current request is created within the application. The HttpContext
is then sent through the request pipeline, which includes several middleware components as well as endpoints, which generate the final response.
This definition of a scope is important to understand scoped lifetimes. In ASP.NET Core, the lifetime of a scope is equivalent to the lifetime of an HTTP request. Services registered with a scoped lifetime will be maintained and used during the lifetime of the scope (or HTTP request) they have been created in. So for one same request, the instance of an object injected in different classes will be the same.
To register a service with the scoped lifetime, use the AddScoped
extension method on the IServiceCollection
:
builder.Services.AddScoped<IDateTime, Time>();
When we run the application, the two times displayed are the same – the instance injected into the middleware component and into the IndexModel
was the same.
However, when refreshing the page, the time displayed is updated (but the two times still match). Refreshing the page sends a new HTTP request, and a new scope is created within the application. Within this new scope, a new instance of the Time service is created and injected into both classes using the service.
Correctly injecting services in middleware components
Now that we have seen how each of the lifetimes behave, let’s quickly go back to the reason we didn't inject our Time service through constructor injection in our custom middleware component.
The middleware request pipeline is created once for the duration of the application's lifetime. This means that any service injected through constructor injection in a middleware component can only be injected once during the application's lifetime. When this injected service is registered with a singleton lifetime, this poses no problem as the lifetime of the request pipeline and of the service are the same.
However, if the service injected in the middleware component has a transient lifetime, an instance of the service would be injected only once, as the first instantiation would occur at the same time that the pipeline is created and the component is constructed. Middleware components are only constructed once during the application's lifetime, which means they are singletons within the application. Dependencies injected via the constructor are captured for the application's lifetime. Subsequent injection of newly resolved services could not happen because the lifetime of the middleware component is longer than that of the service. This would condition the application's behaviour, as the middleware component would receive only one instance of the service.
Capturing a short-lived service inside of a longer-lived service will result in an InvalidOperationException
being thrown because instances of the services can't be correctly injected.
The way to solve this is to inject services as parameters in the Invoke
or InvokeAsync
method of the middleware component. As this method will be invoked once per request, its parameters will be resolved from the container within the scope of the current request.
Conclusion
In this post, I have explained one of the most important aspects to consider when registering dependencies in the dependency injection container – service lifetimes. We have seen that services with transient lifetimes are instantiated every time they need to be injected, services with scoped lifetimes are created once per request, and singleton services have the same lifetime as the application. We have also seen an example of why it is important to be mindful of the lifetimes of our registered dependencies, as capturing short-lived services inside long-lived services can introduce bugs into our code.