In the first part of this configuration series, I covered:

  • Configuration sources
  • Providers
  • Precedence
  • IConfiguration
  • Binding

Getting configuration into a strongly typed class is only the first step. Once it’s bound, you still have to decide how it’s registered, how it’s validated, how it reacts to changes, and how it handles secrets. Let’s start with registration.

Key takeaways:

  • The options pattern isn’t IConfiguration + Bind() with extra steps: it adds DI integration, validation, configurable lifetimes, and named configuration.
  • One-line rule for picking an interface: use IOptions<T> if configuration never changes at runtime, IOptionsSnapshot<T> if it does and each request needs a consistent value, IOptionsMonitor<T> if it does and you just want the latest value plus change notifications.
  • Validation (Data Annotations, IValidatableObject, or a custom IValidateOptions<T>) and secrets (Secret Manager locally, Key Vault/Secrets Manager/env vars in production) are both covered below.

Options pattern

Microsoft’s documentation defines it like this:

The options pattern uses classes to provide strongly typed access to groups of related settings.

If you read the first post in this series, you already know IConfiguration + Bind() does something similar. So what’s different?

It brings in:

  • Dependency injection integration
  • Validation
  • Different lifetimes
  • Named configuration

A code example makes this concrete. I’ll reuse the payment example from the previous article.

IConfiguration

Binding

Options infrastructure

Validation / lifetimes / reloads / DI

Application service

The configuration:

{
  "Payments": {
    "BaseUrl": "https://api.example.com",
    "Timeout": 30,
    "RetryCount": 3
  }
}

And the class:

public sealed class PaymentOptions
{
    public const string SectionName = "Payments";

    public string BaseUrl { get; init; } = string.Empty;
    public int Timeout { get; init; }
    public int RetryCount { get; init; }
}

Keeping the section name as a constant on the options class itself avoids repeating the same magic string throughout the application.

The registration that uses it:

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

Personally, I’m not a fan of registering this way. I use this instead:

builder.Services
    .AddOptions<PaymentOptions>()
    .BindConfiguration(PaymentOptions.SectionName);

AddOptions<T>() returns an OptionsBuilder<T>, which lets me:

  • Compose registration easily
  • Validate configuration
  • Modify the bound instance after registration, on top of the regular binding

I’ll build on this example for the rest of the post. Consuming it looks like this:

public sealed class PaymentService(IOptions<PaymentOptions> paymentOptions, HttpClient httpClient)
{
    private readonly PaymentOptions _options = paymentOptions.Value;

    public async Task<PaymentResult> ChargeAsync(decimal amount, CancellationToken cancellationToken)
    {
        httpClient.BaseAddress = new Uri(_options.BaseUrl);
        httpClient.Timeout = TimeSpan.FromSeconds(_options.Timeout);

        var response = await httpClient.PostAsJsonAsync("charges", new { amount }, cancellationToken);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<PaymentResult>(cancellationToken: cancellationToken)
            ?? throw new InvalidOperationException("Empty response from payment provider.");
    }
}

public sealed record PaymentResult(string TransactionId, string Status);

Registered as a typed client, so HttpClient shows up through DI instead of being newed up by hand:

builder.Services.AddHttpClient<PaymentService>();

Consuming options

The options pattern exposes three interfaces:

IOptions<T>

This is the most widely used interface. It reads configuration of type T and is registered as a singleton. Most of the time you don’t see it directly, it just shows up in a constructor, the way PaymentService used it earlier. A readiness check is a realistic place to reach for it inside a handler:

app.MapGet("/health/payments", (IOptions<PaymentOptions> options) =>
    Results.Ok(new { provider = options.Value.BaseUrl }));

Notice what’s missing from that response: ApiKey. Returning options.Value as-is from an endpoint hands out whatever secrets happen to live on the same options class, so project only the fields you actually want exposed.

IOptions<T> gives you a static value. If configuration changes at runtime, you need to restart the app to see the new values, or use one of the next two interfaces.

