Every application needs some kind of configuration: whitelisted URLs, threshold values, database connection strings. Some values differ between environments; others stay the same.
All of these sources can contribute configuration to the same application. To understand how .NET combines them, we first need to establish a few core concepts.
Core concepts
What is configuration?
Configuration is a collection of values that controls application behavior without changing the actual code. A database connection string is a good example.
Configuration sources
A configuration source is where configuration values originate.
The best-known configuration source in .NET is appsettings.json. Beyond that, there are also:
- Environment variables
- Command-line arguments
- User Secrets
- In-memory values
- Azure App Configuration
- Azure Key Vault
Configuration providers
For configuration to be read from a source, a configuration provider is required. It knows how to:
- read values from a configuration source
- convert them into the .NET configuration representation
- expose them to the configuration system
- optionally support change detection and reloads
JSON files use the JSON configuration provider, and environment variables use the environment variables provider. Every configuration source has a dedicated configuration provider.
IConfigurationrepresents the application’s combined configuration. It gives the application a single abstraction over all registered providers.
Custom configuration providers
Providers aren’t limited to the built-in ones. You can write your own by implementing IConfigurationSource and ConfigurationProvider, the same base class the built-in providers use. This minimal provider reads Key=Value pairs from a flat file, with Section:Key nesting:
// KeyValueConfigurationSource.cs
using Microsoft.Extensions.Configuration;
namespace MiniApi.Configuration;
public class KeyValueConfigurationSource : IConfigurationSource
{
public string Path { get; set; } = string.Empty;
public bool Optional { get; set; } = true;
public IConfigurationProvider Build(IConfigurationBuilder builder)
=> new KeyValueConfigurationProvider(Path, Optional);
}
// KeyValueConfigurationProvider.cs
using Microsoft.Extensions.Configuration;
namespace MiniApi.Configuration;
// Reads simple "Key=Value" lines, supports "Section:Key=Value" nesting,
// reloads automatically when the file changes on disk.
public class KeyValueConfigurationProvider : ConfigurationProvider
{
private readonly string _path;
private readonly bool _optional;
public KeyValueConfigurationProvider(string path, bool optional)
{
_path = path;
_optional = optional;
}
public override void Load()
{
var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(_path))
{
if (!_optional)
throw new FileNotFoundException($"Config source not found: {_path}");
Data = data;
return;
}
foreach (var rawLine in File.ReadAllLines(_path))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
continue;
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
continue;
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
data[key] = value;
}
Data = data;
}
}
A small extension method keeps registration readable:
// KeyValueConfigurationExtensions.cs
using Microsoft.Extensions.Configuration;
namespace MiniApi.Configuration;
public static class KeyValueConfigurationExtensions
{
public static IConfigurationBuilder AddKeyValueFile(
this IConfigurationBuilder builder, string path, bool optional = true)
{
return builder.Add(new KeyValueConfigurationSource
{
Path = path,
Optional = optional
});
}
}
Hierarchical key-value model
Regardless of the source, .NET always represents data in the same structure: a hierarchical key-value model.
It doesn’t matter whether it’s a JSON file:
{
"Payments": {
"Stripe": {
"Timeout": 30,
"RetryCount": 3
}
}
}
An environment variable:
Payments__Stripe__Timeout=30
Or a command-line argument:
--Payments:Stripe:Timeout=30
.NET uses the section delimiter (:) to flatten everything into one Dictionary<string, string>, preserving the parent-child relationship:
Configuration precedence
When multiple configuration providers contain the same key, the value from the provider added later has higher priority and overrides the previous value.
The application code doesn’t need to know where the final value originated. It asks the configuration system for Payments:Timeout and receives the effective value after all providers have been processed.
Precedence depends entirely on the order in which providers are registered.
builder.Configuration
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
.AddCommandLine(args);
Command-line arguments win here: that provider was added last.
.NET default configuration
The configuration pipeline is set up inside Program.cs:
using MiniApi.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddKeyValueFile("settings.kv", optional: true);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
WebApplication.CreateBuilder(args) registers the standard application configuration sources automatically, including JSON files, User Secrets in Development, environment variables, command-line arguments, and more:
appsettings.json
This file holds the base configuration for all environments:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
It’s in JSON format and it’s a good fit for values like:
- timeouts
- logging defaults
- feature flags
appsettings.{Environment}.json
These files are environment-specific and override values from appsettings.json. .NET loads appsettings.{ENVIRONMENT}.json after appsettings.json:
// appsettings.json
{
"Payments": {
"Timeout": 30
}
}
// appsettings.Production.json
{
"Payments": {
"Timeout": 60
}
}
Since environment configuration loads after the base file, .NET already knows how to resolve the
{ENVIRONMENT}placeholder.
It reads the ASPNETCORE_ENVIRONMENT variable, set here in launchSettings.json:
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5264",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Environment variables
These values come from the operating system, the host (container), or the CI/CD pipeline. Because different systems (Windows, Mac, Linux) provide them differently, they use a double underscore (__) as a separator. .NET converts that to the section delimiter (:) internally.
A .NET configuration key and its environment-variable equivalent:
| .NET configuration key | Environment variable |
|---|---|
Payments:Timeout | Payments__Timeout |
A Docker Compose file shows this in practice:
services:
api:
environment:
Payments__BaseUrl: https://payments.example.com
Payments__Timeout: 60
Environment variables have higher priority than appsettings.json and appsettings.{Environment}.json:
They let the deployment environment override application defaults without modifying the application package.
Command line
Values can also be attached from the CLI to a specific process at startup:
dotnet MyApp.dll --Payments:Timeout=90
That argument becomes the logical configuration key Payments:Timeout = 90, so application code reads it the same way as any other value:
var timeout =
builder.Configuration.GetValue<int>("Payments:Timeout");
Command-line arguments have the highest precedence and override all previous values:
| Source | Role | Typical environment |
|---|---|---|
appsettings.json | Shared defaults | All |
appsettings.{Environment}.json | Environment defaults | Dev/Staging/Prod |
| User Secrets | Local secrets | Development |
| Environment variables | Deployment values | Usually production |
| Command line | Explicit startup override | Any |
The table is ordered from lowest to highest priority.
IConfiguration
IConfiguration is the read-only abstraction your application uses to access the effective configuration after all registered providers have contributed their values.
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
At this point, builder.Configuration already represents the application’s combined configuration. The key idea is to read configuration through a single API, regardless of where the values originally came from.
The simplest usage accesses values through the indexer, which always returns a string:
var timeout = configuration["Payments:Timeout"];
The same indexer is how a request-pipeline component reads a single value without binding a whole section. An authorization filter checking an API key against configuration["DemoApiKey"] is a real example in Filters in .NET.
GetValue<T> is a better approach, in my opinion, because you get the type you need:
var timeout = configuration.GetValue<int>("Payments:Timeout");
IConfiguration can also expose an entire subtree through sections. The example above can be written a bit longer, but cleaner:
var paymentsSection = configuration.GetSection("Payments");
var timeout = paymentsSection.GetValue<int>("Timeout");
For simple scenarios, direct access through IConfiguration works fine. But as more settings get added, it becomes a maintenance problem:
- configuration keys are repeated as strings
- settings are scattered across the codebase
- related values aren’t modeled together
To avoid that and keep related configuration encapsulated, bind it to strongly typed objects instead.
Binding configuration to objects
A new configuration section is added for payments:
{
"Payments": {
"BaseUrl": "https://api.example.com",
"Timeout": 30,
"RetryCount": 3
}
}
This is a good candidate for a PaymentSettings type. First, the class contract:
public sealed class PaymentSettings
{
public string BaseUrl { get; init; } = string.Empty;
public int Timeout { get; init; }
public int RetryCount { get; init; }
}
None of these properties reject a bad value yet. Timeout binds a negative number just as happily as a positive one. C# 14’s field keyword makes that validation possible directly inside the setter; see .NET 10 and C# 14: The features I’d actually use in production for the pattern.
Then the mapping:
var settings = configuration
.GetSection("Payments")
.Get<PaymentSettings>();
Get<T> always creates a new instance and returns it. Nested objects, if there are any, get populated too. Consuming it looks like this:
var settings = configuration.GetSection("Payments").Get<PaymentSettings>();
Console.WriteLine($"Calling {settings.BaseUrl} with a {settings.Timeout}s timeout");
If you’d rather not create a new object every time, use Bind() instead. It populates an object you’ve already created:
var settings = new PaymentSettings();
configuration
.GetSection("Payments")
.Bind(settings);
My personal preference is
Bind(), since I’m not a fan of creating a new object every time. Use whatever fits your case.
That covers sources, providers, precedence, and binding to typed objects. A follow-up post covers the Options pattern, validation, and a production-ready setup.