Configuration and logging

How the configuration stack layers sources, the options pattern for typed settings, and structured logging that is worth reading at 3am.

The configuration stack

Configuration is a flat key/value store assembled from several providers. Later providers override earlier ones, so the same key can mean different things per environment without any branching in code.

OrderProviderTypical use
1appsettings.jsonShared defaults committed to source control
2appsettings.{Environment}.jsonPer-environment overrides, committed
3User secretsLocal developer-only values, never committed
4Environment variablesContainer and CI injection; __ separates sections
5Command-line argumentsHighest precedence, handy for one-off runs
{
  "Shop": {
    "PageSize": 25,
    "RetryCount": 3,
    "PricingBaseUrl": "https://pricing.internal/"
  },
  "ConnectionStrings": {
    "Default": "Data Source=shop.db"
  }
}
# section separator is a double underscore
Shop__RetryCount=5 dotnet run --project src/Shop.Api

# developer secrets stay outside the repository
dotnet user-secrets init --project src/Shop.Api
dotnet user-secrets set "ConnectionStrings:Default" "Data Source=dev.db" --project src/Shop.Api

Typed options

Reading raw strings from IConfiguration scatters magic keys through the codebase. Bind a section to a class once, validate it at startup, and inject it where it is needed.

public sealed class ShopOptions
{
    public const string SectionName = "Shop";

    [Range(1, 500)] public int PageSize { get; init; } = 25;
    [Range(0, 10)]  public int RetryCount { get; init; } = 3;
    [Required, Url] public string PricingBaseUrl { get; init; } = "";
}

builder.Services.AddOptions<ShopOptions>()
    .Bind(builder.Configuration.GetSection(ShopOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// consumed as a snapshot or as a live view
public sealed class OrderService(IOptions<ShopOptions> options)
{
    private readonly ShopOptions _shop = options.Value;
}

public sealed class PricingClient(IOptionsMonitor<ShopOptions> monitor)
{
    public void Log() => Console.WriteLine(monitor.CurrentValue.PricingBaseUrl);
}
  • IOptions<T> is a singleton snapshot read once — correct for values that never change while running.
  • IOptionsSnapshot<T> is scoped and re-read per request.
  • IOptionsMonitor<T> is a singleton that raises OnChange, for long-lived consumers.
  • ValidateOnStart() converts a missing key from a runtime null reference into a startup failure with the missing property named.
⚠️
Injecting IOptions<T> into a scoped service is fine, but injecting IOptionsSnapshot<T> into a singleton throws at startup. Choose the interface by the lifetime of the consumer, not by whichever one you used last.

Structured logging

builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(o => o.IncludeScopes = true);

var app = builder.Build();

app.Logger.LogInformation("Starting {Service} in {Environment}",
    "Shop.Api", app.Environment.EnvironmentName);

// message templates, not string concatenation
public async Task<Order> LoadAsync(int id, CancellationToken ct)
{
    using var scope = _log.BeginScope(new Dictionary<string, object>
    {
        ["OrderId"] = id,
        ["TraceId"] = Activity.Current?.Id ?? "none"
    });

    try
    {
        var order = await _store.FindAsync(id, ct);
        _log.LogDebug("Loaded order with {LineCount} lines", order?.Lines.Count ?? 0);
        return order!;
    }
    catch (DbException ex)
    {
        _log.LogError(ex, "Loading order {OrderId} failed", id);
        throw;
    }
}
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "System.Net.Http.HttpClient": "Warning"
    }
  }
}
  • Named placeholders become structured fields — {OrderId} is searchable, an interpolated string is not.
  • Set Microsoft.AspNetCore to Warning. Framework request logging at Information dwarfs your own output and inflates log cost.
  • Never log credentials, tokens, full card numbers or personal data. Log an identifier and look the record up if you need detail.
  • Use LogError(ex, ...) so the exception travels as a structured object with its stack trace attached.

FAQ

How do I keep secrets out of appsettings.json?
Keep only non-secret defaults there. Use user secrets locally, environment variables in containers, and a managed secret store in production. All three are providers in the same stack, so no application code changes.
Logging is not appearing in my container logs. Why?
Usually the console provider was cleared and no replacement added, or the minimum level filtered it out. Check Logging:LogLevel and confirm a provider is registered for the output you are reading.

Controllers, dependency injection and middleware Publishing and deployment

Last refreshed 2026-09-18.