This post is an update of a post I wrote several years ago, about using scoped services with the IOptions
pattern. Since that post, an easier API for registering IOptions
has been added, OptionsBuilder<T>
, but you still need to be aware of many of the same options.
In this post I look at some of the problems you can run into with strong-typed settings. In particular, I show how you can run into lifetime issues and captive dependencies if you’re not careful.
I start by providing a brief overview of strongly-typed configuration in ASP.NET Core and the difference between IOptions<>
and IOptionsSnapshot<>
. I then describe how you can inject services when building your strongly-typed settings using the OptionsBuilder<>
API. Finally, I look at what happens if you try to use Scoped services with OptionsBuilder<>
, the problems you can run into, and how to work around them.
tl;dr; If you need to use Scoped services inside
OptionsBuilder<T>.Configure<TDeps>()
, create a new scope usingIServiceProvider.CreateScope()
and resolve the service directly. Be aware that the service lives in its own scope, separate from the main scope associated with the request.
Strongly-typed settings in ASP.NET Core
The most common approach to using strongly-typed settings in ASP.NET Core is to bind you key-value pair configuration values to a POCO object T
when configuring DI. Alternatively, you can provide a configuration Action<T>
for your settings class T
. When an instance of your settings class T
is requested, ASP.NET Core will apply each of the configuration steps in turn:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Bind MySettings to configuration section "MyConfig"
builder.Services.Configure<MySettings>(
builder.Configuration.GetSection("MyConfig"));
// Configure MySettings using an Action<>
builder.Services.Configure<MySettings>(options => options.MyValue = "Some value");
WebApplication app = builder.Build();
app.MapGet("/", (IOptions<MySettings> opts) => opts.Value);
app.run();
As shown in the above example, to access the configured MySettings
object in your classes/endpoints, you inject an instance of IOptions<MySettings>
or IOptionsSnapshot<MySettings>
. The configured settings object itself is available on the Value
property.
It’s important to note that order matters when configuring options. When you inject an IOptions<MySettings>
or IOptionsSnapshot<MySettings>
in your app, each configuration method runs sequentially. So for the configuration shown previously, the MySettings
object would first be bound to the MyConfig
configuration section, and then the Action<>
would be executed, overwriting the value of MyValue
.
Configuring IOptions
with OptionsBuilder<T>
OptionsBuilder<T>
is a helper class which provides a simplified API for registering your IOptions<T>
objects. Originally introduced in .NET Core 2.1, it has slowly gained additional features, such as adding validation to your IOptions<T>
objects as I described in a recent post.
You can create an OptionsBuilder<T>
object by calling AddOptions<T>()
on IServiceCollection
. You can then chain methods on the builder to add additional configuration. For example, we could rewrite the previous example to use OptionsBuilder<T>
as the following:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<MySettings>()
.BindConfiguration("MyConfig") // Bind to configuration section "MyConfig"
.Configure(options => options.MyValue = "Some value"); // Configure MySettings using an Action<>
WebApplication app = builder.Build();
app.MapGet("/", (IOptions<MySettings> opts) => opts.Value);
app.run();
These two examples are equivalent. As already mentioned OptionsBuilder<T>
includes some extra APIs for adding validation, and for automatically retrieving dependencies, as you’ll see shortly. But before we get to that, we should take a short diversion to look at the difference between IOptions<T>
and IOptionsSnapshot<T>
.
The difference between IOptions<>
and IOptionsSnapshot<>
In the previous examples I showed an example of injecting an IOptions<T>
instance into an endpoint. Another way of accessing your settings object is to inject an IOptionsSnapshot<T>
. As well as providing access to the configured strongly-typed options <T>
, this interface provides several additional features compared to IOptions<T>
:
- Access to named options.
- Changes to the underlying
IConfiguration
object are honoured. - Has a Scoped lifecycle (
IOption<>
s have a Singleton lifecycle).
Named options
I discussed named options some time ago in a previous post. Named options allow you to register multiple instances of a strongly-typed settings class (e.g. MySettings
), each with a different string
name. You can then use IOptionsSnapshot<T>
to retrieve these named options using the Get()
method:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<MySettings>("Alice")
.BindConfiguration("AliceSettings");
builder.Services.AddOptions<MySettings>("Bob")
.BindConfiguration("BobSettings");
// Configure the default "unnamed" settings
builder.Services.AddOptions<MySettings>()
.BindConfiguration("DefaultSettings");
WebApplication app = builder.Build();
app.MapGet("/", (IOptionsSnapshot<MySettings> settings) => new {
aliceSettings = settings.Get("Alice"), // get the Alice settings
bobSettings = settings.Get("Bob"), // get the Bob settings
mySettings = settings.Value, // get the default, unnamed settings
});
app.Run();
Named options are used a lot in the ASP.NET Core authentication system, though I haven’t seem them used directly in apps.
Reloading strongly typed configuration with IOptionsSnapshot
One of the most common uses of IOptionSnapshot<>
is to enable automatic configuration reloading, without having to restart the application. Some configuration providers, most notably the file-providers that load settings from JSON files etc, will automatically update the underlying key-value pairs that make up an IConfiguration
object when the configuration file changes.
The MySettings
settings object associated with an IOptions<MySettings>
instance won’t change when you update the underlying configuration file. The values are fixed the first time you access the IOptions<T>.Value
property.
IOptionsSnapshot<T>
works differently. IOptionsSnapshot<T>
re-runs the configuration steps for your strongly-typed settings objects once per request when the instance is requested. So if a configuration file changes (and hence the underlying IConfiguration
changes), the properties of the IOptionsSnapshot.Value
instance reflect those changes on the next request.
I discussed reloading of configuration values in more detail in a previous post!
Related to this, the IOptionsSnapshot<T>
has a Scoped lifecycle, so for a single request you will use the same IOptionsSnapshot<T>
instance throughout your application. That means the strongly-typed configuration objects (e.g. MySettings
) are constant within a given request, but may vary between requests.
Note: As the strongly-typed settings are re-built with every request, and the binding relies on reflection under the hood, you should bear performance in mind.
I’ll come back to the different lifecycles for IOptions<>
and IOptionsSnapshot<>
later, as well as the implications. First, I’ll describe another common question around strongly-typed settings – how can you use additional services to configure them?
Using services during options configuration
Configuring strongly-typed options with the Configure<>()
extension method is very common. However, sometimes you need additional services to configure your strongly-typed settings. For example, imagine that configuring your MySettings
class requires loading values from the database using EF Core, or performing some complex operation that is encapsulated in a CalculatorService
. You can’t access services you’ve registered in DI while registering services in DI, so you can’t use the Configure<>()
method directly:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// register the helper service
builder.Services.AddSingleton<CalculatorService>();
// Want to set MySettings based on values from the CalculatorService
builder.Services.AddOptions<MySettings>()
.Configure(options =>
{
// No easy/safe way of accessing CalculatorService here!
});
There are two approaches you can take here:
- Use the
IConfigureOptions<T>
interface - Use the
OptionsBuilder.Configure<Deps>()
helper method.
Using IConfigureOptions<T>
The first approach, using IConfigureOptions<T>
, is the “classic” way to handle this requirement. Instead of calling Configure<MySettings>
, you create a simple class to handle the configuration for you. This class implements IConfigureOptions<MySettings>
and can use dependency injection to inject dependencies that you registered in DI:
public class ConfigureMySettingsOptions : IConfigureOptions<MySettings>
{
// Can use standard DI here
private readonly CalculatorService _calculator;
public ConfigureMySettingsOptions(CalculatorService calculator)
{
_calculator = calculator;
}
public void Configure(MySettings options)
{
options.MyValue = _calculator.DoComplexCalcaultion();
}
}
All that remains is to register the IConfigureOptions<>
instance (and its dependencies) with the DI container:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Using the "old school" approach
builder.Services.Configure<MySettings>(
builder.Configuration.GetSection("MyConfig"));
// Register the IConfigureOptions instance
builder.Services.AddSingleton<IConfigureOptions<MySettings>, ConfigureMySettingsOptions>();
// Add the dependencies
builderServices.AddSingleton<CalculatorService>();
When you inject an instance of IOptions<MySettings>
into your controller, the MySettings
instance is configured based on the configuration section "MyConfig"
, followed by the configuration applied in ConfigureMySettingsOptions
using the CalculatorService
.
The above example uses the “older” configuration methods, but OptionsBuilder<T>
includes APIs to do all this inline.
Using OptionsBuilder<T>.Configure<TDeps>
OptionsBuilder<T>
has several overloads in which you provide a lambda function, and declare the dependencies you need as generic parameters:
Configure<TDep>(Action<TOptions,TDep>)
Configure<TDep1,TDep2>(Action<TOptions,TDep1,TDep2>)
Configure<TDep1,TDep2,TDep3>(Action<TOptions,TDep1,TDep2,TDep3>)
Configure<TDep1,TDep2,TDep3,TDep4>(Action<TOptions,TDep1,TDep2,TDep3,TDep4>)
Configure<TDep1,TDep2,TDep3,TDep4,TDep5>(Action<TOptions,TDep1,TDep2,TDep3,TDep4,TDep5>)
These overloads use the same IConfigureOptions<T>
interface behind the scenes, but remove the need to create an explicit class, and register it with DI.
Taking the CalculatorService
example from below, that means you can simplify your configuration to:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Using the "OptionsBuilder" approach
builder.Services.AddOptions<MySettings>()
.BindConfiguration("MyConfig")
.Configure<CalculatorService>( // decalare the depdency
(opts, calc) => opts.MyValue = calc.DoComplexCalcaultion()); // use the dependency
// Add the dependencies
builderServices.AddSingleton<CalculatorService>();
Using IConfigureOptions<T>
and OptionsBuilder<T>.Configure<TDep>
makes it trivial to use other services and dependencies when configuring strongly-typed options. Where things get tricky is if you need to use scoped dependencies, like an EF Core DbContext
.
A slight detour: scoped dependencies in the ASP.NET Core DI container
In order to understand the issue of using scoped dependencies when configuring options, we need to take a short detour to look at how the DI container resolves instances of services. For now I’m only going to think about Singleton and Scoped services, and will leave out Transient services.
Every ASP.NET Core application has a “root” IServiceProvider
. This is used to resolve Singleton services.
In addition to the root IServiceProvider
it’s also possible to create a new scope. A scope (implemented as IServiceScope
) has its own IServiceProvider
. You can resolve Scoped services from the scoped IServiceProvider
; when the scope is disposed, all disposable services created by the container will also be disposed.
In ASP.NET Core, a new scope is created for each request. That means all the Scoped services for a given request are resolved from the same container, so the same instance of a Scoped service is used everywhere for a given request. At the end of the request, the scope is disposed, along with all the resolved services. Each request gets a new scope, so the Scoped services are isolated from one another.
In addition to the automatic scopes created each request, it’s possible to create a new scope manually, using IServiceProvider.CreateScope()
. You can use this to safely resolve Scoped services outside the context of a request, for example after you’ve configured your application, but before you call app.Run()
. This can be useful when you need to do things like run EF Core migrations, for example.
IServiceProvider
?While that’s technically possible, doing so is essentially a memory leak, as the Scoped services are not disposed, and effectively become Singletons! This is sometimes called a “captive dependency”. By default, the ASP.NET Core framework checks for this error when running in the Development
environment, and throws an InvalidOperationException
at runtime. In Production
the guard rails are off, and you’ll likely just get buggy behaviour.
Which brings us to the problem at hand – using Scoped services with OptionsBuilder<T>.Configure<TDeps>
when you are configuring strongly-typed settings.
Scoped dependencies and IConfigureOptions: Here be dragons
Lets consider a relatively common scenario: I want to load some of the configuration for my strongly-typed MySettings
object from a database using EF Core. As we’re using EF Core, we’ll need to use the DbContext
, which is a Scoped service. To simplify things slightly further for this demo, we’ll imagine that the logic for loading from the database is encapsulated in a service, ValueService
:
public class ValueService
{
private readonly Guid _val = Guid.NewGuid();
// Return a fixed Guid for the lifetime of the service
public Guid GetValue() => _val;
}
We’ll imagine that the GetValue()
method fetches some configuration from the database, and we want to set that value on a MySettings
object. In our app, we might be using IOptions<>
or IOptionsSnapshot<>
, we’re not sure yet.
We need to use the ValueService
to configure the strongly-typed settings MySettings
, so we know we’ll need to use an IConfigureOptions<T>
implementation (which we’ll call ConfigureMySettingsOptions
) or the OptionsBuilder.Configure<Deps>()
method. I’m only going to look at the OptionsBuilder
case in this post.
If you are using the
IConfigureOptions<T>
approach you have more variables to consider, such as what lifecycle should you use to register theConfigureMySettingsOptions
instance? I discuss those trade-offs in my previous post on this subject.
Lets start with our sample app. This is super basic, and does 3 things:
- Registers the
ValueService
as a Scoped service in DI - Configures the
MySettings
object using theValueService
- Exposes an API that returns the configured
MySettings
value
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// Add the options
builder.Services.AddOptions<MySettings>()
.Configure<ValueService>((opts, service) => opts.MyValue = service.GetValue());
// Add the dependency
builder.Services.AddScoped<ValueService>();
var app = builder.Build();
// Simple API exposing the options
app.MapGet("/", (IOptions<MySettings> settings) => settings.Value);
app.Run();
public class ValueService
{
private readonly Guid _val = Guid.NewGuid();
// Return a fixed Guid for the lifetime of the service
public Guid GetValue() => _val;
}
public class MySettings
{
public Guid MyValue { get; set; }
}
Unfortunately, if you run this as-is, you’ll get an exception:
An unhandled exception has occurred while executing the request.
InvalidOperationException: Cannot resolve scoped service 'ValueService' from root provider.
Behind the scenes the OptionsBuilder.Configure<T>
registers an IConfigureOptions<T>
object as a transient service. That means that the service, and any dependencies, are resolved from the root container, triggering the captive dependency detection.
There’s a relatively simple fix for this: create a new scope before resolving the dependency
Creating a new scope in OptionsBuilder<T>.Configure<TDeps>
Instead of directly depending on ValueService
, using OptionsBuilder<T>.Configure<ValueService>
you must:
- Depend on
IServiceProvider
instead (orIServiceScopeProvider
) - Manually create a new scope, by calling
CreateScope()
- Resolve the
ValueService
instance from the scopedIServiceProvider
.
In code, that looks like this:
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<MySettings>()
.Configure<IServiceProvider>((opts, provider) => // 👈 Use IServiceProvider instead of ValueService
{
using var scope = provider.CreateScope(); // 👈 Create a new IServiceScope
// Resolve the scope ValueService instance from the scoped provider 👇
var service = scope.ServiceProvider.GetRequiredService<ValueService>();
opts.MyValue = service.GetValue(); // 👈 use the value
});
builder.Services.AddScoped<ValueService>();
var app = builder.Build();
app.MapGet("/", (IOptions<MySettings> settings) => settings.Value);
app.Run();
By creating a manual scope, you’re no longer resolving the ValueService
from the root container, so there’s no more captive dependency.
On the first request to your endpoint OptionsBuilder<T>.Configure()
is invoked, which creates a new scope, resolves the scoped service, sets the value of MyValue
, and then disposes the scope (thanks to the using
declaration). On subsequent requests, the same MySettings
object is returned, so it always has the same value:
> curl http://localhost:5000
{"myValue":"5380796b-75e3-4b21-8b96-74afedccda28"}
> curl http://localhost:5000
{"myValue":"5380796b-75e3-4b21-8b96-74afedccda28"}
In contrast, if you inject IOptionsSnapshot<MySettings>
into the endpoint, MySettings
is re-bound every request, and OptionsBuilder<T>.Configure()
is invoked on every request. That gives you a new value every time:
> curl http://localhost:5000
{"myValue":"53fd9985-b512-418b-a40d-5897fa9d0251"}
> curl http://localhost:5000
{"myValue":"a6324b88-3062-474e-877f-c4729c16bc92"}
Generally speaking, this gives you the best of both worlds – you can use both IOptions<>
and IOptionsSnapshot<>
as appropriate, and you don’t have any captive dependency issues. There’s just one caveat to watch out for…
Watch your scopes
You registered ValueService
as a Scoped service, so ASP.NET Core uses the same instance of ValueService
to satisfy all requests for a ValueService
within a given scope. In almost all cases, that means all instances of a Scoped service for a given request are the same.
However…
Our solution to the captive dependency problem was to create a new scope. Even when we’re building a Scoped object, e.g. an instance of IOptionsSnapshot<>
, we always create a new Scope inside ConfigureMySettingsOptions
. Consequently, you will have two different instances of ValueService
for a given request:
- The
ValueService
instance associated with the scope we created inOptionsBuilder<T>.Configure
. - The
ValueService
instance associated with the request’s scope.
One way to visualise the issue is to inject ValueService
directly into the endpoint, and compare its GetValue()
with the value set on MySettings.MyValue
:
app.MapGet("/", (IOptionsSnapshot<MySettings> settings, ValueService service) => new {
mySettings = settings.Value.MyValue,
service = service.GetValue(),
});
For each request, the value of _service.GetValue()
is different to MySettings.MyValue
, because the ValueService
used to set MySettings.MyValue
was a different instance than the one used in the rest of the request:
> curl http://localhost:5000
{
"mySettings":"ec802a8d-2154-4f10-9d12-4005df009ecc","service":"54795d72-8cb8-4490-b452-914bf08e7372"
}
> curl http://localhost:5000
{
"mySettings":"beeaa783-8912-4c75-bffd-54e64f9c1afe","service":"b93d4b22-15f1-4690-b036-4ff75c0f811e"
}
So is this something to worry about?
Generally, I don’t think so. Strongly-typed settings are typically that, just settings and configuration. I think it would be unusual to be in a situation where being in a different scope matters, but its worth bearing in mind.
One possible scenario I could imagine is where you’re using a DbContext
in your OptionsBuilder<T>.Configure
method. Given you’re creating the DbContext
out of the usual request scope, the DbContext
wouldn’t be subject to any session management services for handling SaveChanges()
, or committing and rolling back transactions for example. But then, writing to the database in the OptionsBuilder<T>.Configure()
method seems like a generally bad idea anyway, so you’re probably trying to force a square peg into a round hole at that point!
Summary
In this post I provided an overview of how to use strongly-typed settings with ASP.NET Core. In particular, I highlighted how IOptions<>
is registered as Singleton service, while IOptionsSnapshot<>
is registered as a Scoped service. It’s important to bear that difference in mind when using OptionsBuilder<T>.Configure<TDeps>()
with Scoped services to configure your strongly-typed settings.
If you need to use Scoped services when calling OptionsBuilder<T>.Configure<TDeps>()
, you should inject an IServiceProvider
into your class, and manually create a new scope to resolve the services. Don’t inject the services directly into Configure<TDeps>
as you will end up with a captive dependency.
When using this approach you should be aware that the scope created in OptionsBuilder<T>.Configure<TDeps>()
is distinct from the scope associated with the request. Consequently, any services you resolve from it will be different instances to those resolved in the rest of your application
Continue reading blogs at the ESPC Resource Center.
About the Author:
My name is Andrew Lock, though everyone knows me as ‘Sock’. I am a full-time developer, working predominantly in full stack ASP.NET development in Devon, UK. I graduated with an MEng in Engineering from Cambridge University in 2008, and completed my PhD in Medical Image Processing in 2014. I have experience primarily with C# and VB ASP.NET, working both in MVC and WebForms, but have also worked professionally with C++ and WinForms.
Reference:
Lock,A. (2023). The dangers and gotchas of using scoped services in OptionsBuilder. Available at: https://andrewlock.net/the-dangers-and-gotchas-of-using-scoped-services-when-configuring-options-with-options-builder/ [Accessed: 30th March 2023].