Updated on

The options pattern binds a section of configuration to a class and hands that class to whatever needs it through dependency injection. Instead of asking IConfiguration for "Pages:HomePage:Color", a service asks for IOptions<TitleConfiguration> and reads .Color.

Three interfaces deliver that class, and the difference between them is entirely about when the values are read. IOptions<T> reads once. IOptionsSnapshot<T> reads once per request. IOptionsMonitor<T> reads on demand and can notify when a value changes.

Pick by lifetime. A singleton service cannot take IOptionsSnapshot<T>, and a class that never needs to see a changed value has no reason to take anything but IOptions<T>.

If you’ve missed some of the basic configuration stuff, check out the ASP.NET Core Configuration Basics.

To download the source code for this article, you can visit the OptionsPattern folder in our GitHub repository. The source code for the whole series is here.

Let’s dive in.

What Is the Options Pattern in ASP.NET Core?

The options pattern binds a configuration section to a plain class and delivers that class through dependency injection. A service that needs three settings asks for those three settings, not for the whole configuration.

The class has to follow two rules. It must be non-abstract with a public parameterless constructor, and a public property binds only if it has a setter, where an init accessor counts. Fields and get-only properties are ignored, which is the mistake worth knowing about before it costs an afternoon.

Registration is one line in Program.cs. builder.Services.Configure<TitleConfiguration>(builder.Configuration.GetSection("Pages:HomePage")) says which class binds to which section.

From there, any class asks for IOptions<TitleConfiguration> in its constructor and reads the values off .Value. The section name and the class name do not have to match, and the class never learns where its values came from.

That decoupling is the point. Configuration moves, and the service reading it does not.

So, in short, the options pattern helps us to:

  • bind the configuration data to strongly typed objects
  • group the configuration data in logical sections
  • reload the configuration while the application is running
  • validate the configuration
  • inject only the needed parts of the configuration into different parts of the application
  • test the configuration easier

That last point is a direct consequence of the second one. A class that takes a small options interface instead of the whole configuration is trivial to test, which is why we can mock IOptions<T> in a test with a single helper call.

Let’s see some real examples of the options pattern usage.

How Do We Read Configuration With IOptions?

IOptions<T> is the simplest of the three interfaces and the right default. It is registered as a singleton, it can be injected anywhere, and it reads its values once.

Three pieces make it work. A class holding the settings, a section in appsettings.json whose property names match, and one Configure<T> call binding the two together.

A service then takes IOptions<TitleConfiguration> in its constructor and reads .Value. The value is computed the first time .Value is touched, not when the service is constructed, and it is the same instance from then on.

That last part is the limitation. Editing appsettings.json while the application runs changes nothing for anything holding IOptions<T>; the value was read at startup and is not read again.

When that matters, the answer is one of the other two interfaces, which the next two sections cover.

Let’s add a few values to our appsettings.json file first:

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    },
    "ConnectionStrings": {
        "sqlConnection": "server=.; database=CodeMazeCommerce; Integrated Security=true"
    },
    "Pages": {
        "HomePage": {
            "WelcomeMessage": "Welcome to the ProjectConfigurationDemo Home Page",
            "ShowWelcomeMessage": true,
            "Color": "red"
        }
    },
    "AllowedHosts": "*"
}

We’ll be using the HomePage subsection to configure our Index view of the HomeController.

Next, we need a class that will contain these properties, so let’s create it in the Models folder of the project:

public class TitleConfiguration
{
    public string WelcomeMessage { get; set; } = string.Empty;
    public bool ShowWelcomeMessage { get; set; }
    public string Color { get; set; } = string.Empty;
}

We need to make sure these property names match those of the appsettings.json file section.

Now we can modify the HomeController to support the options pattern. First, let’s inject IOptions<TitleConfiguration> instead of IConfiguration as we did before:

private readonly TitleConfiguration _homePageTitleConfiguration;

