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.
| Order | Provider | Typical use |
|---|---|---|
| 1 | appsettings.json | Shared defaults committed to source control |
| 2 | appsettings.{Environment}.json | Per-environment overrides, committed |
| 3 | User secrets | Local developer-only values, never committed |
| 4 | Environment variables | Container and CI injection; __ separates sections |
| 5 | Command-line arguments | Highest 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.ApiTyped 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 raisesOnChange, 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.AspNetCoretoWarning. 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.Related
Controllers, dependency injection and middleware Publishing and deployment
Last refreshed 2026-09-18.