C# Configuration & Options Pattern

2026/01/027 min read
bookmark this

C# Configuration & Options Pattern

Configuration is core to building maintainable ASP.NET Core apps. This post walks through the built-in layered configuration model, the Options pattern for strongly-typed settings, the differences between IOptions<T>, IOptionsSnapshot<T> and IOptionsMonitor<T>, named options, validation, and common anti-patterns.

Configuration in ASP.NET Core

  • ASP.NET Core uses a layered configuration system: multiple sources are combined and later sources override earlier ones.
  • Default configuration sources (last wins):
1. appsettings.json
2. appsettings.{Environment}.json  (e.g., appsettings.Development.json)
3. User Secrets                     (Development only)
4. Environment Variables
5. Command-line arguments
  • appsettings.json is the primary, file-based config store:
{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=MyApp;Trusted_Connection=true"
  },
  "Logging": { "LogLevel": { "Default": "Information" } },
  "EmailSettings": {
    "SmtpHost": "smtp.example.com",
    "SmtpPort": 587,
    "FromAddress": "noreply@example.com",
    "EnableSsl": true
  },
  "FeatureFlags": { "EnableNewCheckout": false, "MaxRetries": 3 }
}
  • You can access configuration directly via IConfiguration but that is error-prone (magic strings, no type-safety). Prefer the Options pattern.
// IConfiguration - raw access (not recommended for most scenarios)
var smtpHost = builder.Configuration["EmailSettings:SmtpHost"];
var port = builder.Configuration.GetValue<int>("EmailSettings:SmtpPort");
var connStr = builder.Configuration.GetConnectionString("Default");

Sample appsetting

Assume the following setup: appsettings.json is used for all environments, and appsettings.dev.json is used when the ASPNETCORE_ENVIRONMENT is set to dev. appsettings.json

  "MyApp": {
    "ApiKey": "my-key-12345"
  }

appsettings.dev.json

  "MyApp": {
    "ApiKey": "my-key-10000"
  }

appsettings.prd.json

// No ApiKey for MyApp in this file

Now, if you run dotnet run, you'll see different values depending on the environment:

  • If you run with ASPNETCORE_ENVIRONMENT=dev, you'll get my-key-10000 for ApiKey.
  • If you run with ASPNETCORE_ENVIRONMENT=prd, the config will return my-key-12345.

Use user-secrets

You can use user-secrets to override config values from appsettings. User secrets allow you to store sensitive values on your local development machine, so you don't need to check credentials into the repo. For development or production, you can inject these values into the application from a Vault or environment variables.

dotnet user-secrets init
dotnet user-secrets set "MyApp:ApiKey" "99999"
dotnet user-secrets list

A few other commands for user-secrets

# Remove a secret
dotnet user-secrets remove "MyApp:ApiKey"

# Clear all secrets
dotnet user-secrets clear

Use Environment Variables

Running the following PowerShell command will override both appsettings and user-secrets:

# Set and run in one command (PowerShell)
$env:MyApp__ApiKey="secret"; dotnet run

Use command-line arguments

Command-line arguments will override all other configuration sources:

dotnet run --MyApp:ApiKey="command-line-key-12345"

The Options Pattern (IOptions<T>)

  • The Options pattern binds configuration sections to strongly-typed classes. This gives type-safety, validation, and easier testing.
public class EmailSettings
{
    public const string SectionName = "EmailSettings";
    public string SmtpHost { get; init; } = string.Empty;
    public int SmtpPort { get; init; } = 587;
    public string FromAddress { get; init; } = string.Empty;
    public bool EnableSsl { get; init; } = true;
}

// Register in Program.cs
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection(EmailSettings.SectionName));

// Consume via DI
public class EmailSender
{
    private readonly EmailSettings _settings;
    public EmailSender(IOptions<EmailSettings> options) => _settings = options.Value;

    public async Task SendAsync(string to, string body, CancellationToken ct)
    {
        using var client = new SmtpClient(_settings.SmtpHost, _settings.SmtpPort);
        client.EnableSsl = _settings.EnableSsl;
        await client.SendMailAsync(new MailMessage(_settings.FromAddress, to, "Subject", body), ct);
    }
}

IOptions<T> vs IOptionsSnapshot<T> vs IOptionsMonitor<T>

Interface Lifetime Reloads on Change Use Case
IOptions<T> Singleton No Settings that never change at runtime
IOptionsSnapshot<T> Scoped Yes (per-request) Settings that may change and are consumed in scoped services
IOptionsMonitor<T> Singleton Yes (live) Singleton services that need live reload and change callbacks
  • IOptions<T>: read once at startup — simplest and fastest. Use for secrets or values that won't change during runtime.

  • IOptionsSnapshot<T>: refreshed per request/scope. Useful for feature flags or settings you may update and expect to take effect on the next request. Cannot be injected into Singleton services.

  • IOptionsMonitor<T>: supports live reload and OnChange callbacks. Use when a Singleton needs to react to config changes.

