Named Options

Configure several instances of one settings class, such as two carrier APIs, and resolve each by name without mix-ups.

Syntaxbuilder.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

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

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.

Example · csharp
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&lt;T&gt;()</code> registers the class for every options interface it implements, and resolves its constructor dependencies from DI. Because it implements <code>IConfigureNamedOptions&lt;T&gt;</code>, the factory calls <code>Configure(name, options)</code> for <code>Dhl</code> and <code>Ups</code>. Had it implemented only <code>IConfigureOptions&lt;T&gt;</code>, the user agent would be set on the unnamed instance and both carriers would send an empty header.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.