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);
}| Source | Precedence | Where it belongs |
|---|---|---|
appsettings.json | Lowest | Defaults safe to commit |
appsettings.{Environment}.json | Above the base file | Environment-specific non-secret values |
| User secrets | Above the JSON files | Developer secrets, never deployed |
| Environment variables | Above secrets | Container and orchestrator configuration |
| Command line | Highest | Overrides for a single run |
- Environment variable names for nested keys use a double underscore:
RateLimit__RequestsPerMinute. ValidateOnStartturns 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, notIOptions.
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.
Related
Dependency injection and the generic host Diagnostics and observability in production
Last refreshed 2026-09-18.