The Options Pattern

Bind a configuration section to a typed settings class once and inject it with IOptions<T> instead of reading string keys.

Syntaxbuilder.Services.AddOptions<PaymentOptions>() .BindConfiguration(PaymentOptions.SectionName); public sealed class PaymentGateway(IOptions<PaymentOptions> options) { }

Reading config["Payments:Timeout"] in ten places spreads string keys, parsing and defaults across the codebase. The options pattern binds a section to a class once, and code depends on that class through IOptions<T>.

Registration

The preferred form is builder.Services.AddOptions<PaymentOptions>().BindConfiguration("Payments"). It resolves IConfiguration from DI, returns an OptionsBuilder<T> you can chain validation onto, and wires up reload. Configure<T>(builder.Configuration.GetSection("Payments")) is the older equivalent. Put the section name in a constant on the class so the string lives in one place.

An options instance is built by running, in order: every Configure action and every Bind in registration order, then every PostConfigure, then validation. Later steps overwrite earlier ones property by property.

Binding rules

  • Public properties with a setter (or init) are bound, matched case-insensitively. An options type needs a public parameterless constructor: the options factory creates the instance with Activator and then binds it, so a positional record throws MissingMethodException on the first .Value. Constructor binding only works with GetSection(...).Get<T>().
  • Missing keys leave the property at its initial value; unknown keys are ignored.
  • TimeSpan reads "00:00:10", enums read names case-insensitively, Uri, arrays, List<T> and Dictionary<string, T> work.
  • Collections are appended to, not replaced. List<string> Reasons = ["Duplicate"] bound to ["Duplicate", "Fraud"] ends up with three items. Initialise collections empty.

IOptions<T> and when binding happens

IOptions<T> is a singleton. The instance is created on the first read of .Value and cached for the life of the process, so it never sees a reload. Creation is lazy: a value that cannot be converted, such as "ten" for an int, throws InvalidOperationException on the first request that touches it, not at start-up. Chain ValidateOnStart() to move that failure to start-up.

Inject IOptions<T> and read .Value in the constructor. Some teams register T itself with AddSingleton(sp => sp.GetRequiredService<IOptions<T>>().Value) to keep the interface out of domain classes; that works, at the cost of reload support.

Silent defaults

The options system resolves IOptions<T> for any class with a parameterless constructor, registered or not. A missing registration, or a section name typo such as "Payment", produces an instance full of defaults: an empty URL, a zero timeout. Nothing fails until a payment call does. Two habits prevent it: validate required values at start-up, and set ErrorOnUnknownConfiguration where a typo in a key is likely.

Native AOT

Binding uses reflection by default. Setting EnableConfigurationBindingGenerator to true in the project file generates the binding code at compile time instead; it is switched on automatically for Native AOT publishing.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Bind the Payments section to PaymentOptions once and inject it into the payment gateway client instead of reading string keys.
  • Normalise an invoice number prefix with PostConfigure so INV, inv and ' Inv ' from different environments all behave the same.
  • Catch a misspelled Inventory:ReservationWindw key at start-up with ErrorOnUnknownConfiguration and ValidateOnStart.

More examples

Configure, Bind and PostConfigure in order

<code>Configure</code> and <code>Bind</code> run in the order they were registered, so a <code>Configure</code> registered after the bind would overwrite the configured value. <code>PostConfigure</code> always runs after all of them, which makes it the right place for normalising values such as trimming and upper-casing a prefix, or deriving one setting from another. Library authors use <code>Configure</code> for defaults so that the application's own binding wins.

Example · csharp
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// 1. Code default, applied first.
builder.Services.Configure<InvoiceOptions>(o => o.DueInDays = 30);

// 2. Configuration overrides only the keys it contains.
builder.Services.AddOptions<InvoiceOptions>().BindConfiguration("Invoices");

// 3. Runs after every Configure and Bind, whatever the registration order.
builder.Services.PostConfigure<InvoiceOptions>(o =>
    o.NumberPrefix = o.NumberPrefix.Trim().ToUpperInvariant());

var app = builder.Build();

app.MapGet("/invoices/settings", (IOptions<InvoiceOptions> options) => TypedResults.Ok(options.Value));

app.Run();

public sealed class InvoiceOptions
{
    public int DueInDays { get; set; }
    public string NumberPrefix { get; set; } = "INV";
    public decimal LateFeePercent { get; set; } = 1.5m;
}

Failing on unknown keys

By default a misspelled key is ignored and the property keeps its default, which hides the typo forever. <code>ErrorOnUnknownConfiguration</code> makes the binder throw when the section holds a key with no matching property. Binding is lazy, so on its own the exception would appear on the first request; <code>ValidateOnStart()</code> builds the instance while the host starts, which turns both unknown keys and conversion errors into a start-up failure.

Example · csharp
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<InventoryOptions>()
    .BindConfiguration("Inventory", binder => binder.ErrorOnUnknownConfiguration = true)
    .ValidateOnStart();

var app = builder.Build();

app.MapGet("/inventory/reservation-window", (IOptions<InventoryOptions> options) =>
    TypedResults.Ok(new { minutes = options.Value.ReservationWindow.TotalMinutes }));

// With "Inventory": { "ReservationWindw": "00:20:00" } in appsettings.json the host
// does not start: 'ErrorOnUnknownConfiguration' was set ... not found: 'ReservationWindw'.
app.Run();

public sealed class InventoryOptions
{
    public TimeSpan ReservationWindow { get; set; } = TimeSpan.FromMinutes(15);
    public int LowStockThreshold { get; set; } = 10;
}

How values are written in appsettings.json

Configuration stores strings, so the binder converts each value with the type's converter. A <code>TimeSpan</code> written as <code>10</code> means ten days, not ten seconds, which is a classic way to end up with an HTTP timeout that never fires; write <code>00:00:10</code>. Dictionary keys come from the JSON property names, which lets an environment add or override one merchant account with <code>Payments__MerchantAccounts__UK</code>.

Example · json
{
  "Payments": {
    "Provider": "Adyen",                       // enum: by name, case-insensitive
    "BaseUrl": "https://checkout-test.payments.example/v71/",
    "Timeout": "00:00:10",                     // TimeSpan: [d.]hh:mm:ss
    "MaxRetries": 3,                           // numbers arrive as strings and are converted
    "SupportedCurrencies": [ "USD", "EUR" ],   // array or List<string>
    "MerchantAccounts": {                      // Dictionary<string, string>
      "US": "ShopUS_ECOM",
      "EU": "ShopEU_ECOM"
    }
  }
}

Discussion

  • Be the first to comment on this lesson.