How to Handle Options in ASP.NET Core Better

Configuration settings are an integral part of applications. The default configuration file in ASP.NET Core is the appsettings.json file. There are several approaches to reading the settings for your application.

Let’s look at the common approach and how we can do it better.

Common Approach

In appsettings.json, we have the ExternalServiceOptions section. 

{
  "ExternalServiceOptions": {
    "Enabled": true,
    "Url": "http://localhost:3000",
    "ApiKey": "api-key"
  }
}

We need to create a class for this section.

public class ExternalServiceOptions
{
    public string Url { get; set; }
    public bool Enabled { get; set; }
    public string ApiKey { get; set; }
}

The section reading and registration in the DI container are as follows.

var builder = WebApplication.CreateBuilder(args);

var options = new ExternalServiceOptions();
builder.Configuration
     .GetRequiredSection(nameof(ExternalServiceOptions))
     .Bind(options);

builder.Services.AddSingleton(options);

var app = builder.Build();

The GetRequiredSection method was introduced in .NET 6. It throws an exception if a section is missing in the settings. For optional sections, you can use the GetSection method. 

System.InvalidOperationException: ‘Section ‘ExternalServiceOptions’ not found in configuration.’

Now, you can inject ExternalServiceOptions into your endpoints or services.

As this approach looks straightforward, it still requires too many lines of code for such a simple thing. We must create an empty object, bind it with options, and register it in the DI Container. 

Let’s see how we can do it better. 

Options Pattern

The Options pattern is an alternative approach to load configuration. It automatically binds options and registers in the DI container for us.

var builder = WebApplication.CreateBuilder(args);

var config = builder.Configuration;
builder.Services.Configure<ExternalServiceOptions>(
    config.GetRequiredSection(nameof(ExternalServiceOptions)));

var app = builder.Build();

app.MapGet("/options", (IOptions<ExternalServiceOptions> options) =>
{
    var _ = options.Value; // <== ExternalServiceOptions object
});

As you can see, the Configure extension method of IServiceCollection does binding and registering in the DI container for us in one line.
However, it’s only an advantage of the Options Pattern.

Besides the IOptions interface, there is IOptionsSnapshot

app.MapGet("/options", (IOptionsSnapshot<ExternalServiceOptions> options) =>
{
    var _ = options.Value; // <== ExternalServiceOptions object
});

And IOptionsMonitor.

app.MapGet("/options", (IOptionsMonitor<ExternalServiceOptions> options) =>
{
    var _ = options.CurrentValue; // <== ExternalServiceOptions object
});

You don’t need to change the options registration. Just inject the chosen interface.

IOptions interface doesn’t support the configuration reading after the application has started.

However, IOptionsSnapshot and IOptionsMonitor can update options while the application is running. The difference is that IOptionsSnapshot is registered as Scoped and can’t be used in the Singleton services, and IOptionsMonitor is registered as Singleton and can be used in every service.

Additionally, IOptionsMonitor has an OnChange method that allows you to subscribe for live configuration changes. 

Also, both interfaces support Named options, unlike the IOptions interface. The named options are helpful when you have multiple configuration sections bind to the same properties.

For example, SubscriptionPrices has Monthly and Yearly prices with the same Price properties. 

"SubscriptionPrices": {
  "Monthly": {
    "Price": 10.0
  },
  "Yearly": {
    "Price": 100.0
  }
}

builder.Services
     .Configure<SubscriptionPrices>("Monthly",
        config.GetRequiredSection("SubscriptionPrices:Monthly"))
     .Configure<SubscriptionPrices>("Yearly",
        config.GetRequiredSection("SubscriptionPrices:Yearly"));
        
app.MapGet("/options", (IOptionsMonitor<SubscriptionPrices> options) =>
{
    var monthlyPrice = options.Get("Monthly");
    var yearlyPrice = options.Get("Yearly");
});

public class SubscriptionPrices
{
    public decimal Price { get; set; }
}

The Options pattern also supports options validation. To do it, you need to use OptionsBuilder and change the options registration slightly. 

builder.Services
   .AddOptions()
   .Bind(config.GetRequiredSection(nameof(ExternalServiceOptions)))
   .ValidateDataAnnotations();

public class ExternalServiceOptions
{
    public string Url { get; set; }
    public bool Enabled { get; set; }

    [RegularExpression(@"^[a-zA-Z''-'\s]{1,40}$")]
    public string ApiKey { get; set; }
}

The validation is done when the first use of options occurs. If you want to validate options on the application start, you can do that with the ValidateOnStart method.

builder.Services
     .AddOptions<ExternalServiceOptions>()
     .Bind(config.GetRequiredSection(nameof(ExternalServiceOptions)))
     .ValidateDataAnnotations()
     .ValidateOnStart();

The OptionsBuilder is for streamlining the configuration of your options.

Also, if you need to update your options after configuration, you can do that with the PostConfigure method. 

builder.Services.PostConfigure<ExternalServiceOptions>(options =>
   {
       options.Enabled = true;
   });

The PostConfigure runs after all configurations. 

Summary

The Options pattern is flexible and powerful. 

It makes it easy to configure your application options, allows options validation, supports Named options, and can do live options updates while your application runs.

Scroll to Top