Binding and Validating Configuration

Copy a configuration section into a typed class, check it with attributes, and fail at startup rather than mid-request.

Syntaxbuilder.Services.AddOptions<T>().Bind(builder.Configuration.GetSection("Name")).ValidateDataAnnotations().ValidateOnStart();

Reading config["Mailer:RetryCount"] hands you a string, every time, in every file that needs it. Binding copies a configuration section into an ordinary class once, so the rest of the app works with typed properties. Validation then checks the values make sense before anything uses them.

Binding a section

Write a class whose property names match the keys in the section. The binder matches by name, ignoring case, and converts each string to the property type. There are two ways to register it, and they are the same thing:

// Shorthand
builder.Services.Configure<MailerOptions>(
    builder.Configuration.GetSection("Mailer"));

// Longhand, which can also take validation
builder.Services.AddOptions<MailerOptions>()
    .Bind(builder.Configuration.GetSection("Mailer"));

Either way the class is injected as IOptions<MailerOptions> and read through its Value property.

Failing at boot

ValidateDataAnnotations() turns attributes such as [Required] and [Range] into checks. ValidateOnStart() runs those checks while the host starts, so a bad value throws OptionsValidationException before the app accepts traffic. Without it, validation runs the first time something reads Value, which is usually inside a request: the deploy looks healthy and the errors start arriving later.

Calling curl http://localhost:5000/mailer against the example returns [email protected] retries=3. Change RetryCount to 99 and the process exits at startup instead.

Example

Example · csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// Stands in for the Mailer section of appsettings.json.
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
    ["Mailer:FromAddress"] = "[email protected]",
    ["Mailer:RetryCount"] = "3"
});

builder.Services.AddOptions<MailerOptions>()
    .Bind(builder.Configuration.GetSection(MailerOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

var app = builder.Build();

app.MapGet("/mailer", (IOptions<MailerOptions> options) =>
{
    MailerOptions mailer = options.Value;
    return $"{mailer.FromAddress} retries={mailer.RetryCount}";
});

app.Run();

public sealed class MailerOptions
{
    // Keeping the section name on the class stops it being retyped as a string.
    public const string SectionName = "Mailer";

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

    [Range(0, 10)]
    public int RetryCount { get; set; }
}

When to use it

  • Turn a Mailer section of appsettings.json into a MailerOptions class that services take as a constructor parameter.
  • Make a deploy with a missing connection string fail at startup so the rollout stops instead of serving errors.
  • Reject a page size of 10000 at boot rather than discovering it in a slow query log.

More examples

A rule that spans two properties

Data annotations check one property at a time, so a rule that compares two of them has nowhere to live. Validate takes a predicate over the whole object and the message to report when it returns false. Use it for the relationships that silently misbehave rather than crash: a hard limit below a soft limit, a timeout shorter than a retry delay, a page size above a maximum.

Example · csharp
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<CacheOptions>()
    .Bind(builder.Configuration.GetSection("Cache"))
    .ValidateDataAnnotations()
    // Validate takes a predicate and the message used when it returns false.
    .Validate(
        options => options.HardLimit >= options.SoftLimit,
        "Cache:HardLimit must be greater than or equal to Cache:SoftLimit")
    .ValidateOnStart();

var app = builder.Build();

app.MapGet("/cache", (IOptions<CacheOptions> options) =>
    $"{options.Value.SoftLimit}/{options.Value.HardLimit}");

app.Run();

public sealed class CacheOptions
{
    public int SoftLimit { get; set; }
    public int HardLimit { get; set; }
}

Validation in a class of its own

Reach for IValidateOptions<T> when the rule is long enough to deserve a test, or when it needs another service to check it. Returning ValidateOptionsResult.Fail with a message naming the configuration key is the difference between an operator fixing the deploy in a minute and filing a bug against you.

Example · csharp
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<StorageOptions>()
    .Bind(builder.Configuration.GetSection("Storage"))
    .ValidateOnStart();

// The validator is found through DI, so it can take dependencies of its own.
builder.Services.AddSingleton<IValidateOptions<StorageOptions>, StorageOptionsValidator>();

var app = builder.Build();
app.MapGet("/storage", (IOptions<StorageOptions> o) => o.Value.RootPath);
app.Run();

public sealed class StorageOptions
{
    public string RootPath { get; set; } = string.Empty;
}

public sealed class StorageOptionsValidator : IValidateOptions<StorageOptions>
{
    // name is the empty string for the unnamed options instance; a null name
    // means validate every named instance.
    public ValidateOptionsResult Validate(string? name, StorageOptions options)
    {
        if (!Path.IsPathRooted(options.RootPath))
        {
            return ValidateOptionsResult.Fail(
                "Storage:RootPath must be an absolute path");
        }

        return ValidateOptionsResult.Success;
    }
}

Reading values that change while the app runs

Three interfaces exist and the difference matters. IOptions<T> is a singleton built once, so a file edit never reaches it. IOptionsMonitor<T> is a singleton whose CurrentValue is recomputed after a reload, which is what you want for a value an operator may tune. IOptionsSnapshot<T> is scoped and fixed for one request. Prefer IOptions<T> unless the value genuinely has to change, because a setting that can change mid-process is a setting two parts of one request can disagree about.

Example · csharp
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// reloadOnChange: true makes the provider re-read the file after an edit.
builder.Configuration.AddJsonFile("tuning.json", optional: true, reloadOnChange: true);
builder.Services.Configure<TuningOptions>(builder.Configuration.GetSection("Tuning"));

var app = builder.Build();

// IOptions<T>.Value is built once for the life of the process.
app.MapGet("/fixed", (IOptions<TuningOptions> o) => o.Value.BatchSize);

// IOptionsMonitor<T>.CurrentValue is rebuilt after a reload.
app.MapGet("/live", (IOptionsMonitor<TuningOptions> o) => o.CurrentValue.BatchSize);

app.Run();

public sealed class TuningOptions
{
    public int BatchSize { get; set; } = 100;
}

Discussion

  • Be the first to comment on this lesson.