.NET & C# Development · Lesson 5 of 92

Kill the Hardcoded Values — appsettings.json & Env Vars

The Problem With Hardcoded Values

C#
// 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

JSON
// 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
JSON
// appsettings.Development.json — local overrides only
{
  "App": {
    "AllowGuestCheckout": false
  },
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}
JSON
// 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.

C#
// 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.

C#
// 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
}
C#
// Program.cs — register options
builder.Services
    .AddOptions<AppOptions>()
    .BindConfiguration(AppOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();
C#
// 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.

Bash
# 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
# Dockerfile / docker-compose
ENV ConnectionStrings__Default="Server=db;Database=Orders;User Id=app;Password=secret;"
ENV Email__ApiKey="SG.abcdef123456"
YAML
# 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-key

dotnet 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.

Bash
# 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 clear

Secrets 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.json

Validation at Startup

Catch missing or invalid config before your app starts serving traffic.

C#
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 invalid

Complete Program.cs Example

C#
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

C#
// 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