public HomeController(ILogger<HomeController> logger,
    IOptions<TitleConfiguration> homePageTitleConfiguration)
{
    _logger = logger;
    _homePageTitleConfiguration = homePageTitleConfiguration.Value;
}

As you can see we’re accessing the configuration data via the Value property of the IOptions interface.

We are going to change the HomeModel class to reflect these changes too:

public class HomeModel
{
    public TitleConfiguration Configuration { get; set; } = new();
}

We need to change the Index method too:

public IActionResult Index()
{
    var homeModel = new HomeModel
    {
        Configuration = _homePageTitleConfiguration
    };

    return View(homeModel);
}

And we should add some logic to the view so we can use these properties we’ve defined:

@model HomeModel
@{
    ViewData["Title"] = "Home Page";
}
<div class="text-center">
    @if (Model.Configuration.ShowWelcomeMessage)
    {
        <h1 class="display-4" style="color:@Model.Configuration.Color">@Model.Configuration.WelcomeMessage</h1>
    }
</div>

This should give us a big red title right in the middle of our Home Page.

Now, the only thing left to do is to actually register and configure the TitleConfiguration in our Program class:

builder.Services.Configure<TitleConfiguration>(builder.Configuration.GetSection("Pages:HomePage"));

This should register our HomePage configuration.

That single line covers almost every case, but it is not the only way to reach a bound class at startup. When something needs the values before the application is running, we can also resolve an IOptions instance inside Program.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

Now if we run our application the result is pretty clear:

Home Page - ProjectConfigurationDemo

Great! We’re getting the right properties to the view.

But if we want to change the title to the color green, for example, we need to restart the application to do it.

But there’s a better way to do it! And it’s called IOptionsSnapshot.

Using IOptionsSnapshot to Read the Updated Configuration

IOptionsSnapshot contains the values just for the lifetime of a request. So that means it’s registered as a scoped service in our application and we can use it only with scoped and transient dependencies. We cannot inject it into singleton services!

If we need to change the configuration without restarting the application, we need to implement IOptionsSnapshot<T> because IOptions<T> doesn’t support it.

Let’s modify our code to use IOptionsSnapshot<T> instead of IOptions<T>.

Changing our code, in this case, is easy, we just need to change the constructor of the HomeController:

public HomeController(ILogger<HomeController> logger,
    IOptionsSnapshot<TitleConfiguration> homePageTitleConfiguration)
{
    _logger = logger;
    _homePageTitleConfiguration = homePageTitleConfiguration.Value;
}

If we run the application, we’ll get the same home page as we did before, big red title.

But to test IOptionsSnapshot, let’s go to our appsettings.json file and change the color of the text to “blue”:

"Pages": {
    "HomePage": {
        "WelcomeMessage": "Welcome to the ProjectConfigurationDemo Home Page",
        "ShowWelcomeMessage": true,
        "Color": "blue"
    }
},

Now, we just need to refresh the HomePage and our title will be blue if we did everything correctly:

Home Page blue

Great, we’ve successfully modified our application to reload the configuration data dynamically.

Using IOptionsMonitor for Singleton Services

There is one problem with our current solution, and we’ve already mentioned it. IOptionsSnapshot is not suitable to be injected into services registered as a singleton in our application.

To demonstrate this, let’s create a simple service and try to inject IOptionsSnapshot into it.

First, let’s extend our Home Page title configuration a bit. We’ll add a new configuration property UseRandomTitleColor to our TitleConfiguration class:

public class TitleConfiguration
{
    public string WelcomeMessage { get; set; } = string.Empty;
    public bool ShowWelcomeMessage { get; set; }
    public string Color { get; set; } = string.Empty;
    public bool UseRandomTitleColor { get; set; }
}

And we’ll change appsettings.json to reflect it:

"Pages": {
    "HomePage": {
        "WelcomeMessage": "Welcome to the ProjectConfigurationDemo Home Page",
        "ShowWelcomeMessage": true,
        "Color": "blue",
        "UseRandomTitleColor": true
    }
},