IOptionsMonitor<T>

When configuration changes, this interface makes the new values available immediately. It’s also registered as a singleton, but it’s designed to expose the current value and notify consumers when it changes.

That makes it a good fit for an ops endpoint that confirms a config reload actually took effect, without restarting the app or digging through logs:

app.MapGet("/ops/payments/policy", (IOptionsMonitor<PaymentOptions> monitor) =>
    Results.Ok(new { monitor.CurrentValue.Timeout, monitor.CurrentValue.RetryCount }));

For that to work, the underlying configuration provider must support reloads:

builder.Configuration.AddJsonFile(
    "appsettings.json",
    optional: false,
    reloadOnChange: true);

It also supports reacting to a change notification directly:

var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("PaymentOptions");

app.Services.GetRequiredService<IOptionsMonitor<PaymentOptions>>().OnChange(options =>
    logger.LogInformation("Payments configuration changed. New BaseUrl: {BaseUrl}", options.BaseUrl));

How it picks up new values without an app restart: IOptionsMonitor<T> caches the created options instance. When a configuration change is detected, the cached instance is invalidated. The next time the option is requested, a new instance is created from the latest configuration values.

First access

Create options

Configuration changes

Invalidate cached instance

Next access

Recreate options

Cache instance

Next access

Return cached instance

When values are needed per request, the third interface is your choice.

IOptionsSnapshot<T>

The key difference from the previous two: it’s resolved fresh for every request instead of once for the whole app’s lifetime. Updated values show up too, but only starting with the next request.

That per-request consistency matters when a handler touches the same options more than once, like a charge endpoint that sizes the outgoing call and then reads the response back against those same values:

public sealed record ChargeRequest(decimal Amount);

app.MapPost("/payments/charge", async (
    ChargeRequest request,
    IOptionsSnapshot<PaymentOptions> options,
    HttpClient httpClient,
    CancellationToken cancellationToken) =>
{
    httpClient.BaseAddress = new Uri(options.Value.BaseUrl);
    httpClient.Timeout = TimeSpan.FromSeconds(options.Value.Timeout);

    var response = await httpClient.PostAsJsonAsync("charges", request, cancellationToken);
    response.EnsureSuccessStatusCode();

    return Results.Ok(await response.Content.ReadFromJsonAsync<PaymentResult>(cancellationToken: cancellationToken));
});

Both reads inside the handler see the same BaseUrl and Timeout, even if configuration reloads midway through the request. IOptionsMonitor<T> can’t promise that: CurrentValue reflects whatever is newest at the moment you read it, which could differ between the two calls.

Because of that lifetime, you can’t inject it into a singleton service. There are also open concerns about the cost of recomputing it on every request: see IOptionsSnapshot is very slow (dotnet/runtime #53793) and Improve the performance of configuration binding (dotnet/runtime #36130).

Picking between the three comes down to two questions:

Does config need to change while the app runs?Does each request/scope need a consistent value?Use
Non/aIOptions<T>: same value for the app’s lifetime
YesYesIOptionsSnapshot<T>: new snapshot per DI scope
YesNoIOptionsMonitor<T>: latest value, plus OnChange notifications

AddOptions<T>() registers all three under the hood. You pick whichever fits the use case.

Configuration validation

You can’t assume configuration is correct. What if a property that requires a positive number gets a negative one? What if a URL isn’t valid? That’s what validation is for.

Data Annotations

The most common way to validate configuration is Data Annotations: attributes that define metadata and validation rules on a class’s properties, the same attributes .NET 10’s Minimal APIs now run automatically against request contracts.

They go directly on the options class:

public sealed class PaymentOptions
{
    public const string SectionName = "Payments";

    [Required, Url]
    public string BaseUrl { get; init; } = string.Empty;

    [Range(1, 120)]
    public int Timeout { get; init; }

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

    [Required]
    public string ApiKey { get; init; } = string.Empty;
}

Registration adds one call:

builder.Services
    .AddOptions<PaymentOptions>()
    .BindConfiguration(PaymentOptions.SectionName)
    .ValidateDataAnnotations();

Validation runs when ValidateDataAnnotations() is invoked. Binding has to happen first.

If you’d rather keep properties and validation in one place, implement IValidatableObject instead. It adds a single Validate method that runs as part of the same ValidateDataAnnotations() call:

This replaces the attributes version above, not adds to it. PaymentOptions only ever has one shape at a time.

public sealed class PaymentOptions : IValidatableObject
{
    public const string SectionName = "Payments";

    public string BaseUrl { get; init; } = string.Empty;
    public int Timeout { get; init; }
    public int RetryCount { get; init; }
    public string ApiKey { get; init; } = string.Empty;

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out _))
            yield return new ValidationResult($"{nameof(BaseUrl)} must be a valid absolute URL.", [nameof(BaseUrl)]);

        if (Timeout is < 1 or > 120)
            yield return new ValidationResult($"{nameof(Timeout)} must be between 1 and 120 seconds.", [nameof(Timeout)]);

        if (RetryCount is < 0 or > 10)
            yield return new ValidationResult($"{nameof(RetryCount)} must be between 0 and 10.", [nameof(RetryCount)]);
    }
}

