Configuration Handling

The Options Pattern Simplified

Easily use your configuration in your application

Andre Lopes
Checkout.com-techblog
8 min readMay 9, 2023

--

Image by StockSnap from Pixabay

Hi people!

The Options pattern is a way of configuring application settings in .NET. It allows you to define strongly-typed options classes your application can easily consume. This means you can avoid using magic strings or hard-coded values for your settings and instead use a more type-safe and flexible approach.

These application settings, or configurations, can come from multiple sources:

  • JSON files — appsettings.json or any other JSON file added to your configuration
  • Environment variables
  • Command line
  • And many others…

In the example below, we are adding environment variables as configuration to our application, aside from the default appsettings.json settings.

using api.Configuration;
using Api.Services;
using Microsoft.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

builder.Host
.ConfigureHostConfiguration((config) =>
{
config.AddJsonFile("hosting.json", optional: true);
})
.ConfigureAppConfiguration((host, config) =>
{
config.AddJsonFile("optional.json", optional: true);

config.AddEnvironmentVariables("App_");
config.AddCommandLine(args);
});

Somethings to note:

ConfigureHostConfiguration and ConfigureAppConfiguration work very similarly:

  • ConfigureHostConfiguration — Used when the host is being built, and it is used to configure the configuration settings for the host itself. This includes settings not specific to the application, such as the environment, logging, and other infrastructure-related settings. It also flows to the ConfigureAppConfiguration method. It also uses the project directory path to find files.
  • ConfigureAppConfiguration — Configure settings that are application specific or depend on the host configuration. It also uses the content root path as the relative path.

And as for the methods to add configurations:

  • AddJsonFile — Adds a custom JSON file to your configuration
  • AddEnvironmentVariable — Adds an environment variable to your configuration. MyOption=”MyOption” will add a setting named MyOption, MyOption:Property=”MyOption Property” will add a setting object MyOption with a property Property. With a prefix, for example App_, only the variables with that prefix will be added. E.g.: App_MyOption.
  • AddCommandLine — Adds command line arguments to your configuration. dotnet run /MySetting "Super Setting" will add a setting named MyOption with a string value Super Setting.

With this configurations overview, we can dive into the options pattern.

Let’s get started

Let’s say we have this appsettings.json file:

{
"Application": {
"Title": "My Super App",
"ConnectionString": "https://mydatabase.com/database-id",
"MaximumRetries": 5,
"RetryInterval": "00:30:00",
"IsLive": true
}
}

If we don’t use the Options Pattern, we’d have to inject the IConfiguration interface into our service and manually get the value we want.

[Route("[controller]")]
public class NewController : Controller
{
private readonly IConfiguration _configuration;

public NewController(IConfiguration configuration)
{
_configuration = configuration;
}

[HttpGet]
public IActionResult Get()
{
var title = _configuration.GetValue<string>("Application:Title");
return Ok(title);
}
}

With the options pattern, we introduce type-safety and more flexibility for accessing and using configuration.

We must first define a class representing our options to use the pattern.

public class ApplicationOptions
{
public const string Key = "Application";

public string Title { get; set; }

public string ConnectionString { get; set; }

public int MaximumRetries { get; set; }

public TimeSpan RetryInterval { get; set; }

public bool IsLive { get; set; }
}

A few things to note:

  • The property names need to match the ones defined in your configuration method.
  • The constant Key is defined as the option/setting section name in the configuration. Some prefer not to have it and name the section the same name as the class, e.g. ApplicationOptions, and then use nameof(ApplicationOptions) for binding the section. In my opinion, it is cleaner to name to have a Key property.
  • .NET tries to deserialize the values to the defined type like "00:30:00" to TimeSpan, if it can’t, it will throw an exception during configuration binding.

Now we need to bind the Application section from the configuration to our configuration object. We can do it in the Program.cs during the services definition with the IServiceCollection:

builder.Services
.AddOptions<ApplicationOptions>()
.Bind(builder.Configuration.GetSection(ApplicationOptions.Key));

We could also do it with the following:

builder.Services
.Configure<ApplicationOptions>(builder.Configuration.GetSection(ApplicationOptions.Key));

or

var applicationOptions = new ApplicationOptions();
builder.Configuration
.GetSection(ApplicationOptions.Key)
.Bind(applicationOptions);

But the first method returns the OptionsBuilder<T>, which allows us to work with our options during startup, such as adding validations.

Now that we configured our option, we can inject it through the IOptionsSnapshot<T> class and use it in our services, like:

app.MapGet("/get", (IOptions<ApplicationOptions> applicationOptions) =>
{
return Results.Ok(applicationOptions.Value.Title);
});

Or in a class with dependency injection:

[Route("[controller]")]
public class NewController : Controller
{
private readonly ApplicationOptions _applicationOptions;

public NewController(IOptionsSnapshot<ApplicationOptions> applicationOptions)
{
_applicationOptions = applicationOptions.Value;
}

[HttpGet]
public IActionResult Get()
{
return Ok(_applicationOptions.Title);
}
}

Note that I don’t inject directly ApplicationOptions but use get it through IOptions<T>. This is the configuration access mechanism provided by .NET when you bind a configuration in your service. There are three ways of accessing your option:

  • IOptions<T>
  • IOptionsSnapshot<T>
  • IOptionsMonitor<T>

IOptions vs IOptionsSnapshot vs IOptionsMonitor

All three options serve the same purpose and allow access to your options, but the difference is their lifecycle and how they handle and react to changes in your configurations. If you update the appsettings.json file in your server when your application is running, for example.

IOptions

This interface is registered as a Singleton and will read the configuration at the application's start. This means that the values with this interface will only change if you restart your application.

It is designed for scenarios where the options data is read-only and does not change frequently.

public class MyService
{
private readonly ConnectionStringOptions _options;

public MyService(IOptions<ApplicationOptions> options)
{
_options = options.Value;
}
}

IOptionsSnapshot

This interface reads the configuration on every request and is registered as Scoped, so you cannot use it inside singleton services.

It is useful when you need to have the most updated value of an option during a request.

public class MyService
{
private readonly ConnectionStringOptions _options;

public MyService(IOptionsSnapshot<ConnectionStringOptions> options)
{
_options = options.Value;
}
}

IOptionsMonitor

This interface enables you to retrieve the most updated value of an option. It also allows you to manage options notifications for an option.

It is registered as a Singleton, so it can be injected into any service.

There are two ways you can use it:

public class MyService
{
private readonly IOptionsMonitor<ApplicationOptions> _options;

public MyService(IOptionsMonitor<ApplicationOptions> options)
{
_options = options;
}

public void DoSomething()
{
Console.WriteLine(_options.CurrentValue.Title);
}
}

or

public class MyService
{
private readonly ApplicationOptions _options;
private readonly IDisposable _changeListener;

public MyService(IOptionsMonitor<ApplicationOptions> applicationOptions)
{
_options = applicationOptions.CurrentValue;

_changeListener = options.OnChange(options =>
{
_options = options.CurrentValue;
});
}

public void DoSomething()
{
Console.WriteLine(_options.Title);
}

~MyService()
{
_changeListener?.Dispose();
}
}

Note that the method OnChange returns an IDisposble for the registered listener. We use the destructor method ~MyService() to clean up the listener and properly clean up the memory used.

Options Validations

.NET offers some built-in resources to ensure your settings are properly validated. These tools allow you to easily validate the options properties without the need of having to do something like:

var title = builder.Configuration.GetValue<string>("Application:Title");

if (string.IsEmptyOrNull(title))
{
throw new ArgumentNullException(nameof(title));
}

Let’s check better ways of doing it.

Data Annotations

One straightforward way of doing it is to use data annotations.

We mark the properties we want to be validated with the attribute we marked them with.

public class ApplicationOptions
{
public const string Key = "Application";

[Required(ErrorMessage = "Title Required")]
public string Title { get; set; }

[Required(ErrorMessage = "ConnectionString Required")]
public string ConnectionString { get; set; }

[Required(ErrorMessage = "Maximum Retries Required")]
[Range(1, 10, ErrorMessage = "Maximum retries out of range (1 - 10)")]
public int MaximumRetries { get; set; }

[Required(ErrorMessage = "RetryInterval Required")]
public TimeSpan RetryInterval { get; set; }

public bool IsLive { get; set; }
}

For a list of all data annotation attributes, please check the documentation here https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?source=recommendations&view=aspnetcore-7.0

Now we can add the ValidateDataAnnotation extension method after binding it with AddOptions<T>().Bind(section) .

builder.Services
.AddOptions<ApplicationOptions>()
.Bind(builder.Configuration.GetSection(ApplicationOptions.Key))
.ValidateDataAnnotations();

This will validate the ApplicationOptions properties every time it is injected/used.

If you want to validate it on application start, which makes more sense for most cases, you can add theValidateOnStart() method.