After that, we’ll need a service we can register as a singleton, so let’s create a new one. We’ll call it ITitleColorService and we’ll create it in a separate folder called Services:

public interface ITitleColorService
{
    string GetTitleColor();
}

ITitleColorService declares just one method and that’s GetTitleColor, which should return a random color from the list of defined colors. So let’s create TitleColorService in the same folder and implement this interface:

public class TitleColorService : ITitleColorService
{
    private readonly string[] _colors = ["red", "green", "blue", "black", "purple", "yellow", "brown", "pink"];
    private readonly TitleConfiguration _homePageTitleConfiguration;

    public TitleColorService(IOptionsSnapshot<TitleConfiguration> homePageTitleConfiguration)
    {
        _homePageTitleConfiguration = homePageTitleConfiguration.Value;
    }

    public string GetTitleColor()
    {
        return _homePageTitleConfiguration.UseRandomTitleColor
            ? _colors[Random.Shared.Next(_colors.Length)]
            : _homePageTitleConfiguration.Color;
    }
}

It’s a simple implementation, that returns one of the colors from the array of colors if UseRandomTitleColor is set to true, and the value of the Color property if it’s set to false. Take note that we’re using IOptionsSnapshot to inject our configuration.

Random.Shared.Next(_colors.Length) matters more than it looks. Passing a hard-coded upper bound instead is the classic way to make the last color in the array unreachable, because Next(int) excludes its argument, and Random.Shared is the thread-safe instance a singleton service needs.

Now we need to register our service with our application, and we do that in the Program class:

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<TitleConfiguration>(
    builder.Configuration.GetSection("Pages:HomePage"));

builder.Services.TryAddSingleton<ITitleColorService, TitleColorService>();

builder.Services.AddControllersWithViews();

And finally, let’s change HomeController to override the title color:

private readonly ILogger<HomeController> _logger;
private readonly TitleConfiguration _homePageTitleConfiguration;
private readonly ITitleColorService _titleColorService;

public HomeController(ILogger<HomeController> logger,
    IOptionsSnapshot<TitleConfiguration> homePageTitleConfiguration,
    ITitleColorService titleColorService)
{
    _logger = logger;
    _homePageTitleConfiguration = homePageTitleConfiguration.Value;
    _titleColorService = titleColorService;
}

public IActionResult Index()
{
    var homeModel = new HomeModel
    {
        Configuration = _homePageTitleConfiguration
    };

    homeModel.Configuration.Color = _titleColorService.GetTitleColor();

    return View(homeModel);
}

That’s about it. Or is it?

Why IOptionsMonitor Works Where IOptionsSnapshot Does Not

Now if we run the application, it crashes and we get the following exception message:

