dotnet-csharp-configuration
dotnet-csharp-configuration
Configuration patterns for .NET applications using Microsoft.Extensions.Configuration and Microsoft.Extensions.Options. Covers the Options pattern (IOptions<T>, IOptionsMonitor<T>, IOptionsSnapshot<T>), validation, user secrets, environment-based configuration, and feature flags with Microsoft.FeatureManagement.
Scope
- Options pattern (IOptions, IOptionsMonitor, IOptionsSnapshot)
- Options validation and ValidateOnStart
- User secrets and environment-based configuration
- Feature flags with Microsoft.FeatureManagement
- Configuration source precedence
Out of scope
- DI container mechanics and service lifetimes -- see [skill:dotnet-csharp-dependency-injection]
- EditorConfig and analyzer rule configuration -- see [skill:dotnet-editorconfig]
- Structured logging pipeline configuration -- see [skill:dotnet-structured-logging]
Cross-references: [skill:dotnet-csharp-dependency-injection] for service registration patterns, [skill:dotnet-csharp-coding-standards] for naming conventions.
Configuration Sources and Precedence
Default configuration sources in WebApplication.CreateBuilder (last wins):
appsettings.jsonappsettings.{Environment}.json- User secrets (Development only)
- Environment variables
- Command-line arguments
var builder = WebApplication.CreateBuilder(args);
// Sources above are loaded automatically. Add custom sources:
builder.Configuration.AddJsonFile("features.json", optional: true, reloadOnChange: true);
Options Pattern
Bind configuration sections to strongly typed classes and inject them via DI.
Defining Options Classes
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string FromAddress { get; set; } = "";
public bool UseSsl { get; set; } = true;
}
Options classes use
{ get; set; }(notinit) because the configuration binder andPostConfigureneed to mutate properties. Use[Required]via data annotations for mandatory fields instead.
Registration
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
appsettings.json
{
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"FromAddress": "noreply@example.com",
"UseSsl": true
}
}
Options Interfaces
| Interface | Lifetime | Reload Behavior | Use Case |
|---|---|---|---|
IOptions<T> |
Singleton | Never reloads after startup | Static config, most services |
IOptionsSnapshot<T> |
Scoped | Reloads per request/scope | Per-request config in ASP.NET |
IOptionsMonitor<T> |
Singleton | Live reload + change notification | Singletons, background services |
Injection Examples
// Static -- most common, singleton-safe
public sealed class EmailService(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _smtp = options.Value;
public Task SendAsync(string to, string subject, string body,
CancellationToken ct = default)
{
// Use _smtp.Host, _smtp.Port, etc.
return Task.CompletedTask;
}
}
// Live reload in singletons -- monitors config file changes
public sealed class FeatureService(IOptionsMonitor<FeatureOptions> monitor)
{
public bool IsEnabled(string feature)
=> monitor.CurrentValue.EnabledFeatures.Contains(feature);
}
// Per-request in scoped services -- reads latest config each request
public sealed class PricingService(IOptionsSnapshot<PricingOptions> snapshot)
{
public decimal GetMarkup() => snapshot.Value.MarkupPercent;
}
Change Notifications with IOptionsMonitor<T>
public sealed class CacheService : IDisposable
{
private readonly IDisposable? _changeListener;
private CacheOptions _current;
public CacheService(IOptionsMonitor<CacheOptions> monitor)
{
_current = monitor.CurrentValue;
_changeListener = monitor.OnChange(updated =>
{
_current = updated;
// React to config change -- flush cache, resize pool, etc.
});
}
public void Dispose() => _changeListener?.Dispose();
}
Options Validation
Data Annotations
using System.ComponentModel.DataAnnotations;
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
[Required, MinLength(1)]
public string Host { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; } = 587;
[Required, EmailAddress]
public string FromAddress { get; set; } = "";
}
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail fast at startup, not on first use
IValidateOptions<T> (Complex Validation)
Use when validation logic requires cross-property checks or external dependencies.
public sealed class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
public ValidateOptionsResult Validate(string? name, SmtpOptions options)
{
var failures = new List<string>();
if (options.UseSsl && options.Port == 25)
{
failures.Add("Port 25 does not support SSL. Use 465 or 587.");
}
if (string.IsNullOrWhiteSpace(options.Host))
{
failures.Add("SMTP host is required.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
// Register the validator
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
ValidateOnStart (Fail Fast)
Always use .ValidateOnStart() to surface configuration errors at startup instead of at first resolution. Without it, invalid config only throws when IOptions<T>.Value is first accessed.
User Secrets (Development)
Store sensitive values outside source control during development.
# Initialize (once per project)
dotnet user-secrets init
# Set values
dotnet user-secrets set "Smtp:Host" "smtp.example.com"
dotnet user-secrets set "ConnectionStrings:Default" "Server=..."
# List all secrets
dotnet user-secrets list
# Clear all
dotnet user-secrets clear
User secrets are stored in ~/.microsoft/usersecrets/<UserSecretsId>/secrets.json and override appsettings.json values in Development.
Key rules:
- Never use user secrets in production -- use environment variables, Azure Key Vault, or other vault providers
- User secrets are loaded automatically when
ASPNETCORE_ENVIRONMENT=Development - For non-web hosts, explicitly add:
builder.Configuration.AddUserSecrets<Program>()
Environment-Based Configuration
Environment Variables
// Hierarchical keys use __ (double underscore) as separator
// Environment variable: Smtp__Host=smtp.prod.com
// Maps to: configuration["Smtp:Host"]
Per-Environment Files
appsettings.json # Base (all environments)
appsettings.Development.json # Overrides for dev
appsettings.Staging.json # Overrides for staging
appsettings.Production.json # Overrides for prod
// Set environment via ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT
// Defaults to "Production" if not set
var env = builder.Environment.EnvironmentName; // "Development", "Staging", "Production"
Conditional Service Registration
if (builder.Environment.IsDevelopment())
{
builder.Services.AddSingleton<IEmailSender, ConsoleEmailSender>();
}
else
{
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
}
Feature Flags with Microsoft.FeatureManagement
Microsoft.FeatureManagement.AspNetCore provides structured feature flag support with filters, targeting, and gradual rollout.
Setup
dotnet add package Microsoft.FeatureManagement.AspNetCore
builder.Services.AddFeatureManagement();
Configuration
{
"FeatureManagement": {
"NewDashboard": true,
"BetaSearch": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": { "Value": 50 }
}
]
},
"DarkMode": {
"EnabledFor": [
{
"Name": "Targeting",
"Parameters": {
"Audience": {
"Users": [ "alice@example.com" ],
"Groups": [
{ "Name": "Beta", "RolloutPercentage": 100 }
],
"DefaultRolloutPercentage": 0
}
}
}
]
}
}
}
Usage in Code
// Inject IFeatureManager
public sealed class DashboardController(IFeatureManager featureManager) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct = default)
{
if (await featureManager.IsEnabledAsync("NewDashboard"))
{
return Ok(new { version = "v2", dashboard = "new" });
}
return Ok(new { version = "v1", dashboard = "legacy" });
}
}
Feature Gate Attribute
// Entire endpoint gated on feature flag
[FeatureGate("BetaSearch")]
[HttpGet("search")]
public async Task<IActionResult> Search(string query, CancellationToken ct = default)
{
var results = await _searchService.SearchAsync(query, ct);
return Ok(results);
}
Feature Filters
| Filter | Purpose |
|---|---|
Percentage |
Enable for N% of requests (random) |
TimeWindow |
Enable between start/end dates |
Targeting |
Enable for specific users, groups, or rollout percentage |
| Custom | Implement IFeatureFilter for domain-specific logic |
Custom Feature Filter
[FilterAlias("Browser")]
public sealed class BrowserFeatureFilter(IHttpContextAccessor accessor) : IFeatureFilter
{
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
var userAgent = accessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? "";
var settings = context.Parameters.Get<BrowserFilterSettings>();
return Task.FromResult(
settings?.AllowedBrowsers?.Any(b =>
userAgent.Contains(b, StringComparison.OrdinalIgnoreCase)) ?? false);
}
}
public sealed class BrowserFilterSettings
{
public string[] AllowedBrowsers { get; init; } = [];
}
// Register
builder.Services.AddFeatureManagement()
.AddFeatureFilter<BrowserFeatureFilter>();
Named Options
Use named options when you need multiple instances of the same options type (e.g., multiple API clients).
// Registration with names
builder.Services
.AddOptions<ApiClientOptions>("GitHub")
.BindConfiguration("ApiClients:GitHub");
builder.Services
.AddOptions<ApiClientOptions>("Jira")
.BindConfiguration("ApiClients:Jira");
// Resolution via IOptionsSnapshot<T> or IOptionsMonitor<T>
public sealed class ApiClientFactory(IOptionsSnapshot<ApiClientOptions> snapshot)
{
public HttpClient CreateFor(string name)
{
var options = snapshot.Get(name); // "GitHub" or "Jira"
return new HttpClient { BaseAddress = new Uri(options.BaseUrl) };
}
}
Post-Configuration
Apply defaults or overrides after all configuration sources have been processed.
builder.Services.PostConfigure<SmtpOptions>(options =>
{
// Ensure a default port if none specified
if (options.Port == 0)
{
options.Port = options.UseSsl ? 465 : 25;
}
});
Testing Configuration
[Fact]
public void SmtpOptions_Validates_InvalidPort()
{
var options = new SmtpOptions
{
Host = "smtp.example.com",
FromAddress = "test@example.com",
Port = 25,
UseSsl = true
};
var validator = new SmtpOptionsValidator();
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains("Port 25 does not support SSL", result.FailureMessage);
}
[Fact]
public void Configuration_BindsCorrectly()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Smtp:Host"] = "smtp.test.com",
["Smtp:Port"] = "465",
["Smtp:FromAddress"] = "test@test.com",
})
.Build();
var options = new SmtpOptions();
config.GetSection("Smtp").Bind(options);
Assert.Equal("smtp.test.com", options.Host);
Assert.Equal(465, options.Port);
}
References
More from novotnyllc/dotnet-artisan
dotnet-csharp
Baseline C# skill loaded for every .NET code path. Guides language patterns (records, pattern matching, primary constructors, C# 8-15), coding standards, async/await, DI, LINQ, serialization, domain modeling, concurrency, Roslyn analyzers, globalization, native interop (P/Invoke, LibraryImport, ComWrappers), WASM interop (JSImport/JSExport), and type design. Spans 25 topics. Do not use for ASP.NET endpoint architecture, UI framework patterns, or CI/CD guidance.
129dotnet-ui
Builds .NET UI apps across Blazor (Server, WASM, Hybrid, Auto), MAUI (XAML, MVVM, Shell, Native AOT), Uno Platform (MVUX, Extensions, Toolkit), WPF (.NET 8+, Fluent theme), WinUI 3 (Windows App SDK, MSIX, Mica/Acrylic, adaptive layout), and WinForms (high-DPI, dark mode) with JS interop, accessibility (SemanticProperties, ARIA), localization (.resx, RTL), platform bindings (Java.Interop, ObjCRuntime), and framework selection. Spans 20 topic areas. Do not use for backend API design or CI/CD pipelines.
99dotnet-api
Builds ASP.NET Core APIs, EF Core data access, gRPC, SignalR, and backend services with middleware, security (OAuth, JWT, OWASP), resilience, messaging, OpenAPI, .NET Aspire, Semantic Kernel, HybridCache, YARP reverse proxy, output caching, Office documents (Excel, Word, PowerPoint), PDF, and architecture patterns. Spans 32 topic areas. Do not use for UI rendering patterns or CI/CD pipeline authoring.
90dotnet-testing
Defines .NET test strategy and implementation patterns across xUnit v3 (Facts, Theories, fixtures, IAsyncLifetime), integration testing (WebApplicationFactory, Testcontainers), Aspire testing (DistributedApplicationTestingBuilder), snapshot testing (Verify, scrubbing), Playwright E2E browser automation, BenchmarkDotNet microbenchmarks, code coverage (Coverlet), mutation testing (Stryker.NET), UI testing (page objects, selectors), and AOT WASM test compilation. Spans 13 topic areas. Do not use for production API architecture or CI workflow authoring.
86dotnet-advisor
Routes .NET/C# requests to the correct domain skill and loads coding standards as baseline for all code paths. Determines whether the task needs API, UI, testing, devops, tooling, or debugging guidance based on prompt analysis and project signals, then invokes skills in the right order. Always invoked after [skill:using-dotnet] detects .NET intent. Do not use for deep API, UI, testing, devops, tooling, or debugging implementation guidance.
60dotnet-debugging
Debugs Windows and Linux/macOS applications (native, .NET/CLR, mixed-mode) with WinDbg MCP (crash dumps, !analyze, !syncblk, !dlk, !runaway, !dumpheap, !gcroot, BSOD), dotnet-dump, lldb with SOS, createdump, and container diagnostics (Docker, Kubernetes). Hang/deadlock diagnosis, high CPU triage, memory leak investigation, kernel debugging, and dotnet-monitor for production. Spans 17 topic areas. Do not use for routine .NET SDK profiling, benchmark design, or CI test debugging.
57