Configuration, options and logging

The configuration stack layers, the options pattern with validation, and structured logging that is worth reading when something breaks.

Layered configuration

var builder = Host.CreateApplicationBuilder(args);

// Sources are applied in order; the last one wins
builder.Configuration
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json",
                 optional: true, reloadOnChange: true)
    .AddUserSecrets<Program>(optional: true)      // development only
    .AddEnvironmentVariables()
    .AddCommandLine(args);

// Typed options with validation and a reload hook
builder.Services.AddOptions<RateLimitOptions>()
    .Bind(builder.Configuration.GetSection("RateLimit"))
    .Validate(o => o.RequestsPerMinute > 0, "RequestsPerMinute must be positive")
    .ValidateOnStart();

builder.Services.AddOptions<StorageOptions>()
    .Bind(builder.Configuration.GetSection("Storage"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

public sealed class RateLimitOptions
{
    [Range(1, 100_000)]
    public int RequestsPerMinute { get; set; } = 60;

    [Required]
    public string Bucket { get; set; } = "";

    public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1);
}
SourcePrecedenceWhere it belongs
appsettings.jsonLowestDefaults safe to commit
appsettings.{Environment}.jsonAbove the base fileEnvironment-specific non-secret values
User secretsAbove the JSON filesDeveloper secrets, never deployed
Environment variablesAbove secretsContainer and orchestrator configuration
Command lineHighestOverrides for a single run
  • Environment variable names for nested keys use a double underscore: RateLimit__RequestsPerMinute.
  • ValidateOnStart turns a misconfiguration into a startup failure instead of an exception on the first request that touches the option.
  • Never log the whole configuration object — the common mistake that writes a connection string into the log pipeline.
  • Reload is opt-in per source and applies to IOptionsMonitor, not IOptions.

Structured logging

public sealed class OrderImporter
{
    private readonly ILogger<OrderImporter> _log;

    public OrderImporter(ILogger<OrderImporter> log) => _log = log;

    public async Task ImportAsync(IReadOnlyList<Order> orders, CancellationToken ct)
    {
        // Message templates, not string interpolation. The named holes become
        // structured fields and stay queryable.
        using var scope = _log.BeginScope(new Dictionary<string, object>
        {
            ["BatchSize"] = orders.Count
        });

        foreach (var order in orders)
        {
            try
            {
                await SaveAsync(order, ct);
                _log.LogDebug("Imported order {OrderId} worth {Amount} {Currency}",
                              order.Id, order.Amount, order.Currency);
            }
            catch (DbUpdateException ex)
            {
                _log.LogError(ex, "Failed to import order {OrderId}", order.Id);
            }
        }
    }
}
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    },
    "Console": {
      "FormatterName": "json",
      "FormatterOptions": { "IncludeScopes": true, "TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffZ" }
    }
  }
}
💡
Log levels are a budget, not a preference. Information is for events worth counting, Debug for detail you enable temporarily, and Warning for something a person should eventually look at. A service that logs Inform at request level cannot be read at volume.

FAQ

IOptions, IOptionsSnapshot or IOptionsMonitor?
IOptions for values fixed at startup, IOptionsSnapshot for per-request values read once, IOptionsMonitor when you need change notifications in a long-lived service.
Why do my interpolated log messages break the log search?
Because interpolation produces a single opaque string. Message templates keep the parameters as separate fields, so you can query by OrderId.

Dependency injection and the generic host Diagnostics and observability in production

Last refreshed 2026-09-18.