aspnet-core-configuration-options-pattern

Configuration and Options Pattern in ASP.NET Core

  • 4 min

The Options Pattern is the way to map configuration to typed classes, instead of reading individual values as strings.

This gives us real types, autocomplete, validation, and code that’s easier to test. That’s no small thing, because configuration tends to be everywhere (and when it fails, it fails hard).

Non-sensitive configuration values can live in appsettings.json: limits, section names, logging options, behavior flags, etc. For real secrets, like passwords or private keys, it’s better to use User Secrets, environment variables, or a secret store.

The problem with magic strings

Suppose we have this configuration in appsettings.json for sending emails:

{
  "SmtpSettings": {
    "Server": "smtp.gmail.com",
    "Port": 587,
    "SenderName": "No-Reply"
  }
}
Copied!

The quick way to read this would be to inject IConfiguration and access the keys manually:

var server = configuration["SmtpSettings:Server"];
var port = int.Parse(configuration["SmtpSettings:Port"]);
Copied!

It works, yes. But we’re paying several tolls:

  1. Typographical errors: if we type Sever instead of Server, we get null and we’ll see where it blows up.
  2. No real types: everything comes as text, so we have to convert integers, booleans, dates…
  3. Harder maintenance: keys are scattered throughout the code like little landmines.

Using IConfiguration directly isn’t forbidden. For reading a specific value in Program.cs it might be fine. The problem arises when that reading is repeated across services, controllers, and various classes.

The solution: Options Pattern

The Options Pattern consists of creating a C# class that represents a configuration section. Then .NET takes care of binding the JSON values to that class.

This way we go from this:

configuration["SmtpSettings:Server"]
Copied!

To this:

options.Value.Server
Copied!

Much better. The compiler is back on our side, which is where we like to have it.

Create the configuration class

We create a simple class (a POCO, basically) whose properties match the JSON keys:

public class SmtpOptions
{
    public const string SectionName = "SmtpSettings";

    public string Server { get; set; } = string.Empty;
    public int Port { get; set; }
    public string SenderName { get; set; } = string.Empty;
}
Copied!

Property names match the JSON ones. The binding is case-insensitive, so it doesn’t distinguish between uppercase and lowercase, although it’s normal to keep the same style to avoid confusion.

Register options in Program.cs

Now we tell the service container which section of the file to use to populate SmtpOptions.

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<SmtpOptions>(
    builder.Configuration.GetSection(SmtpOptions.SectionName)
);

var app = builder.Build();
Copied!

We could also write the string "SmtpSettings" directly, but using a constant avoids repeating the section name throughout the project.

Inject and use IOptions

Here’s an important detail: we don’t inject SmtpOptions directly. We inject IOptions<SmtpOptions>, which is the wrapper .NET uses to give us access to those options.

using Microsoft.Extensions.Options;

app.MapGet("/send-email", (IOptions<SmtpOptions> options) =>
{
    var config = options.Value;

    return $"Connecting to {config.Server}:{config.Port} as {config.SenderName}";
});
Copied!

Now Port is an int, Server is a real property, and if we rename something, the compiler can lend us a hand.

IOptions vs IOptionsSnapshot vs IOptionsMonitor

There are three common interfaces for reading options. They look very similar, but they are not used for the same thing.

InterfaceLifetimeReloads changesTypical use
IOptions<T>SingletonNoStable configuration for the application’s lifetime.
IOptionsSnapshot<T>ScopedYes, if the provider supports itOptions recalculated once per request.
IOptionsMonitor<T>SingletonYesSingleton services that need to read updated values or react to changes.

Be careful with IOptionsSnapshot<T>.

Since it’s scoped, we can’t inject it into a singleton service. If a singleton needs reloadable configuration, we use IOptionsMonitor<T>.

In most applications, IOptions<T> is enough. If we change configuration files on the fly or have long-lived services, then it makes sense to look at IOptionsSnapshot<T> or IOptionsMonitor<T>.

Validate configuration

There’s a rather treacherous failure: if we forget to put Port in the JSON, its value will be 0, because that’s the default value for an int.

We can validate the options at startup using Data Annotations.

using System.ComponentModel.DataAnnotations;

public class SmtpOptions
{
    public const string SectionName = "SmtpSettings";

    [Required]
    public string Server { get; set; } = string.Empty;

    [Range(1, 65535)]
    public int Port { get; set; }

    public string SenderName { get; set; } = string.Empty;
}
Copied!

And we register the options with validation:

builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection(SmtpOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();
Copied!

With ValidateOnStart(), the application fails at startup if the configuration is not valid.

If ValidateDataAnnotations() doesn’t appear, check that the project has the Microsoft.Extensions.Options.DataAnnotations package available.