The Options Pattern
Bind a configuration section to a typed settings class once and inject it with IOptions<T> instead of reading string keys.
builder.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 withActivatorand then binds it, so a positional record throwsMissingMethodExceptionon the first.Value. Constructor binding only works withGetSection(...).Get<T>(). - Missing keys leave the property at its initial value; unknown keys are ignored.
TimeSpanreads"00:00:10", enums read names case-insensitively,Uri, arrays,List<T>andDictionary<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
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.
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.
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>.
{
"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