Why configuration knowledge is interview gold
Configuration management is where many production incidents originate — wrong connection strings, missing API keys, config drift between environments, or features that mysteriously stop working because a value changed mid-flight. Interviewers test this topic to gauge whether a developer knows how to make configuration safe, validated, and environment-aware.
How the configuration system works
ASP.NET Core aggregates key-value pairs from multiple configuration providers into a
unified IConfiguration interface. Later providers override earlier ones for the same key.
// Default provider stack (WebApplication.CreateBuilder):
// 1. appsettings.json
// 2. appsettings.{Environment}.json ← overrides 1
// 3. User Secrets (Development only) ← overrides 1+2
// 4. Environment variables ← overrides everything below
// 5. Command-line arguments ← highest priority
var builder = WebApplication.CreateBuilder(args);
// Read a simple value:
string? dbConn = builder.Configuration["ConnectionStrings:DefaultConnection"];
// GetConnectionString is a shortcut:
string? dbConn2 = builder.Configuration.GetConnectionString("DefaultConnection");
// GetSection returns a sub-node:
IConfigurationSection mail = builder.Configuration.GetSection("Mail");
string? host = mail["Host"];
int port = mail.GetValue<int>("Port", defaultValue: 587);
appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=MyDb;Trusted_Connection=True"
},
"Mail": {
"Host": "smtp.example.com",
"Port": 587,
"UseSsl": true
}
}
Key rule: never hard-code secrets or environment-specific values in source code. The configuration system exists precisely so those values can be overridden without a code change.
Configuration providers — understanding the priority stack
// Customizing the stack:
builder.Host.ConfigureAppConfiguration((ctx, config) =>
{
config.Sources.Clear(); // start fresh if needed
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
config.AddJsonFile($"appsettings.{ctx.HostingEnvironment.EnvironmentName}.json",
optional: true, reloadOnChange: true);
if (ctx.HostingEnvironment.IsDevelopment())
config.AddUserSecrets<Program>();
// Environment variables: double underscore __ maps to :
// MYAPP_Mail__Host → Mail:Host
config.AddEnvironmentVariables(prefix: "MYAPP_");
config.AddCommandLine(args);
// Azure Key Vault (requires NuGet: Azure.Extensions.AspNetCore.Configuration.Secrets):
// config.AddAzureKeyVault(new Uri("https://vault.azure.net/"), new DefaultAzureCredential());
});
Environment variable key separator: __ (double underscore) maps to :.
MYAPP_Mail__Host → config key Mail:Host.
MYAPP_ConnectionStrings__DefaultConnection → ConnectionStrings:DefaultConnection.
This means you can override any nested config value with an environment variable on any platform, including Docker containers and Kubernetes.
Reading config — Bind, Get<T>, and GetValue
// GetValue<T> — single key with optional default:
string name = config.GetValue<string>("App:Name") ?? "DefaultApp";
int maxItems = config.GetValue<int>("App:MaxItems", 50);
bool debugMode = config.GetValue<bool>("App:DebugMode");
// GetSection + Get<T> — deserialize a section to a POCO:
AppOptions opts = config.GetSection("App").Get<AppOptions>()!;
// Bind — populate an existing object:
var opts2 = new AppOptions();
config.GetSection("App").Bind(opts2);
// Arrays / lists:
string[] tags = config.GetSection("App:Tags").Get<string[]>()!;
// Check section existence before binding:
var section = config.GetSection("OptionalFeature");
if (section.Exists())
section.Bind(featureOptions);
Prefer the Options pattern over calling IConfiguration directly in services — it gives
you DI, validation, reload support, and testability. Use IConfiguration directly only in
startup code (Program.cs) or in simple console apps.
The Options pattern — IOptions, IOptionsSnapshot, IOptionsMonitor
Bind a config section to a strongly-typed class and inject it via DI:
// POCO — convention: add SectionName constant:
public class MailOptions
{
public const string SectionName = "Mail";
public string Host { get; set; } = default!;
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true;
public string FromAddress { get; set; } = default!;
}
// Register:
builder.Services.Configure<MailOptions>(
builder.Configuration.GetSection(MailOptions.SectionName));
// .NET 7+ shorthand:
builder.Services.AddOptions<MailOptions>()
.BindConfiguration(MailOptions.SectionName);
The three interfaces differ in lifetime and reload behavior:
// IOptions<T> — singleton; reads config ONCE at startup:
public class EmailService
{
private readonly MailOptions _opts;
public EmailService(IOptions<MailOptions> opts) => _opts = opts.Value;
// _opts.Host never changes, even if appsettings.json is edited on disk
}
// IOptionsSnapshot<T> — scoped; fresh per HTTP request:
public class EmailService
{
private readonly MailOptions _opts;
public EmailService(IOptionsSnapshot<MailOptions> opts) => _opts = opts.Value;
// New value takes effect on the next request after a reload
}
// IOptionsMonitor<T> — singleton; sees changes immediately:
public class EmailService
{
private readonly IOptionsMonitor<MailOptions> _monitor;
public EmailService(IOptionsMonitor<MailOptions> monitor)
{
_monitor = monitor;
monitor.OnChange(newOpts =>
Console.WriteLine($"Config changed: Host={newOpts.Host}"));
}
public string CurrentHost => _monitor.CurrentValue.Host; // always fresh
}
| Interface | DI Lifetime | Sees reload? | Use when |
|---|---|---|---|
IOptions<T> | Singleton | Never | Startup-only, singletons |
IOptionsSnapshot<T> | Scoped | Per-request | Web requests, feature flags |
IOptionsMonitor<T> | Singleton | Immediately | Background workers, long-running singletons |
Options validation — fail fast at startup
Without validation, bad config causes a runtime error when the feature is first used.
ValidateOnStart() makes the app refuse to start if config is invalid:
public class SmtpOptions
{
[Required]
public string Host { get; set; } = default!;
[Range(1, 65535)]
public int Port { get; set; }
[Required, EmailAddress]
public string FromAddress { get; set; } = default!;
}
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.ValidateDataAnnotations()
.ValidateOnStart(); // throw on startup, not on first use
// Custom validation logic:
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.Validate(o => o.Port != 25 || !o.Host.Contains(".corp"),
"Port 25 blocked for corp mail servers")
.ValidateOnStart();
A ValidateOnStart failure throws OptionsValidationException during
app.Run() — the app exits cleanly with a clear error message rather than silently
misbehaving in production.
User Secrets — safe development credentials
User Secrets store sensitive config outside the repo directory so they are never accidentally committed:
# Initialize (adds <UserSecretsId> to .csproj):
dotnet user-secrets init
# Set values:
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=dev-db;..."
dotnet user-secrets set "Stripe:SecretKey" "sk_test_abc123"
dotnet user-secrets set "Mail:Password" "dev-smtp-password"
# List:
dotnet user-secrets list
# Remove:
dotnet user-secrets remove "Mail:Password"
# Clear all:
dotnet user-secrets clear
Stored at %APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json (Windows)
or ~/.microsoft/usersecrets/<id>/secrets.json (Linux/macOS).
WebApplication.CreateBuilder automatically loads them in Development:
// This is what the builder does automatically in Development:
if (ctx.HostingEnvironment.IsDevelopment())
config.AddUserSecrets<Program>();
User Secrets are a development convenience only — in staging and production, use environment variables or a secrets manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault).
Environment-specific configuration
ASPNETCORE_ENVIRONMENT controls which appsettings.{Env}.json overlay is loaded:
// appsettings.json — base config, in source control, no secrets:
{
"Logging": { "LogLevel": { "Default": "Warning" } },
"FeatureFlags": { "NewCheckout": false }
}
// appsettings.Development.json — local overrides, can be committed:
{
"Logging": { "LogLevel": { "Default": "Debug", "Microsoft": "Information" } },
"FeatureFlags": { "NewCheckout": true }
}
// appsettings.Production.json — minimal; real secrets come from env vars:
{
"Logging": { "LogLevel": { "Default": "Error" } }
}
// Check environment in code:
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
// Custom environment names:
// ASPNETCORE_ENVIRONMENT=Staging → loads appsettings.Staging.json
if (app.Environment.IsEnvironment("Staging"))
EnableBluegreenRouting();
Standard environments: Development, Staging, Production. Custom names are allowed.
Named options — multiple instances of the same type
Named options let you configure multiple instances of the same options class:
// Two SMTP configs:
builder.Services.Configure<SmtpOptions>("Transactional",
builder.Configuration.GetSection("Smtp:Transactional"));
builder.Services.Configure<SmtpOptions>("Marketing",
builder.Configuration.GetSection("Smtp:Marketing"));
// Inject via IOptionsMonitor — get by name:
public class EmailDispatcher
{
private readonly IOptionsMonitor<SmtpOptions> _monitor;
public EmailDispatcher(IOptionsMonitor<SmtpOptions> monitor) => _monitor = monitor;
public SmtpOptions GetTransactional() => _monitor.Get("Transactional");
public SmtpOptions GetMarketing() => _monitor.Get("Marketing");
}
IHttpClientFactory uses named options internally — each AddHttpClient("name", ...) is
a named options configuration under the hood.
PostConfigure — guaranteed last word
PostConfigure runs after all Configure calls for the same type, making it useful for
enforcing global constraints regardless of what third-party libraries set:
// Third-party library:
builder.Services.Configure<CacheOptions>(o => { o.MaxSize = 1000; o.Expiry = TimeSpan.FromMinutes(5); });
// Your constraint — always runs last:
builder.Services.PostConfigure<CacheOptions>(o =>
{
if (o.MaxSize > 500) o.MaxSize = 500; // cap regardless of library setting
});
// PostConfigureAll — applies to ALL named instances:
builder.Services.PostConfigureAll<SmtpOptions>(o => o.UseSsl = true); // enforce SSL everywhere
Configuration in background services and console apps
The configuration system is framework-agnostic — it works equally well outside ASP.NET Core:
var host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((ctx, config) =>
{
config.AddJsonFile("appsettings.json");
config.AddEnvironmentVariables();
if (ctx.HostingEnvironment.IsDevelopment())
config.AddUserSecrets<Program>();
})
.ConfigureServices((ctx, services) =>
{
services.AddOptions<WorkerOptions>()
.BindConfiguration("Worker")
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddHostedService<MyWorker>();
})
.Build();
await host.RunAsync();
public class MyWorker : BackgroundService
{
private readonly WorkerOptions _opts;
public MyWorker(IOptions<WorkerOptions> opts) => _opts = opts.Value;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
DoWork();
await Task.Delay(TimeSpan.FromSeconds(_opts.PollIntervalSeconds), ct);
}
}
}
Recap
The configuration system layers providers — appsettings.json → environment-specific file →
User Secrets → environment variables → command-line — with later sources winning. Read config
through IOptions<T> (startup value, never reloads), IOptionsSnapshot<T> (per-request),
or IOptionsMonitor<T> (live updates in singletons). Always add .ValidateDataAnnotations() .ValidateOnStart() to critical options so bad config causes a clean startup failure rather
than a runtime surprise. Use User Secrets locally; use environment variables or a cloud
secrets manager in production. Use PostConfigure to enforce global constraints that
third-party libraries cannot override.