Custom validation

Skipping Data Annotations entirely is possible too, with an inline check chained directly onto the OptionsBuilder<T>:

builder.Services.AddOptions<PaymentOptions>()
    .Bind(builder.Configuration.GetSection(PaymentOptions.SectionName))
    .ValidateDataAnnotations()
    .Validate(options =>
    {
        return options.RetryCount <= options.Timeout;
    }, "RetryCount must be <= Timeout");

Honestly, I find this approach messy and hard to maintain. A dedicated validation class with the complete logic is a better fit:

public class PaymentOptionsValidation : IValidateOptions<PaymentOptions>
{
    public ValidateOptionsResult Validate(string? name, PaymentOptions options)
    {
        if (options == null)
        {
            return ValidateOptionsResult.Fail("PaymentOptions not found.");
        }

        StringBuilder? validationResult = new();
        var rx = new Regex(@"^[a-zA-Z0-9]{16,64}$");
        var match = rx.Match(options.ApiKey);

        if (string.IsNullOrEmpty(match.Value))
        {
            validationResult.Append($"{nameof(options.ApiKey)} doesn't match RegEx<br>");
        }

        if (options.RetryCount < 0 || options.RetryCount > 10)
        {
            validationResult.Append($"{nameof(options.RetryCount)} doesn't match Range 0 - 10<br>");
        }

        if (options.RetryCount > options.Timeout)
        {
            validationResult.Append("RetryCount must be <= Timeout<br>");
        }

        if (validationResult.Length > 0)
        {
            return ValidateOptionsResult.Fail(validationResult.ToString());
        }

        return ValidateOptionsResult.Success;
    }
}

Register the validator as a singleton after binding. DI resolves and runs it every time that configuration is requested:

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

builder.Services.AddSingleton<IValidateOptions<PaymentOptions>,
    PaymentOptionsValidation>();

You can register multiple IValidateOptions<PaymentOptions> implementations, and the framework runs all of them.

In a future post, I’ll cover validating configuration with FluentValidation.

ValidateOnStart()

It’s cheaper to find out configuration is invalid before you use it than after. That’s the job of ValidateOnStart(). Called after ValidateDataAnnotations(), it forces the options system to create and validate the configured options during application startup. If something’s wrong, it throws OptionsValidationException immediately instead of waiting for the first request that touches it.

