In our previous post
, we explored the basics of .NET’s configuration system. When using configuration in your application, you’ve probably found yourself calling configuration["SomeKey"]
over and over again while worrying about typos or missing keys. Wouldn’t it be nice to group related settings into one class and access them as a single object? Well, that’s possible with the Options pattern. It allows us to bind grouped settings to strongly typed classes and inject them wherever they’re needed.
In this post, we’ll explore how the Options pattern works, how to set it up, and how it behaves behind the scenes.
This post is part of a series:
1. .NET configuration
2.Options pattern
More follow soon
What is the options pattern?
The Options pattern in .NET is designed to bind configuration sections to strongly typed classes. Instead of working with individual configuration keys, like configuration["SomeKey]"
, you can create a class to represent related settings. This approach makes your code cleaner, more maintainable, and easier to read.
The Options pattern embraces two fundamental software engineering principles:
- Encapsulation: Services access only the configuration settings they need and don’t have access to unrelated configuration values.
- Separation of concerns: Configuration settings for different parts of the application are kept isolated and don’t influence each other.
For example, if you have a section in your configuration file for database settings, you can group related values in a DatabaseSettings
class:
public class DatabaseSettings
{
public string ConnectionString { get; set; } = string.Empty;
public int MaxConnections { get; set; }
}
By grouping these settings, your service can access everything it needs through a single object.
Setting up the Options pattern
-
Define a configuration class
Create a non-abstract class to represent your configuration section. Abstract classes can only be used when using theBind()
method (see further).public class DatabaseSettings { public string ConnectionString { get; set; } = string.Empty; public int MaxConnections { get; set; } }
In this class
- each public property corresponds to a key in your configuration file.
- fields (even if public) are not bound, only properties with getters and setters are.
- initialize string properties to avoid null values at startup.
-
Add configuration in
Program.cs
Bind the configuration section to the class:builder.Services.Configure<DatabaseSettings>( builder.Configuration.GetSection("DatabaseSettings"));
This binds the
"DatabaseSettings"
section from your configuration file (appsettings.json
) to theDatabaseSettings
class. Example:{ "DatabaseSettings": { "ConnectionString": "Server=myserver;Database=mydb;", "MaxConnections": 10 } }
-
Access configuration using
IOptions<T>
To access the settings in your service, injectIOptions<DatabaseSettings>
:public class DatabaseService { private readonly DatabaseSettings _settings; public DatabaseService(IOptions<DatabaseSettings> options) { _settings = options.Value; // Access the bound settings } public void Connect() { Console.WriteLine($"Connecting to {_settings.ConnectionString}..."); } }
The configuration values are now accessible as a strongly typed object.
When you inject IOptions, the DI container uses an OptionsManager internally to:
- create an instance of your options class by reading the configuration section.
- cache the options instance in memory.
- return the same instance every time options.Value is called.
When you register options using
builder.Services.Configure()
, here’s what happens behind the scenes: TheIOptions
system registers a singletonOptionsManager
for each options class.OptionsManager
handles creating the options instance by running allIConfigureOptions
andIPostConfigureOptions
(more onIPostConfigureOptions
later in this post) implementations. After the options are created, they’re cached in memory. If you usereloadOnChange: true
for JSON files, the system can detect file changes and rebind the configuration values forIOptionsMonitor
andIOptionsSnapshot
, but not forIOptions
. The differences betweenIOptions
,IOptionsMonitor
andIOptionsSnapshot
will be described later in this post.
Binding vs. Get<T>()
When you are not registering the options classes in the DI container in the Program.cs
file, you can also get the configuration values using the Options pattern when needed. You can use binding or the Get()
method for this.
To bind configuration by using Get<T>()
:
var options = configuration.GetSection("DatabaseSettings").Get<DatabaseSettings>();
Console.WriteLine($"Connection string: {options.ConnectionString}");
And to use binding, the code would be the following:
ver options = new DatabaseSettings();
var options = configuration.GetSection("DatabaseSettings").Bind(options);
Console.WriteLine($"Connection string: {options.ConnectionString}");
In both cases, changes to the configuration file are picked up after the app has started.
The key difference between the 2 approaches is that
Get<T>()
creates a new instance of your settings each time it is called. For this to work, the options class must be a non-abstract class. TheGet()
method usesActivator.CreateInstance()
, which doesn’t track or cache the object. It simply creates a new instance based on the configuration data.Bind()
populates an existing instance. This instance can be a concrete or an abstract class.
Named options
Sometimes, different configurations are needed for the same type, such as when connecting to external APIs requiring different URLs and API keys. Instead of creating separate classes for each API configuration, you can use named options.
Example configuration
{
"ApiSettings": {
"WeatherApi": {
"BaseUrl": "https://api.weather.com",
"ApiKey": "abc-weather-api-key"
},
"StockMarketApi": {
"BaseUrl": "https://api.stockmarket.com",
"ApiKey": "xyz-stock-api-key"
}
}
}
Binding named options
Add named options in Program.cs
:
builder.Services.Configure<ApiSettings>("WeatherApi", builder.Configuration.GetSection("ApiSettings:WeatherApi"));
builder.Services.Configure<ApiSettings>("StockMarketApi", builder.Configuration.GetSection("ApiSettings:StockMarketApi"));
Accessing named options in a service
public class ApiService
{
private readonly ApiSettings _weatherApiSettings;
private readonly ApiSettings _stockMarketApiSettings;
public ApiService(IOptionsSnapshot<ApiSettings> namedOptionsAccessor)
{
_weatherApiSettings = namedOptionsAccessor.Get("WeatherApi");
_stockMarketApiSettings = namedOptionsAccessor.Get("StockMarketApi");
}
}
namedOptionsAccessor.Get("WeatherApi")
gives you the configuration for the weather API, while Get("StockMarketApi")
retrieves the configuration for the stock market API. This is useful when different APIs or services require their own configurations, but you want to avoid creating separate classes for each one.
Important note:
IOptions<T>
doesn’t support named options, soIOptionsSnapshot<T>
orIOptionsMonitor<T>
must be used for this scenario.
Options Interfaces and Caching Behavior
The IOptions
interface comes in 3 flavors: IOptions
, IOptionsSnapshot
and IOptionsMonitor
. Understanding how these behave differently, for example, in terms of caching and reloading, is important for choosing the proper interface.
IOptions: Singleton Cache
- Lifetime: Singleton, can be injected into any service lifetime
- Behavior: Options are created once at startup and cached for the application’s lifetime.
- Use Case: When configuration values don’t change after startup.
If the configuration file changes,
IOptions<T>
won’t reflect the new values until the app is restarted. TheOptionsManager
stores the configuration and returns the same instance for every request.
IOptionsSnapshot: Scoped Cache
- Lifetime: Scoped (one instance per request). Since it is Scoped, it can’t be injected into a Singleton service.
- Behavior: A new options instance is created for every request.
- Use Case: When configuration values might change between requests.
IOptionsSnapshot
uses anOptionsFactory
to create new options instances per request. The factory runs allIConfigureOptions
andIPostConfigureOptions
implementations every time a new request starts. Since this runs for every request, this can have an impact on the performance.
IOptionsMonitor: Singleton with Real-Time Updates
- Lifetime: Singleton, can be injected into any service lifetime
- Behavior: Automatically updates when the configuration source changes.
- Use Case: For long-running services or background jobs that need real-time updates.
IOptionsMonitor
subscribes to configuration change notifications. When the configuration file changes (for example, theappsettings.json
), the monitor recreates the options instance and triggers theOnChange
event.
Post-Configuration
Post-configuration allows you to change options after they’ve been loaded.
Post-Configure a Single Instance
builder.Services.PostConfigure<DatabaseSettings>(options =>
{
options.ConnectionString = "OverriddenConnectionString";
});
Post-Configure All Instances To apply post-configuration to all instances:
builder.Services.PostConfigureAll<DatabaseSettings>(options =>
{
options.MaxConnections = 50;
});
Post-configuration runs after all IConfigureOptions
implementations have been applied, making it ideal for overriding values without modifying the configuration source.
Summary of Options Behavior
Interface | Caching Behavior | Suitable for |
---|---|---|
IOptions<T> |
Cached for the lifetime of the application. | Static settings that don’t change at runtime. |
IOptionsSnapshot<T> |
Cached per request (discarded after request ends). | Per-request settings in web apps. |
IOptionsMonitor<T> |
Cached in memory, updated dynamically on changes. | Long-running services needing real-time updates. |
Wrapping Up
The .NET Options pattern provides a clean way to group and access configuration settings. By understanding the differences between IOptions<T>
, IOptionsSnapshot<T>
, and IOptionsMonitor<T>
, you can choose the most suitable option based on the requirements of your application. Additionally, named options and post-configuration add even more flexibility, allowing you to manage multiple configurations and override settings after they’ve been applied.
In the next post, we’ll look at how to validate options to ensure that your app doesn’t start with invalid configuration values. Stay tuned!