blog post image
Andrew Lock avatar

Andrew Lock

~14 min read

The dangers and gotchas of using scoped services in OptionsBuilder

A quick reminder that my new book, ASP.NET Core in Action, Third Edition, is currently available in MEAP. Completely updated to .NET 7, minimal APIs and minimal hosting (but also covering Razor Pages, MVC controllers and generic hosting). Use the discount code au35loc to get 35% off at manning.com.

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 using IServiceProvider.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 MySettingssettings 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.

Singleton and scoped service resolution in ASP.NET Core DI

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.

But why would you need to create a scope outside the context of a request? Couldn't you just resolve the necessary dependencies directly from the root 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.

A captive dependency

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 the ConfigureMySettingsOptions 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 the ValueService
  • 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.

An unhandled exception has occurred while executing the request. System.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:

  1. Depend on IServiceProvider instead (or IServiceScopeProvider)
  2. Manually create a new scope, by calling CreateScope()
  3. Resolve the ValueService instance from the scoped IServiceProvider.

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 in OptionsBuilder<T>.Configure.
  • The ValueService instance associated with the request's scope.

Multiple scopes in a single request

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

Andrew Lock | .Net Escapades
Want an email when
there's new posts?