builder.Services
.AddOptions<ApplicationOptions>()
.Bind(builder.Configuration.GetSection(ApplicationOptions.Key))
.ValidateDataAnnotations()
.ValidateOnStart();

If you need to have some custom validations, you can make use of the Validate method:

builder.Services
.AddOptions<ApplicationOptions>()
.Bind(builder.Configuration.GetSection(ApplicationOptions.Key))
.ValidateDataAnnotations()
.Validate(option => option.Title.Length <= 20, "Title too long.")
.Validate(option => option.RetryInterval.CompareTo(TimeSpan.FromMinutes(30)) < 1, "RetryInterval too long.")
.ValidateOnStart();

We can also clean this and delegate the validation to the proper service by taking advantage of the IValidateOptions<T> interface. With it, we separate our validation from the configuration, and it also allows us to use dependency injection easily.

For that, we need to create a new class, let’s say ApplicationOptionsValidation and make it implement the interface IValidateOptions<ApplicationOptions>. Then we need to implement the Validate method where you can return a ValidateOptionsResult.Fail(message) if the validation fails or ValidateOptionsResult.Success if the validation succeeds.

public class ApplicationOptionsValidation : IValidateOptions<ApplicationOptions>
{
private readonly ILogger<ApplicationOptionsValidation> _logger;

public ApplicationOptionsValidation(ILogger<ApplicationOptionsValidation> logger)
{
_logger = logger;
}

public ValidateOptionsResult Validate(string name, ApplicationOptions options)
{
_logger.LogInformation("Validating options");

if (options.Title.Length >= 20)
{
return ValidateOptionsResult.Fail("Title too long");
}

if (options.RetryInterval.CompareTo(TimeSpan.FromMinutes(30)) > 0)
{
return ValidateOptionsResult.Fail("RetryInterval too long");
}

return ValidateOptionsResult.Success;
}
}

Now you just need to register it in the Program.cs with:

builder.Services
.AddTransient<IValidateOptions<ApplicationOptions>, ApplicationOptionsValidation>();

Named Options

In case you have a configuration that shares the same properties and you don’t want to create different types for these configurations, you can make use of named options, which will allow you to bind multiple options to a type by a string name.

Let’s say you have this configuration JSON:

{
"WeatherService": {
"Name": "Weather API",
"ApiKey": "SuperSecretWeatherKey"
},
"EMailService": {
"Name": "Super Email",
"ApiKey": "ThisIsObviouslySecret"
}
}

Both WeatherService and MailService share the same properties, so we can have the following options class for them:

public class ExternalServiceOptions
{
public const string Weather = "WeatherService";
public const string EMail = "EMailService";

public string Name { get; set; }

public string ApiKey { get; set; }
}

To register them in the Program.cs you can pass a string to the AddOptions<T> that will be the desired name for that option.

builder.Services
.AddOptions<ExternalServiceOptions>(ExternalServiceOptions.Weather)
.Bind(builder.Configuration.GetSection(ExternalServiceOptions.Weather));

builder.Services
.AddOptions<ExternalServiceOptions>(ExternalServiceOptions.EMail)
.Bind(builder.Configuration.GetSection(ExternalServiceOptions.EMail));

And now, to get the desired option, we can just call the method Get(optionName) with the options access interface:

public class MyService
{
private readonly ExternalServiceOptions _weatherService;
private readonly ExternalServiceOptions _emailService;

public MyService(IOptionsSnapshot<ExternalServiceOptions> externalServiceOptions)
{
_weatherService = externalServiceOptions.Get(ExternalServiceOptions.Weather);
_emailService = externalServiceOptions.Get(ExternalServiceOptions.EMail);
}

public void MyMethod()
{
Console.WriteLine(_weatherService.Name);
Console.WriteLine(_emailService.Name);
}
}

Conclusion

In this story, you could check how useful the Options pattern can be by removing the need to use magical strings and hard-coded values.

You could see that the data can come from JSON files and other sources like environment values and command line arguments.

Not only is the pattern super useful, but it is also very easy to set up with just a few lines of code. And also, it is easy to react to changes in your configurations by using the IOptionsSnapshot for changes that happen before the start of a request or IOptionsMonitor for getting the most updated value of an option.

.NET also offers many tools to validate your options with data annotations or custom validations. You can even set the validations to happen at the application start.

And last, you learned how to reuse the same type/class to bind multiple option values.

Happy coding 💻!

--

--

Andre Lopes
Checkout.com-techblog

Full-stack developer | Casual gamer | Clean Architecture passionate