Named Options
Configure several instances of one settings class, such as two carrier APIs, and resolve each by name without mix-ups.
builder.Services.AddOptions<CarrierApiOptions>("Dhl").BindConfiguration("Carriers:Dhl");
builder.Services.AddOptions<CarrierApiOptions>("Ups").BindConfiguration("Carriers:Ups");
var dhl = monitor.Get("Dhl");An orders service that quotes shipping from two carriers needs the same settings twice: base URL, account number, timeout. Two near-identical classes would work, but the options system already supports this: every options instance has a name, and the same type can be configured differently per name.
Registering and resolving
AddOptions<CarrierApiOptions>("Dhl").BindConfiguration("Carriers:Dhl") binds one name to one section; Configure<T>("Ups", section) is the older form. Resolve with IOptionsMonitor<T>.Get("Dhl") in singletons or IOptionsSnapshot<T>.Get("Dhl") per request. Both cache per name.
IOptions<T> has no Get: its Value is always the default instance, whose name is Options.DefaultName, the empty string. Injecting IOptions<CarrierApiOptions> in a named setup gives you an instance nobody configured.
Shared defaults and ordering
ConfigureAll<T> and PostConfigureAll<T> apply to every name. Register ConfigureAll first to set defaults (an 8-second timeout for every carrier), then bind each name so configuration overrides it. The steps run in registration order per name, then post-configuration, then validation.
Configuration that needs services
AddOptions<T>(name).Configure<IHostEnvironment>((o, env) => ...) injects up to five dependencies. For anything larger, write a class implementing IConfigureNamedOptions<T> and register it with services.ConfigureOptions<TSetup>(). Implement the named interface: the options factory calls a plain IConfigureOptions<T> only for the default name, so a class that implements just Configure(T) never touches "Dhl" or "Ups".
Where you have already used them
Named options are everywhere in ASP.NET Core. Every AddHttpClient("Dhl") configures named HttpClientFactoryOptions; every authentication scheme is a named JwtBearerOptions or CookieAuthenticationOptions instance. Understanding names explains why services.Configure<JwtBearerOptions>(...) without a scheme name has no effect on the Bearer scheme.
Production failure modes
- Names are compared case-sensitively.
Get("dhl")returns a new, unconfigured instance rather than an error. Keep names in constants and validate. - A
.Validate(...)chain applies only to the name it was registered under, so register it per name, or register a generated[OptionsValidator], which validates every name, including the misspelled one. - A carrier added to configuration but not to the registration loop is ignored. Drive the loop from one list of names, and fail start-up when the configuration has sections the code does not know.
When the instances also need different behaviour, combine named options with keyed services: each keyed client resolves its own named settings.
Example
When to use it
- Quote shipping from two carrier APIs whose base URL, account number and timeout live under Carriers:Dhl and Carriers:Ups.
- Give every downstream HTTP API an 8-second default timeout with ConfigureAll while one slow partner overrides it to 20 seconds.
- Catch a Get("dhl") lookup that silently returned an empty base URL by registering a validator that covers every name.
More examples
One named HttpClient per carrier
The client name and the options name are the same string, so each named <code>HttpClient</code> picks up its own base address and timeout. <code>AddHttpClient</code> itself stores its setup as named <code>HttpClientFactoryOptions</code>, which is why the two mechanisms fit together. Validation is chained per name; with one list of carriers driving both registrations, adding a third carrier is a configuration change plus one array entry.
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
string[] carriers = ["Dhl", "Ups"];
foreach (var carrier in carriers)
{
builder.Services.AddOptions<CarrierApiOptions>(carrier)
.BindConfiguration($"Carriers:{carrier}")
.Validate(o => o.BaseUrl is { IsAbsoluteUri: true }, $"Carriers:{carrier}:BaseUrl is required")
.ValidateOnStart();
builder.Services.AddHttpClient(carrier, (sp, client) =>
{
var options = sp.GetRequiredService<IOptionsMonitor<CarrierApiOptions>>().Get(carrier);
client.BaseAddress = options.BaseUrl;
client.Timeout = options.Timeout;
});
}
var app = builder.Build();
app.MapGet("/shipping/quotes/{carrier}", async (string carrier, decimal weightKg,
IHttpClientFactory factory, CancellationToken ct) =>
{
if (!carriers.Contains(carrier))
{
return Results.NotFound();
}
var client = factory.CreateClient(carrier);
var quote = await client.GetFromJsonAsync<CarrierQuote>($"quotes?weightKg={weightKg}", ct);
return Results.Ok(quote);
});
app.Run();
public sealed class CarrierApiOptions
{
public Uri? BaseUrl { get; set; }
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
}
public sealed record CarrierQuote(string Carrier, decimal Amount, string Currency, int TransitDays);Named configuration that needs a service
<code>ConfigureOptions<T>()</code> registers the class for every options interface it implements, and resolves its constructor dependencies from DI. Because it implements <code>IConfigureNamedOptions<T></code>, the factory calls <code>Configure(name, options)</code> for <code>Dhl</code> and <code>Ups</code>. Had it implemented only <code>IConfigureOptions<T></code>, the user agent would be set on the unnamed instance and both carriers would send an empty header.
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<CarrierApiOptions>("Dhl").BindConfiguration("Carriers:Dhl");
builder.Services.AddOptions<CarrierApiOptions>("Ups").BindConfiguration("Carriers:Ups");
builder.Services.ConfigureOptions<CarrierUserAgentSetup>();
var app = builder.Build();
app.MapGet("/shipping/user-agent/{carrier}", (string carrier, IOptionsMonitor<CarrierApiOptions> options) =>
options.Get(carrier).UserAgent);
app.Run();
public sealed class CarrierApiOptions
{
public Uri? BaseUrl { get; set; }
public string UserAgent { get; set; } = "";
}
public sealed class CarrierUserAgentSetup(IHostEnvironment environment) : IConfigureNamedOptions<CarrierApiOptions>
{
public void Configure(string? name, CarrierApiOptions options) =>
options.UserAgent = $"shop-orders-api/{environment.EnvironmentName} (carrier={name})";
// Required by IConfigureOptions<T>. The options factory always calls Configure(name, options)
// on a named setup, so this overload only serves direct callers.
public void Configure(CarrierApiOptions options) => Configure(Options.DefaultName, options);
}Keyed clients over named settings
Named options carry the data; keyed services carry the behaviour. The service key and the options name are the same string, so the factory passes the key straight to <code>Get</code>. This scales to carriers with different client classes: register a <code>DhlClient</code> under <code>"Dhl"</code> and an <code>UpsClient</code> under <code>"Ups"</code>, both reading <code>CarrierApiOptions</code> by name.
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
foreach (var carrier in new[] { "Dhl", "Ups" })
{
builder.Services.AddOptions<CarrierApiOptions>(carrier).BindConfiguration($"Carriers:{carrier}");
builder.Services.AddKeyedSingleton<ICarrierClient>(carrier, (sp, key) =>
new CarrierClient((string)key!, sp.GetRequiredService<IOptionsMonitor<CarrierApiOptions>>()));
}
var app = builder.Build();
app.MapGet("/shipping/dhl/account", ([FromKeyedServices("Dhl")] ICarrierClient client) => client.AccountNumber);
app.MapGet("/shipping/ups/account", ([FromKeyedServices("Ups")] ICarrierClient client) => client.AccountNumber);
app.Run();
public sealed class CarrierApiOptions
{
public string AccountNumber { get; set; } = "";
}
public interface ICarrierClient
{
string AccountNumber { get; }
}
public sealed class CarrierClient(string name, IOptionsMonitor<CarrierApiOptions> options) : ICarrierClient
{
// Read per call, so a reloaded account number is picked up.
public string AccountNumber => options.Get(name).AccountNumber;
}
Discussion