Overview
Configuration in .NET is the system used to load application settings from multiple sources, combine them into a single key-value configuration model, and make those settings available to the application. Common configuration sources include appsettings.json, environment-specific JSON files, environment variables, command-line arguments, user secrets, Azure Key Vault, Azure App Configuration, and custom providers.
The options pattern is the recommended way to read groups of related configuration values as strongly typed C# objects. Instead of repeatedly reading raw strings from IConfiguration, an application defines option classes such as JwtOptions, StorageOptions, or EmailOptions, binds configuration sections to those classes, validates them, and injects them using IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T>.
This topic matters because almost every production .NET application needs configuration for connection strings, feature flags, API endpoints, security settings, retry policies, authentication, logging, and external service integration. Configuration mistakes can cause production outages, security leaks, hard-to-debug environment differences, or runtime failures.
For interviews, this topic is important because it tests whether a developer understands how real .NET applications are configured across development, staging, and production. A strong candidate should know provider ordering, environment-based overrides, secrets handling, strongly typed options, validation, reload behavior, and the difference between IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T>.
Core Concepts
Configuration in .NET
Configuration is represented as a set of key-value pairs. Keys can be hierarchical, usually separated with : in code and JSON paths.
Example appsettings.json:
{
"ExternalApi": {
"BaseUrl": "https://api.example.com",
"TimeoutSeconds": 30,
"RetryCount": 3
}
}
The configuration values can be read directly with IConfiguration:
var baseUrl = builder.Configuration["ExternalApi:BaseUrl"];
var timeoutSeconds = builder.Configuration.GetValue<int>("ExternalApi:TimeoutSeconds");
Direct reads are useful for simple startup decisions, but they become harder to maintain when settings grow. For grouped settings, the options pattern is usually better.
Common Configuration Sources
A configuration source is where configuration data comes from. A configuration provider is the component that reads from that source.
Common providers include:
A typical ASP.NET Core application loads configuration automatically through WebApplication.CreateBuilder(args).
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
Provider Order and Override Behavior
Configuration providers are ordered. When multiple providers contain the same key, the later provider usually overrides the earlier value.
A common default order is:
appsettings.jsonappsettings.{Environment}.json- User secrets in development
- Environment variables
- Command-line arguments
This means an environment variable can override a value from appsettings.json, and a command-line argument can override both.
Example:
// appsettings.json
{
"Payment": {
"Provider": "Sandbox"
}
}
Environment variable:
Payment__Provider=Stripe
Result in code:
var provider = builder.Configuration["Payment:Provider"];
// Stripe
In environment variables, __ is commonly used instead of : because : is not supported consistently across operating systems and shells.
Environments
.NET applications commonly use environments such as Development, Staging, and Production.
Environment-specific files allow the same application to use different settings per environment:
appsettings.json
appsettings.Development.json
appsettings.Staging.json
appsettings.Production.json
Example:
// appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Default": "Debug"
}
}
}
// appsettings.Production.json
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
In interviews, it is important to explain that environment files are for environment-specific non-secret settings. They should not contain production passwords, access keys, or private certificates.
Secrets Management
Secrets should not be stored in source-controlled configuration files.
Bad example:
{
"ConnectionStrings": {
"DefaultConnection": "Server=prod;User Id=admin;Password=RealPassword123;"
}
}
Better approaches:
- Use User Secrets for local development.
- Use environment variables in deployment platforms when appropriate.
- Use Azure Key Vault or another secret store for production.
- Use managed identity where possible instead of storing credentials.
User Secrets are useful for local development, but they are not a production secret management solution. In production, a secure external store such as Azure Key Vault is usually preferred.
Binding Configuration to Strongly Typed Classes
The options pattern starts with a C# class that represents a configuration section.
public sealed class ExternalApiOptions
{
public const string SectionName = "ExternalApi";
public string BaseUrl { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; }
public int RetryCount { get; set; }
}
Register the options:
builder.Services.Configure<ExternalApiOptions>(
builder.Configuration.GetSection(ExternalApiOptions.SectionName));
Inject and use them:
using Microsoft.Extensions.Options;
public sealed class ExternalApiClient
{
private readonly ExternalApiOptions _options;
public ExternalApiClient(IOptions<ExternalApiOptions> options)
{
_options = options.Value;
}
public HttpClient CreateClient()
{
return new HttpClient
{
BaseAddress = new Uri(_options.BaseUrl),
Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds)
};
}
}
The main benefit is that the application works with typed properties instead of scattered string keys.
Bind, Configure, and Get<T>
There are several ways to map configuration to objects.
Get<T> creates and returns an instance immediately:
var options = builder.Configuration
.GetSection("ExternalApi")
.Get<ExternalApiOptions>();
Bind fills an existing object:
var options = new ExternalApiOptions();
builder.Configuration.GetSection("ExternalApi").Bind(options);
Configure<T> registers binding with the dependency injection container:
builder.Services.Configure<ExternalApiOptions>(
builder.Configuration.GetSection("ExternalApi"));
For application services, Configure<T> with the options pattern is usually preferred because it integrates with dependency injection, validation, named options, and reload behavior.
IOptions<T>
IOptions<T> provides access to a configured options instance through the Value property.
public sealed class ReportService
{
private readonly ReportOptions _options;
public ReportService(IOptions<ReportOptions> options)
{
_options = options.Value;
}
}
Use IOptions<T> when:
- The configuration is read once and does not need to change while the app is running.
- The service can be singleton, scoped, or transient.
- Named options are not needed.
A common interview point is that IOptions<T> is simple and stable, but it does not provide per-request snapshots or change notifications.
IOptionsSnapshot<T>
IOptionsSnapshot<T> is scoped and is commonly used in ASP.NET Core request-scoped services. It provides options that are computed once per scope.
public sealed class TenantSettingsService
{
private readonly TenantOptions _options;
public TenantSettingsService(IOptionsSnapshot<TenantOptions> options)
{
_options = options.Value;
}
}
Use IOptionsSnapshot<T> when:
- The service is scoped or transient.
- You want updated configuration values per request when the provider supports reload.
- You need named options in scoped services.
Avoid injecting IOptionsSnapshot<T> into singleton services because it is scoped. Doing so creates a lifetime mismatch.
IOptionsMonitor<T>
IOptionsMonitor<T> is a singleton-friendly options service that supports current values, named options, reloadable configuration, and change notifications.
public sealed class CachePolicyService
{
private CacheOptions _current;
public CachePolicyService(IOptionsMonitor<CacheOptions> optionsMonitor)
{
_current = optionsMonitor.CurrentValue;
optionsMonitor.OnChange(newOptions =>
{
_current = newOptions;
});
}
}
Use IOptionsMonitor<T> when:
- The consuming service is singleton.
- You need to react to configuration changes.
- You need named options in singleton services.
- The configuration provider supports reload.
A common use case is long-running services, background workers, singleton clients, and services that need to respond to runtime configuration changes.
Comparing IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T>
Practical rule:
- Use
IOptions<T>for simple static settings. - Use
IOptionsSnapshot<T>for scoped web request settings that can refresh between requests. - Use
IOptionsMonitor<T>for singleton services or runtime change notifications.
Options Validation
Options validation prevents invalid configuration from reaching business logic.
Example options class:
using System.ComponentModel.DataAnnotations;
public sealed class JwtOptions
{
public const string SectionName = "Jwt";
[Required]
public string Issuer { get; set; } = string.Empty;
[Required]
public string Audience { get; set; } = string.Empty;
[Range(1, 1440)]
public int ExpirationMinutes { get; set; }
}
Register with validation:
builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
.Validate(options => options.ExpirationMinutes <= 120,
"JWT expiration should not exceed 120 minutes.")
.ValidateOnStart();
ValidateOnStart() is useful because it fails fast during application startup instead of allowing the application to start with invalid settings and fail later during a request.
Named Options
Named options allow multiple configurations for the same options type.
Example:
{
"Storage": {
"Images": {
"ContainerName": "images"
},
"Documents": {
"ContainerName": "documents"
}
}
}
Registration:
builder.Services.Configure<StorageOptions>("Images",
builder.Configuration.GetSection("Storage:Images"));
builder.Services.Configure<StorageOptions>("Documents",
builder.Configuration.GetSection("Storage:Documents"));
Usage with IOptionsSnapshot<T>:
public sealed class FileService
{
private readonly StorageOptions _imageStorage;
private readonly StorageOptions _documentStorage;
public FileService(IOptionsSnapshot<StorageOptions> options)
{
_imageStorage = options.Get("Images");
_documentStorage = options.Get("Documents");
}
}
Named options are useful when one options type represents the same shape of settings for multiple clients, tenants, providers, or storage containers.
Post-Configuration
Post-configuration runs after normal configuration. It is useful when you need to derive or normalize values.
builder.Services.PostConfigure<ExternalApiOptions>(options =>
{
options.BaseUrl = options.BaseUrl.TrimEnd('/');
});
Use post-configuration carefully. It is good for normalization, but complex business logic should usually live in services rather than configuration setup.
Configuration Reload Behavior
Some configuration providers support reload when the underlying source changes. JSON files can be configured to reload on change, and some external providers also support refresh mechanisms.
Important points:
- Reload behavior depends on the provider.
IOptions<T>is not designed for dynamic updates.IOptionsSnapshot<T>can see updated values per new scope or request.IOptionsMonitor<T>can provide current values and change notifications.- A changed setting does not automatically rebuild every object that already copied the old value.
A common mistake is storing options values in a field during construction and expecting the field to update automatically.
public sealed class BadService
{
private readonly int _timeoutSeconds;
public BadService(IOptionsMonitor<ApiOptions> options)
{
_timeoutSeconds = options.CurrentValue.TimeoutSeconds;
}
}
The service above captures the current value once. To respond to changes, use CurrentValue when needed or subscribe to OnChange.
Connection Strings
Connection strings are often stored under the ConnectionStrings section.
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=AppDb;Trusted_Connection=True;TrustServerCertificate=True"
}
}
Read using:
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
For production, avoid committing real production connection strings. Use secure deployment configuration, environment variables, managed identity, or a secret store.
Configuration in ASP.NET Core
In ASP.NET Core, configuration is available through builder.Configuration during startup and through IConfiguration or options classes in services.
Example registration:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddScoped<EmailService>();
var app = builder.Build();
app.Run();
Example service:
public sealed class EmailService
{
private readonly EmailOptions _options;
public EmailService(IOptions<EmailOptions> options)
{
_options = options.Value;
}
}
Configuration in Worker Services and Console Apps
The same configuration and options pattern works outside ASP.NET Core.
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddOptions<WorkerOptions>()
.Bind(builder.Configuration.GetSection("Worker"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddHostedService<MyWorker>();
using IHost host = builder.Build();
host.Run();
This is important because modern .NET uses the same hosting, configuration, logging, and dependency injection model across web apps, workers, and console applications.
Best Practices
Use strongly typed options for related settings.
public sealed class PaymentOptions
{
public const string SectionName = "Payment";
public string Provider { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; }
}
Validate options at startup for required production settings.
builder.Services
.AddOptions<PaymentOptions>()
.Bind(builder.Configuration.GetSection(PaymentOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
Keep secrets out of source control.
Use environment-specific files for non-secret environment differences.
Prefer IOptionsMonitor<T> for singleton services that need reloadable configuration.
Prefer IOptionsSnapshot<T> only in scoped or transient services.
Avoid scattering magic strings across the application. Store section names as constants.
Avoid directly injecting IConfiguration into many business services unless the service truly needs dynamic key-based lookup. Typed options are usually clearer and more testable.
Common Mistakes
A common mistake is storing secrets in appsettings.json and committing them to source control.
Another common mistake is injecting IOptionsSnapshot<T> into a singleton service. IOptionsSnapshot<T> is scoped, so it does not match singleton lifetime.
Another mistake is assuming provider order does not matter. Provider order determines which values win when the same key exists in multiple places.
Another mistake is assuming all providers reload automatically. Reload depends on the provider and configuration setup.
Another mistake is reading configuration directly everywhere:
public sealed class OrderService
{
public OrderService(IConfiguration configuration)
{
var timeout = configuration.GetValue<int>("Payment:TimeoutSeconds");
}
}
This works, but it is harder to validate, test, and refactor than a typed options class.
Better:
public sealed class OrderService
{
private readonly PaymentOptions _options;
public OrderService(IOptions<PaymentOptions> options)
{
_options = options.Value;
}
}
Configuration Sources vs Options Pattern
Configuration sources answer the question: "Where do settings come from?"
The options pattern answers the question: "How does application code consume related settings safely and cleanly?"
They work together:
- Configuration providers load key-value pairs.
- Configuration sections group related settings.
- Options classes model those groups.
- Binding maps configuration values to C# objects.
- Validation checks correctness.
- Services consume options through dependency injection.