builder.Services
    .AddOptions<PaymentOptions>()
    .BindConfiguration(PaymentOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

Secrets and sensitive configuration

Not all configuration should be visible or easily consumable. API keys, passwords, client secrets, and tokens need to stay hidden, which is why they belong in a secret store kept outside the project repository.

Secret Manager

Secret Manager stores sensitive data in a JSON file, for development only. It doesn’t encrypt anything; the only real benefit is that the file isn’t checked into source control, unlike appsettings.json.

First, enable it for the project:

dotnet user-secrets init

Then persist a value:

dotnet user-secrets set "Payments:ApiKey" "super-secret-key"

"Payments:ApiKey" is the key, "super-secret-key" is the value. It lands in the same Payments section as BaseUrl, Timeout, and RetryCount, without ever touching appsettings.json.

Reading it back is no different from any other option, it flows into whatever consumes PaymentOptions, like PaymentService from earlier:

app.MapPost("/payments/charge", async (
    ChargeRequest request,
    PaymentService payments,
    CancellationToken cancellationToken) =>
    Results.Ok(await payments.ChargeAsync(request.Amount, cancellationToken)));

That’s the point of the options pattern: the PaymentOptions instance is the same shape whether every property came from appsettings.json or ApiKey came from Secret Manager. The binding step doesn’t care which provider a value came from, and ApiKey never leaves the server. PaymentService reads it internally; no endpoint returns it.

Secrets in production

In production, secrets need to come from stores built to handle them:

  • Azure Key Vault
  • AWS Secrets Manager
  • Environment variables populated by your deployment infrastructure

There’s no code difference between these. The only thing that changes is where the secret originates.

Advanced features

Two features that don’t come up often, but are worth knowing:

  • Named options
  • PostConfigure

Named options

This leans on the same hierarchical key-value model from part 1: when you have the same configuration shape repeated under different sections with different values, named options let you reuse a single class instead of creating StripeOptions, PayPalOptions, and so on:

{
  "Payments": {
    "Stripe": {
      "BaseUrl": "https://api.stripe.com",
      "Timeout": 30
    },
    "PayPal": {
      "BaseUrl": "https://api.paypal.com",
      "Timeout": 60
    }
  }
}
public sealed class PaymentProviderOptions
{
    public string BaseUrl { get; init; } = string.Empty;
    public int Timeout { get; init; }
}
builder.Services
    .AddOptions<PaymentProviderOptions>("Stripe")
    .BindConfiguration("Payments:Stripe");

builder.Services
    .AddOptions<PaymentProviderOptions>("PayPal")
    .BindConfiguration("Payments:PayPal");

Consuming a named instance means resolving it by name at the call site, here to route a charge to whichever provider the caller picked:

app.MapPost("/payments/{provider}/charge", async (
    string provider,
    ChargeRequest request,
    IOptionsSnapshot<PaymentProviderOptions> options,
    HttpClient httpClient,
    CancellationToken cancellationToken) =>
{
    var providerOptions = options.Get(provider);
    httpClient.BaseAddress = new Uri(providerOptions.BaseUrl);
    httpClient.Timeout = TimeSpan.FromSeconds(providerOptions.Timeout);

    var response = await httpClient.PostAsJsonAsync("charges", request, cancellationToken);
    response.EnsureSuccessStatusCode();

    return Results.Ok(await response.Content.ReadFromJsonAsync<PaymentResult>(cancellationToken: cancellationToken));
});

PostConfigure

As the name suggests, PostConfigure runs after the normal configuration of an options instance completes. It’s useful for last-mile fixes that don’t belong in the class or the source data.

Suppose configuration contains:

{
  "Payments": {
    "BaseUrl": "https://api.example.com/"
  }
}

The trailing slash needs to go. PostConfigure handles it in one step:

builder.Services
    .AddOptions<PaymentOptions>()
    .BindConfiguration(PaymentOptions.SectionName);

builder.Services.PostConfigure<PaymentOptions>(options =>
{
    options.BaseUrl = options.BaseUrl.TrimEnd('/');
});

The resulting value:

BaseUrl = https://api.example.com

Summary

Binding configuration to a class is the easy part. The options pattern is what makes that class safe to depend on: the right lifetime for the consumer, validation before a bad value does damage, live updates where you need them, and secrets that never touch source control.