Some services are not able to be constructed (Error while validating the service descriptor ‘ServiceType: ProjectConfigurationDemo.Services.ITitleColorService Lifetime: Singleton ImplementationType: ProjectConfigurationDemo.Services.TitleColorService’: Cannot consume scoped service ‘Microsoft.Extensions.Options.IOptionsSnapshot`1[ProjectConfigurationDemo.Models.TitleConfiguration]’ from singleton ‘ProjectConfigurationDemo.Services.ITitleColorService’.)

This happens because ASP.NET Core is trying to prevent us from making a mistake of referencing a scoped service from a singleton. It’s a classic mistake and it could result in unexpected behavior otherwise. To put it in simple terms if the parent is a singleton, we can’t create a child service per page loaded. Child service has to be singleton or transient instead.

And that’s exactly where IOptionsMonitor comes in.

Let’s go back to our service and replace IOptionsSnapshot with IOptionsMonitor:

public class TitleColorService : ITitleColorService
{
    private readonly string[] _colors = ["red", "green", "blue", "black", "purple", "yellow", "brown", "pink"];
    private readonly IOptionsMonitor<TitleConfiguration> _titleConfiguration;

    public TitleColorService(IOptionsMonitor<TitleConfiguration> titleConfiguration)
    {
        _titleConfiguration = titleConfiguration;
    }

    public string GetTitleColor()
    {
        var configuration = _titleConfiguration.CurrentValue;

        return configuration.UseRandomTitleColor
            ? _colors[Random.Shared.Next(_colors.Length)]
            : configuration.Color;
    }
}

The service now holds the IOptionsMonitor<TitleConfiguration> itself and reads CurrentValue inside GetTitleColor, rather than Value in the constructor. That placement is the whole point: reading CurrentValue once in the constructor and storing the result would compile, run, and quietly behave exactly like IOptions<T>, because the value would be captured and never read again.

Now if we run the application again we’re not getting the exception we did before. If we refresh the page, our title screen shows up in different colors we’ve defined previously.

Great!

There is one more concept we need to cover.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

What Are Named Options and When Do We Use Them?

Named options bind several configuration sections to the same class, each under its own name. Two page sections with identical shapes share one TitleConfiguration class instead of needing two.

Registration takes the name as its first argument: builder.Services.Configure<TitleConfiguration>("HomePage", builder.Configuration.GetSection("Pages:HomePage")), then the same line again for "ProductPage".

Reading takes the name too. IOptionsSnapshot<T> and IOptionsMonitor<T> both expose .Get(name) for this, and IOptions<T> does not support named options at all, which is the one hard constraint in the feature.

Names are case-sensitive. "HomePage" and "homepage" are two different options instances, and asking for one that was never configured returns a default-constructed object rather than throwing, so a typo shows up as empty properties rather than as an error.

Keep the names in constants on the options class rather than as string literals scattered through registration and reading code. The compiler cannot help with a literal, and a renamed section becomes a silent empty object.

Here is the configuration that makes the case for the feature:

"Pages": {
    "HomePage": {
        "WelcomeMessage": "Welcome to the ProjectConfigurationDemo Home Page",
        "ShowWelcomeMessage": true,
        "Color": "red",
        "UseRandomTitleColor": true
    },
    "ProductPage": {
        "WelcomeMessage": "Welcome to the ProjectConfigurationDemo Product Page",
        "ShowWelcomeMessage": true,
        "Color": "black",
        "UseRandomTitleColor": false
    }
},

We have the same configuration structure for the different configuration subsections of the section “Pages”. Both the “HomePage” and the “ProductPage” have the exact same configuration properties, so one TitleConfiguration class covers both. In our Program class we should configure:

builder.Services.Configure<TitleConfiguration>("HomePage",
    builder.Configuration.GetSection("Pages:HomePage"));
builder.Services.Configure<TitleConfiguration>("ProductPage",
    builder.Configuration.GetSection("Pages:ProductPage"));

Now both subsections are mapped to the same configuration class, which makes sense. We don’t want to create multiple classes with the same properties and just name them differently. This is a much better way of doing it.

Calling the specific option is now done using the Get() method, so we need to refactor our TitleColorService class a bit:

public class TitleColorService : ITitleColorService
{
    private readonly string[] _colors = ["red", "green", "blue", "black", "purple", "yellow", "brown", "pink"];
    private readonly IOptionsMonitor<TitleConfiguration> _titleConfiguration;

    public TitleColorService(IOptionsMonitor<TitleConfiguration> titleConfiguration)
    {
        _titleConfiguration = titleConfiguration;
    }

    public string GetTitleColor(string pageTitleConfiguration)
    {
        var configuration = _titleConfiguration.Get(pageTitleConfiguration);

        return configuration.UseRandomTitleColor
            ? _colors[Random.Shared.Next(_colors.Length)]
            : configuration.Color;
    }
}

We need to change the ITitleColorService interface as well:

public interface ITitleColorService
{
    string GetTitleColor(string pageTitleConfiguration);
}

And change the HomeController accordingly:

public HomeController(ILogger<HomeController> logger,
    IOptionsSnapshot<TitleConfiguration> homePageTitleConfiguration,
    ITitleColorService titleColorService)
{
    _logger = logger;
    _homePageTitleConfiguration = homePageTitleConfiguration.Get("HomePage");
    _titleColorService = titleColorService;
}

public IActionResult Index()
{
    var homeModel = new HomeModel
    {
        Configuration = _homePageTitleConfiguration
    };

    homeModel.Configuration.Color = _titleColorService.GetTitleColor("HomePage");

    return View(homeModel);
}

That’s it, now if we run the application, we’ll see exactly the same result as before.

Two named registrations are one way to vary the same class across a single application. Another is to register services differently per environment, where the decision is made once at startup instead of on every read.

Let’s summarize.

IOptions vs IOptionsSnapshot vs IOptionsMonitor

Start with IOptions<T>. It is a singleton, it goes anywhere, and most configuration never changes while an application is running.

Move to IOptionsSnapshot<T> when values should be re-read and the class reading them is scoped or transient, so a controller, a request handler, or a scoped service. Each request sees a consistent set of values, computed once and cached for that request.

Move to IOptionsMonitor<T> when the class is a singleton. It is the only one of the three a singleton can take, it exposes .CurrentValue instead of .Value, and it can call back through .OnChange() when a value is replaced.

One trap accounts for most of the confusion here. Reading .CurrentValue once in a singleton’s constructor and storing the result gives back exactly the behaviour of IOptions<T>, because the value is captured and never read again. Read it where it is used.

IOptions<T>IOptionsSnapshot<T>IOptionsMonitor<T>
Registered asSingletonScopedSingleton
Can be injected into a singletonYesNoYes
Reads changed values after startupNoYes, once per requestYes, on demand
Property to read.Value.Value.CurrentValue
Named optionsNoYes, via .Get(name)Yes, via .Get(name)
Change notificationsNoNoYes, via .OnChange(…)

Change notifications reach the application only from file-based providers (JSON, INI, XML, user secrets and key-per-file), so an environment variable edited after startup does not show up no matter which interface is used.

How Do We Validate Options?

A bound options class can carry validation rules, so a missing or nonsensical setting fails loudly instead of arriving as null.

The registration form changes slightly. builder.Services.AddOptions<TitleConfiguration>().Bind(builder.Configuration.GetSection("Pages:HomePage")).ValidateDataAnnotations() replaces the plain Configure<T> call and turns [Required] and [Range] attributes on the class into real checks.

By default those checks run the first time the options are resolved, which means a bad value surfaces on the first request that needs it rather than at startup. Chaining .ValidateOnStart() moves the failure to startup, where a misconfigured deployment belongs.

More involved rules move out of the registration and into a class implementing IValidateOptions<T>: one setting depending on another, a value checked against an allowed list, or a rule that needs another service to evaluate.

The next article in this series covers all three approaches, including the source generator that writes the validator for us at compile time.

If compile-time checking is what we are after, the shortest route to it is to validate options with a source generator, which removes the reflection the data-annotations path relies on.

Configuration classes multiply fast once an application has a database, a mail sender, a token issuer and a cache to configure. The Ultimate ASP.NET Core Web API course wires all of them into one project and shows where the options pattern stops being convenient and starts being necessary.

Conclusion

In this article, we’ve learned what the options pattern is and why it’s worth reaching for. We’ve also covered the three interfaces that deliver a bound options class, what separates them, and which one belongs in which service lifetime.

The short version is the one in the table above: IOptions<T> unless the values have to be re-read, IOptionsSnapshot<T> for scoped consumers, and IOptionsMonitor<T> for singletons, read where it is used rather than captured in a constructor.

In the next article, we’ll cover options validation, and you can find other parts of this series on the ASP.NET Core Web API page.

Tested with .NET 10.0.10.