.NET & C# Development · Lesson 5 of 92
Kill the Hardcoded Values — appsettings.json & Env Vars
The Problem With Hardcoded Values
// DO NOT do this — ever
var conn = "Server=prod-db.company.com;Database=Orders;User=sa;Password=P@ssw0rd123!";
var apiKey = "sk-abc123supersecret";These strings end up in git, get shipped to every developer's machine, and rotate into production incidents. The ASP.NET Core configuration system solves this with a layered, override-friendly model.
appsettings.json Structure
// appsettings.json — committed to source control, no secrets here
{
"App": {
"Name": "OrderFlow",
"MaxOrderItems": 50,
"AllowGuestCheckout": true
},
"ConnectionStrings": {
"Default": "Server=localhost;Database=OrderFlow;Trusted_Connection=true;TrustServerCertificate=true;"
},
"Email": {
"Host": "smtp.sendgrid.net",
"Port": 587,
"FromAddress": "noreply@orderflow.io"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}Environment-Specific Files
ASP.NET Core automatically loads and merges files based on ASPNETCORE_ENVIRONMENT. Later files win.
appsettings.json ← base, always loaded
appsettings.Development.json ← loaded when env = Development
appsettings.Staging.json ← loaded when env = Staging
appsettings.Production.json ← loaded when env = Production// appsettings.Development.json — local overrides only
{
"App": {
"AllowGuestCheckout": false
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}// appsettings.Production.json — committed, still no secrets
{
"App": {
"MaxOrderItems": 200
},
"Logging": {
"LogLevel": {
"Default": "Warning"
}
}
}The ASPNETCORE_ENVIRONMENT variable is set by your host (launchSettings.json locally, Docker/Kubernetes in production).
Reading Config With IConfiguration
IConfiguration is injected anywhere in your app.
// Direct key access — fine for one-offs
public class OrderController : ControllerBase
{
private readonly IConfiguration _config;
public OrderController(IConfiguration config)
=> _config = config;
[HttpGet("info")]
public IActionResult Info()
{
var appName = _config["App:Name"];
var maxItems = _config.GetValue<int>("App:MaxOrderItems");
var connStr = _config.GetConnectionString("Default");
return Ok(new { appName, maxItems });
}
}Colon (:) is the section separator. GetValue<T> handles type conversion and returns a default if the key is missing.
Strongly-Typed Options With IOptions<T>
Scattered _config["App:Name"] calls break on typos and give you no IntelliSense. Use IOptions<T> instead.
// Options class — mirrors the JSON structure
public class AppOptions
{
public const string SectionName = "App";
public string Name { get; init; } = string.Empty;
public int MaxOrderItems { get; init; } = 50;
public bool AllowGuestCheckout { get; init; }
}
public class EmailOptions
{
public const string SectionName = "Email";
public string Host { get; init; } = string.Empty;
public int Port { get; init; } = 587;
public string FromAddress { get; init; } = string.Empty;
public string? ApiKey { get; init; } // loaded from env var, not JSON
}// Program.cs — register options
builder.Services
.AddOptions<AppOptions>()
.BindConfiguration(AppOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services
.AddOptions<EmailOptions>()
.BindConfiguration(EmailOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();// Inject IOptions<T> in a service
public class OrderService
{
private readonly AppOptions _appOptions;
public OrderService(IOptions<AppOptions> options)
=> _appOptions = options.Value;
public async Task<Order> CreateAsync(CreateOrderRequest req)
{
if (req.Items.Count > _appOptions.MaxOrderItems)
throw new ValidationException($"Order cannot exceed {_appOptions.MaxOrderItems} items");
// ...
}
}IOptions vs IOptionsSnapshot vs IOptionsMonitor:
| Interface | Lifetime | Reloads? |
|---|---|---|
| IOptions<T> | Singleton | No |
| IOptionsSnapshot<T> | Scoped | Yes (per request) |
| IOptionsMonitor<T> | Singleton | Yes (live) |
Use IOptionsSnapshot<T> when you need per-request fresh values.
Environment Variables Override Everything
Environment variables sit above appsettings files in the configuration hierarchy. Double underscore (__) is the section separator.
# Linux / macOS / Docker
export ConnectionStrings__Default="Server=prod.db;Database=Orders;User Id=app;Password=secret;"
export Email__ApiKey="SG.abcdef123456"
export App__MaxOrderItems="500"
# Windows (PowerShell)
$env:ConnectionStrings__Default = "Server=prod.db;..."# Dockerfile / docker-compose
ENV ConnectionStrings__Default="Server=db;Database=Orders;User Id=app;Password=secret;"
ENV Email__ApiKey="SG.abcdef123456"# Kubernetes deployment
env:
- name: ConnectionStrings__Default
valueFrom:
secretKeyRef:
name: orderflow-secrets
key: db-connection-string
- name: Email__ApiKey
valueFrom:
secretKeyRef:
name: orderflow-secrets
key: sendgrid-api-keydotnet user-secrets (Local Dev Only)
User secrets live outside the project directory so they never get committed to git. They override appsettings.json in Development.
# Initialize (adds UserSecretsId to .csproj)
dotnet user-secrets init
# Set secrets
dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;Database=OrderFlow_Dev;Trusted_Connection=true;"
dotnet user-secrets set "Email:ApiKey" "SG.mylocalapikey"
dotnet user-secrets set "Jwt:Secret" "super-secret-key-min-32-chars-long"
# List all secrets
dotnet user-secrets list
# Remove one
dotnet user-secrets remove "Email:ApiKey"
# Clear all
dotnet user-secrets clearSecrets are stored at:
- Windows:
%APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json - macOS/Linux:
~/.microsoft/usersecrets/<UserSecretsId>/secrets.json
Configuration Priority (Highest Wins)
1. Command-line arguments dotnet run --App:Name=Test
2. Environment variables ASPNETCORE_App__Name=Test
3. User secrets (Development) dotnet user-secrets set "App:Name" "Test"
4. appsettings.{Environment}.json
5. appsettings.jsonValidation at Startup
Catch missing or invalid config before your app starts serving traffic.
public class EmailOptions
{
[Required]
public string Host { get; init; } = string.Empty;
[Range(1, 65535)]
public int Port { get; init; } = 587;
[Required, EmailAddress]
public string FromAddress { get; init; } = string.Empty;
[Required]
public string ApiKey { get; init; } = string.Empty;
}
// Program.cs
builder.Services
.AddOptions<EmailOptions>()
.BindConfiguration(EmailOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // throws InvalidOperationException on startup if invalidComplete Program.cs Example
var builder = WebApplication.CreateBuilder(args);
// Register all options
builder.Services
.AddOptions<AppOptions>()
.BindConfiguration(AppOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services
.AddOptions<EmailOptions>()
.BindConfiguration(EmailOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// DbContext uses the connection string
builder.Services.AddDbContext<OrderFlowDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Application services
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IEmailService, SendGridEmailService>();
var app = builder.Build();
app.MapControllers();
app.Run();Quick Reference: Never Do This
// BAD — hardcoded
var conn = "Server=prod;Database=DB;Password=secret";
// BAD — checked into source control
// appsettings.json: { "Jwt": { "Secret": "actual-production-secret" } }
// BAD — reading config strings scattered everywhere
var host = _config["Email:Host"];
var port = _config["Email:Port"];
var from = _config["Email:From"];
// GOOD — strongly typed, validated at startup
public class MyService(IOptions<EmailOptions> emailOpts)
{
private readonly EmailOptions _email = emailOpts.Value;
}What to Learn Next
- Dependency Injection: Wire up services that consume these options
- Serilog Logging: Structured logging that reads from the same config system
- ASP.NET Core Web API: Putting it all together in a real API