Skip to main content
ANVISoftware Solutions
Lesson 7 of 12Intermediate20 min

Configuration

By the end of this lesson

Read settings from files, environment variables and other sources in priority order.

A setting is any value that has to differ between one place your application runs and another: a connection string, a page size, a timeout, the address of another service. Compiling those into your assembly means one build per environment, which defeats the point of building once and deploying the result.

Configuration in .NET is one merged key-value view, assembled from several sources at start-up. Keys are paths, written with colons: Employees:PageSize refers to PageSize inside the Employees section. Key comparison is case-insensitive, so Employees:PageSize and employees:pagesize reach the same value.

The important property is the merge order. Sources are added in sequence, and a later source overrides an earlier one for the same key. Nothing is combined cleverly — the last value in wins.

The order a host builder sets up by default, from lowest priority to highest. This order is the answer to almost every question that starts with why is this setting not what I expect:

  1. appsettings.json

    Committed to the repository. Holds the values that are the same everywhere, plus sensible defaults for the ones that are not.

  2. appsettings.{Environment}.json

    Also committed. The file name includes the current environment name, so appsettings.Production.json layers on top when the environment is Production. It contains only the keys that differ, not a copy of the whole file.

  3. User secrets

    Development only, and stored in your user profile rather than in the project folder. This is where a local connection string or a test API key belongs. It is added after the JSON files, so it overrides them.

  4. Environment variables

    Override everything above. A double underscore stands in for the colon, because most shells will not accept a colon in a variable name: Employees__PageSize sets Employees:PageSize, and ConnectionStrings__Employees sets the connection string of that name. This is the normal way to configure a container.

  5. Command-line arguments

    Highest priority, when the host is given the argument array. Passing --Employees:PageSize=10 beats every source above it, which makes it useful for a one-off run and for confirming which value a key actually resolves to.