// Example: IOptionsMonitor with change callback
public class CacheService : IDisposable
{
    private CacheSettings _settings;
    private readonly IDisposable? _changeListener;

    public CacheService(IOptionsMonitor<CacheSettings> monitor)
    {
        _settings = monitor.CurrentValue;
        _changeListener = monitor.OnChange(newSettings =>
        {
            _settings = newSettings;
            Console.WriteLine($"Cache updated: TTL={newSettings.TtlMinutes}m");
        });
    }

    public void Dispose() => _changeListener?.Dispose();
}

Named Options — multiple configurations of the same type

When you need multiple instances of the same settings class with different values (e.g., two blob containers), use named options:

{
  "Storage": {
    "Images": { "ConnectionString": "...", "ContainerName": "images" },
    "Documents": { "ConnectionString": "...", "ContainerName": "documents" }
  }
}
public class BlobStorageSettings { public string ConnectionString { get; init; } = string.Empty; public string ContainerName { get; init; } = string.Empty; }

builder.Services.Configure<BlobStorageSettings>("Images", builder.Configuration.GetSection("Storage:Images"));
builder.Services.Configure<BlobStorageSettings>("Documents", builder.Configuration.GetSection("Storage:Documents"));

public class StorageService
{
    private readonly IOptionsSnapshot<BlobStorageSettings> _options;
    public StorageService(IOptionsSnapshot<BlobStorageSettings> options) => _options = options;

    public string GetImageContainer() => _options.Get("Images").ContainerName;
    public string GetDocumentContainer() => _options.Get("Documents").ContainerName;
}

Options Validation

Always validate options to catch misconfiguration early. You can use data annotations, inline validation, or implement IValidateOptions<T>.

public class EmailSettings
{
    [Required]
    public string SmtpHost { get; init; } = string.Empty;

    [Range(1, 65535)]
    public int SmtpPort { get; init; } = 587;

    [Required, EmailAddress]
    public string FromAddress { get; init; } = string.Empty;
}

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

Inline validation example:

builder.Services
  .AddOptions<EmailSettings>()
  .BindConfiguration("EmailSettings")
  .Validate(settings => !string.IsNullOrWhiteSpace(settings.SmtpHost) && settings.SmtpPort is >=1 and <=65535, "EmailSettings validation failed")
  .ValidateOnStart();

Or implement reusable validation via IValidateOptions<T> for complex checks.

Note: .ValidateOnStart() is recommended so the app fails fast on invalid config, that way can detect the error earlier. For example: if you app configured kafka credential, you asked devOps to setup the value but by some reason the value if misconfigured, would you still want to app to running and fail the API calls? or not let it start-up after the deployment?

PostConfigure

Use PostConfigure<T> to compute defaults or derived values after binding, use this when want fallback logic for a config.

builder.Services.PostConfigure<EmailSettings>(settings =>
{
    if (string.IsNullOrEmpty(settings.FromAddress))
        settings.FromAddress = $"noreply@{settings.SmtpHost}";
});

Configuration Sources Quick Tips

  • User Secrets: development-only secure storage for secrets outside source control (dotnet user-secrets).
  • Environment variables: preferred in production (use __ double-underscore to separate sections).
  • Command-line args: useful for short-lived overrides during startup.

Common Anti-Patterns

Anti-Pattern Problem Fix
IConfiguration everywhere Magic strings, no type safety Use IOptions<T>
Secrets in appsettings.json Committed to source control Use User Secrets (dev) or env vars (prod)
No validation on options Bad config causes runtime errors ValidateDataAnnotations() + ValidateOnStart()
IOptions<T> in Singleton that needs reloads Values never update Use IOptionsMonitor<T>
IOptionsSnapshot<T> in Singleton Runtime error (Scoped in Singleton) Use IOptionsMonitor<T>

Binding Patterns

  • Configure<T>: common, simple registration.
  • AddOptions<T>().BindConfiguration(...) — supports validation and ValidateOnStart().
  • Get<T>() on IConfigurationSection — immediate binding (no DI).

Summary — Configuration Checklist

Topic Key Takeaway
Configuration sources appsettings.json → env JSON → User Secrets → env vars → CLI
Options pattern Bind config to strong types for safety and testability
IOptions<T> Singleton, read once — for static settings
IOptionsSnapshot<T> Scoped, refreshed per request — for changing settings in scoped services
IOptionsMonitor<T> Singleton, live reload + OnChange — for singletons that react to updates
Named options Use when you need multiple configurations of same type
Validation Use data annotations or IValidateOptions<T> and ValidateOnStart()
Secrets Don't store in source control — use User Secrets (dev) or env vars (prod)