Options pattern in .NET
The options pattern uses classes to provide strongly-typed access to groups of related settings. When configuration settings are isolated by scenario into separate classes, the app adheres to two important software engineering principles:
- The Interface Segregation Principle (ISP) or Encapsulation: Scenarios (classes) that depend on configuration settings depend only on the configuration settings that they use.
- Separation of Concerns: Settings for different parts of the app aren't dependent or coupled to one another.
Options also provide a mechanism to validate configuration data. For more information, see the Options validation section.
Bind hierarchical configuration
The preferred way to read related configuration values is using the options pattern. The options pattern is possible through the IOptions<TOptions> interface, where the generic type parameter TOptions is constrained to class. The IOptions<TOptions> can later be provided through dependency injection. For more information, see Dependency injection in .NET.
For example, to read the following configuration values:
"TransientFaultHandlingOptions": {
"Enabled": true,
"AutoRetryDelay": "00:00:07"
},
Create the following TransientFaultHandlingOptions class:
public class TransientFaultHandlingOptions
{
public bool Enabled { get; set; }
public TimeSpan AutoRetryDelay { get; set; }
}
When using the options pattern, an options class:
- Must be non-abstract with a public parameterless constructor
- Contain public read-write properties to bind (fields are not bound)
The following code:
- Calls ConfigurationBinder.Bind to bind the
TransientFaultHandlingOptionsclass to the"TransientFaultHandlingOptions"section. - Displays the configuration data.
IConfigurationRoot configurationRoot = configuration.Build();
TransientFaultHandlingOptions options = new();
configurationRoot.GetSection(nameof(TransientFaultHandlingOptions))
.Bind(options);
Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");
In the preceding code, changes to the JSON configuration file after the app has started are read.
ConfigurationBinder.Get<T> binds and returns the specified type. ConfigurationBinder.Get<T> may be more convenient than using ConfigurationBinder.Bind. The following code shows how to use ConfigurationBinder.Get<T> with the TransientFaultHandlingOptions class:
IConfigurationRoot configurationRoot = configuration.Build();
var options =
configurationRoot.GetSection(nameof(TransientFaultHandlingOptions))
.Get<TransientFaultHandlingOptions>();
Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");
In the preceding code, changes to the JSON configuration file after the app has started are read.
Important
The ConfigurationBinder class exposes several APIs, such as .Bind(object instance) and .Get<T>() that are not constrained to class. When using any of the Options interfaces, you must adhere to aforementioned options class constraints.
An alternative approach when using the options pattern is to bind the "TransientFaultHandlingOptions" section and add it to the dependency injection service container. In the following code, TransientFaultHandlingOptions is added to the service container with Configure and bound to configuration:
services.Configure<TransientFaultHandlingOptions>(
configurationRoot.GetSection(
key: nameof(TransientFaultHandlingOptions)));
To access both the services and the configurationRoot objects, you must use the ConfigureServices method — the IConfiguration is available as the HostBuilderContext.Configuration property.
Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
var configurationRoot = context.Configuration;
services.Configure<TransientFaultHandlingOptions>(
configurationRoot.GetSection(nameof(TransientFaultHandlingOptions)));
});
Tip
The key parameter is the name of the configuration section to search for. It does not have to match the name of the type that represents it. For example, you could have a section named "FaultHandling" and it could be represented by the TransientFaultHandlingOptions class. In this instance, you'd pass "FaultHandling" to the GetSection function instead. The nameof operator is used as a convenience when the named section matches the type it corresponds to.
Using the preceding code, the following code reads the position options:
using Microsoft.Extensions.Options;
namespace ConsoleJson.Example;
public class ExampleService
{
private readonly TransientFaultHandlingOptions _options;
public ExampleService(IOptions<TransientFaultHandlingOptions> options) =>
_options = options.Value;
public void DisplayValues()
{
Console.WriteLine($"TransientFaultHandlingOptions.Enabled={_options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={_options.AutoRetryDelay}");
}
}
In the preceding code, changes to the JSON configuration file after the app has started are not read. To read changes after the app has started, use IOptionsSnapshot.
Options interfaces
- Does not support:
- Reading of configuration data after the app has started.
- Named options
- Is registered as a Singleton and can be injected into any service lifetime.
- Is useful in scenarios where options should be recomputed on every injection resolution, in scoped or transient lifetimes. For more information, see Use IOptionsSnapshot to read updated data.
- Is registered as Scoped and therefore cannot be injected into a Singleton service.
- Supports named options
- Is used to retrieve options and manage options notifications for
TOptionsinstances. - Is registered as a Singleton and can be injected into any service lifetime.
- Supports:
- Change notifications
- Named options
- Reloadable configuration
- Selective options invalidation (IOptionsMonitorCache<TOptions>)
IOptionsFactory<TOptions> is responsible for creating new options instances. It has a single Create method. The default implementation takes all registered IConfigureOptions<TOptions> and IPostConfigureOptions<TOptions> and runs all the configurations first, followed by the post-configuration. It distinguishes between IConfigureNamedOptions<TOptions> and IConfigureOptions<TOptions> and only calls the appropriate interface.
IOptionsMonitorCache<TOptions> is used by IOptionsMonitor<TOptions> to cache TOptions instances. The IOptionsMonitorCache<TOptions> invalidates options instances in the monitor so that the value is recomputed (TryRemove). Values can be manually introduced with TryAdd. The Clear method is used when all named instances should be recreated on demand.
Options interfaces benefits
Using a generic wrapper type gives you the ability to decouple the lifetime of the option from the DI container. The IOptions<TOptions>.Value interface provides a layer of abstraction, including generic constraints, on your options type. This provides the following benefits:
- The evaluation of the
Tconfiguration instance is deferred to the accessing of IOptions<TOptions>.Value, rather than when it is injected. This is important because you can consume theToption from various places and choose the lifetime semantics without changing anything aboutT. - When registering options of type
T, you do not need to explicitly register theTtype. This is a convenience when you're authoring a library with simple defaults, and you don't want to force the caller to register options into the DI container with a specific lifetime. - From the perspective of the API, it allows for constraints on the type
T(in this case,Tis constrained to a reference type).
Use IOptionsSnapshot to read updated data
When you use IOptionsSnapshot<TOptions>, options are computed once per request when accessed and are cached for the lifetime of the request. Changes to the configuration are read after the app starts when using configuration providers that support reading updated configuration values.
The difference between IOptionsMonitor and IOptionsSnapshot is that:
IOptionsMonitoris a singleton service that retrieves current option values at any time, which is especially useful in singleton dependencies.IOptionsSnapshotis a scoped service and provides a snapshot of the options at the time theIOptionsSnapshot<T>object is constructed. Options snapshots are designed for use with transient and scoped dependencies.
The following code uses IOptionsSnapshot<TOptions>.
using Microsoft.Extensions.Options;
namespace ConsoleJson.Example;
public class ScopedService
{
private readonly TransientFaultHandlingOptions _options;
public ScopedService(IOptionsSnapshot<TransientFaultHandlingOptions> options) =>
_options = options.Value;
public void DisplayValues()
{
Console.WriteLine($"TransientFaultHandlingOptions.Enabled={_options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={_options.AutoRetryDelay}");
}
}
The following code registers a configuration instance which TransientFaultHandlingOptions binds against:
services.Configure<TransientFaultHandlingOptions>(
configurationRoot.GetSection(
nameof(TransientFaultHandlingOptions)));
In the preceding code, changes to the JSON configuration file after the app has started are read.
IOptionsMonitor
The following code registers a configuration instance which TransientFaultHandlingOptions binds against.
services.Configure<TransientFaultHandlingOptions>(
configurationRoot.GetSection(
nameof(TransientFaultHandlingOptions)));
The following example uses IOptionsMonitor<TOptions>:
using Microsoft.Extensions.Options;
namespace ConsoleJson.Example;
public class MonitorService
{
private readonly IOptionsMonitor<TransientFaultHandlingOptions> _monitor;
public MonitorService(IOptionsMonitor<TransientFaultHandlingOptions> monitor) =>
_monitor = monitor;
public void DisplayValues()
{
TransientFaultHandlingOptions options = _monitor.CurrentValue;
Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");
}
}
In the preceding code, changes to the JSON configuration file after the app has started are read.
Tip
Some file systems, such as Docker containers and network shares, may not reliably send change notifications. When using the IOptionsMonitor<TOptions> interface in these environments, set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes. The interval at which changes are polled is every four seconds and is not configurable.
For more information on Docker containers, see Containerize a .NET app.
Named options support using IConfigureNamedOptions
Named options:
- Are useful when multiple configuration sections bind to the same properties.
- Are case-sensitive.
Consider the following appsettings.json file:
{
"Features": {
"Personalize": {
"Enabled": true,
"ApiKey": "aGEgaGEgeW91IHRob3VnaHQgdGhhdCB3YXMgcmVhbGx5IHNvbWV0aGluZw=="
},
"WeatherStation": {
"Enabled": true,
"ApiKey": "QXJlIHlvdSBhdHRlbXB0aW5nIHRvIGhhY2sgdXM/"
}
}
}
Rather than creating two classes to bind Features:Personalize and Features:WeatherStation,
the following class is used for each section:
public class Features
{
public const string Personalize = nameof(Personalize);
public const string WeatherStation = nameof(WeatherStation);
public bool Enabled { get; set; }
public string ApiKey { get; set; }
}
The following code configures the named options:
ConfigureServices(services =>
{
services.Configure<Features>(
Features.Personalize,
Configuration.GetSection("Features:Personalize"));
services.Configure<Features>(
Features.WeatherStation,
Configuration.GetSection("Features:WeatherStation"));
});
The following code displays the named options:
public class Service
{
private readonly Features _personalizeFeature;
private readonly Features _weatherStationFeature;
public Service(IOptionsSnapshot<Features> namedOptionsAccessor)
{
_personalizeFeature = namedOptionsAccessor.Get(Features.Personalize);
_weatherStationFeature = namedOptionsAccessor.Get(Features.WeatherStation);
}
}
All options are named instances. IConfigureOptions<TOptions> instances are treated as targeting the Options.DefaultName instance, which is string.Empty. IConfigureNamedOptions<TOptions> also implements IConfigureOptions<TOptions>. The default implementation of the IOptionsFactory<TOptions> has logic to use each appropriately. The null named option is used to target all of the named instances instead of a specific named instance. ConfigureAll and PostConfigureAll use this convention.
OptionsBuilder API
OptionsBuilder<TOptions> is used to configure TOptions instances. OptionsBuilder streamlines creating named options as it's only a single parameter to the initial AddOptions<TOptions>(string optionsName) call instead of appearing in all of the subsequent calls. Options validation and the ConfigureOptions overloads that accept service dependencies are only available via OptionsBuilder.
OptionsBuilder is used in the Options validation section.
Use DI services to configure options
Services can be accessed from dependency injection while configuring options in two ways:
Pass a configuration delegate to Configure on OptionsBuilder<TOptions>.
OptionsBuilder<TOptions>provides overloads of Configure that allow use of up to five services to configure options:services.AddOptions<MyOptions>("optionalName") .Configure<ExampleService, ScopedService, MonitorService>( (options, es, ss, ms) => options.Property = DoSomethingWith(es, ss, ms));Create a type that implements IConfigureOptions<TOptions> or IConfigureNamedOptions<TOptions> and register the type as a service.
We recommend passing a configuration delegate to Configure, since creating a service is more complex. Creating a type is equivalent to what the framework does when calling Configure. Calling Configure registers a transient generic IConfigureNamedOptions<TOptions>, which has a constructor that accepts the generic service types specified.
Options validation
Options validation enables option values to be validated.
Consider the following appsettings.json file:
{
"MyCustomSettingsSection": {
"SiteTitle": "Amazing docs from Awesome people!",
"Scale": 10,
"VerbosityLevel": 32
}
}
The following class binds to the "MyCustomSettingsSection" configuration section and applies a couple of DataAnnotations rules:
using System.ComponentModel.DataAnnotations;
namespace ConsoleJson.Example;
public class SettingsOptions
{
public const string ConfigurationSectionName = "MyCustomSettingsSection";
[RegularExpression(@"^[a-zA-Z''-'\s]{1,40}$")]
public string SiteTitle { get; set; } = null!;
[Range(0, 1000,
ErrorMessage = "Value for {0} must be between {1} and {2}.")]
public int Scale { get; set; }
public int VerbosityLevel { get; set; }
}
In the preceding SettingsOptions class, the ConfigurationSectionName property contains the name of the configuration section to bind to. In this scenario, the options object provides the name of its configuration section.
Tip
The configuration section name is independent of the configuration object that it's binding to. In other words, a configuration section named "FooBarOptions" can be bound to an options object named ZedOptions. Although it might be common to name them the same, it's not necessary and can actually cause name conflicts.
The following code:
- Calls AddOptions to get an OptionsBuilder<TOptions> that binds to the
SettingsOptionsclass. - Calls ValidateDataAnnotations to enable validation using
DataAnnotations.
services.AddOptions<SettingsOptions>()
.Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
.ValidateDataAnnotations();
The ValidateDataAnnotations extension method is defined in the Microsoft.Extensions.Options.DataAnnotations NuGet package.
The following code displays the configuration values or the validation errors:
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ConsoleJson.Example;
public class ValidationService
{
private readonly ILogger<ValidationService> _logger;
private readonly IOptions<SettingsOptions> _config;
public ValidationService(
ILogger<ValidationService> logger,
IOptions<SettingsOptions> config)
{
_config = config;
_logger = logger;
try
{
SettingsOptions options = _config.Value;
}
catch (OptionsValidationException ex)
{
foreach (string failure in ex.Failures)
{
_logger.LogError(failure);
}
}
}
}
The following code applies a more complex validation rule using a delegate:
services.AddOptions<SettingsOptions>()
.Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
.ValidateDataAnnotations()
.Validate(config =>
{
if (config.Scale != 0)
{
return config.VerbosityLevel > config.Scale;
}
return true;
}, "VerbosityLevel must be > than Scale.");
IValidateOptions for complex validation
The following class implements IValidateOptions<TOptions>:
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace ConsoleJson.Example;
class ValidateSettingsOptions : IValidateOptions<SettingsOptions>
{
public SettingsOptions _settings { get; private set; }
public ValidateSettingsOptions(IConfiguration config) =>
_settings = config.GetSection(SettingsOptions.ConfigurationSectionName)
.Get<SettingsOptions>();
public ValidateOptionsResult Validate(string name, SettingsOptions options)
{
string result = "";
var rx = new Regex(@"^[a-zA-Z''-'\s]{1,40}$");
Match match = rx.Match(options.SiteTitle);
if (string.IsNullOrEmpty(match.Value))
{
result += $"{options.SiteTitle} doesn't match RegEx\n";
}
if (options.Scale < 0 || options.Scale > 1000)
{
result += $"{options.Scale} isn't within Range 0 - 1000\n";
}
if (_settings.Scale is 0 && _settings.VerbosityLevel <= _settings.Scale)
{
result += "VerbosityLevel must be > than Scale.";
}
return result != null
? ValidateOptionsResult.Fail(result)
: ValidateOptionsResult.Success;
}
}
IValidateOptions enables moving the validation code into a class.
Using the preceding code, validation is enabled in ConfigureServices with the following code:
services.Configure<SettingsOptions>(
Configuration.GetSection(
SettingsOptions.ConfigurationSectionName));
services.TryAddEnumerable(
ServiceDescriptor.Singleton
<IValidateOptions<SettingsOptions>, ValidateSettingsOptions>());
Options post-configuration
Set post-configuration with IPostConfigureOptions<TOptions>. Post-configuration runs after all IConfigureOptions<TOptions> configuration occurs, and can be useful in scenarios when you need to override configuration:
services.PostConfigure<CustomOptions>(customOptions =>
{
customOptions.Option1 = "post_configured_option1_value";
});
PostConfigure is available to post-configure named options:
services.PostConfigure<CustomOptions>("named_options_1", customOptions =>
{
customOptions.Option1 = "post_configured_option1_value";
});
Use PostConfigureAll to post-configure all configuration instances:
services.PostConfigureAll<CustomOptions>(customOptions =>
{
customOptions.Option1 = "post_configured_option1_value";
});