appsettings.json, then appsettings.Production.json layered over it
JSON
// appsettings.json — defaults, committed
{
  "Employees": {
    "PageSize": 25,
    "AllowSelfServiceUpdates": true,
    "SyncIntervalMinutes": 60
  },
  "ConnectionStrings": {
    "Employees": "Host=localhost;Database=employees;Username=dev;Password=REPLACE_ME"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

// appsettings.Production.json — only what differs
{
  "Employees": {
    "PageSize": 100,
    "AllowSelfServiceUpdates": false
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  }
}
  • In production, PageSize is 100 and AllowSelfServiceUpdates is false. SyncIntervalMinutes is not mentioned in the second file, so it keeps the value 60 from the first. The merge happens key by key, not file by file.
  • The connection string in the base file is a placeholder that cannot connect anywhere. Real values arrive from user secrets locally and from an environment variable or secret store in production.
  • Arrays are the exception to the intuitive merge. A later source overrides array entries by index rather than replacing the array, so a two-item list layered over a four-item list leaves the last two items in place. If you need to replace a list, model it as an object keyed by name instead.
  • JSON does not allow comments in the strictest reading of the format, but the configuration reader accepts them. The two comment lines above are there to separate the files for you.
Binding a section to a typed class, with validation at start-up
C#
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

public sealed class EmployeeOptions
{
    public const string SectionName = "Employees";

    [Range(1, 500)]
    public int PageSize { get; set; } = 25;

    public bool AllowSelfServiceUpdates { get; set; }

    [Range(1, 1440)]
    public int SyncIntervalMinutes { get; set; } = 60;
}

// Program.cs
var builder = Host.CreateApplicationBuilder(args);

builder.Services
    .AddOptions<EmployeeOptions>()
    .Bind(builder.Configuration.GetSection(EmployeeOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// A class that needs the settings asks for them by type
public sealed class EmployeeDirectory(IOptions<EmployeeOptions> options)
{
    private readonly EmployeeOptions _options = options.Value;

    public int PageSize => _options.PageSize;
}
  • Binding matches configuration keys to public settable properties by name. A key with no matching property is ignored silently, and a property with no matching key keeps its default — which is why a typo in a key name produces a working application with the wrong value rather than an error.
  • The defaults on the properties are what you get when the section is missing entirely. Choosing safe defaults here is cheaper than handling absent configuration everywhere else.
  • ValidateDataAnnotations checks the attributes. ValidateOnStart runs that check when the host starts instead of when the options are first requested, so a bad value stops the application immediately rather than failing the first request that happens to need it. Without ValidateOnStart, a misconfigured deployment can look healthy for minutes.
  • The class asks for IOptions<EmployeeOptions>, not IConfiguration. That means it states exactly which settings it needs, it can be constructed in a test with a plain object, and it cannot reach any other part of the configuration.
  • ValidateDataAnnotations lives in the Microsoft.Extensions.Options.DataAnnotations package. Web projects already have it through the shared framework; a console project may need the reference.

Three ways to receive options, differing only in what happens when configuration changes while the application is running. File providers watch for changes by default, so this is not a theoretical concern:

IOptions<T>
Bound once, cached for the life of the process. It is registered as a singleton, so it can be injected anywhere. Changes to the file after start-up are not seen. This is the right default: most settings should not change under a running application.
IOptionsSnapshot<T>
Recomputed once per scope and consistent within it. In a web application that means every request sees one coherent set of values, and a change to the file is picked up by the next request. Because it is scoped, it cannot be injected into a singleton.
IOptionsMonitor<T>
A singleton that exposes the current value and a change notification. It is the only one of the three a singleton or a long-running background service can use to see updated values. The cost is that the value can change between two reads in the same operation, so read it once into a local variable.

Summary

  • Configuration is one merged key-value view; keys are colon-delimited paths and comparison is case-insensitive
  • Sources are layered and the last one in wins: JSON files, then user secrets in development, then environment variables written with a double underscore in place of a colon, then the command line
  • Bind a section to a typed options class and validate on start-up, so a bad value stops the application instead of one request
  • IOptions is cached for the process, IOptionsSnapshot is per scope, IOptionsMonitor is the only one a singleton can use to see changes
  • Secrets belong in user secrets locally and a managed secret store in deployed environments, never in a committed file

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Watch the order work

Set Employees:PageSize to 25 in appsettings.json. Now set the same key to 50 as an environment variable using the double underscore form, and to 75 on the command line.

Log the resolved value at start-up and run it three ways: plain, with the variable set, and with both the variable and the argument. Then remove the argument and misspell the environment variable with a single underscore.

Show solution

You should see 25, then 50, then 75. Each source added later overrides the one before it for that key, and the command line sits at the top.

The misspelled variable produces 25 again, with no error anywhere. That silence is the point of the exercise: configuration does not tell you about keys it did not recognise, so a typo looks exactly like a value you never set.

Shell
dotnet run
Employees__PageSize=50 dotnet run
Employees__PageSize=50 dotnet run -- --Employees:PageSize=75

Think about it

Which of the three does this service need?

A background worker in the employees API runs continuously and reads SyncIntervalMinutes at the top of each cycle. Operations want to change that interval without restarting the service.

Which of IOptions, IOptionsSnapshot and IOptionsMonitor fits, why are the other two wrong, and what should the worker do about a value that changes mid-cycle?

Show solution

IOptionsMonitor. The worker is effectively a singleton, so a scoped IOptionsSnapshot cannot be injected into it, and IOptions caches the value for the life of the process so the interval would never change.

The worker should read the value into a local variable once per cycle rather than reading it repeatedly. A monitor's value can change at any moment, and a cycle that reads it twice can act on two different intervals in one pass.

There is a design question underneath the mechanical one. Configuration that changes under a running process makes behaviour harder to reason about and harder to reproduce from a log. If a restart is cheap — and with a rolling deployment it usually is — plain IOptions and a redeploy is the simpler answer, and simpler is worth something here.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

appsettings.json sets Employees:PageSize to 25, appsettings.Production.json sets it to 100, and an environment variable sets Employees__PageSize to 50. Running in Production, what does the application read?
Why can IOptionsSnapshot not be injected into a singleton service?

Saved in this browser only.