Skip to main content
ANVISoftware Solutions
Lesson 7 of 23Intermediate16 min

Configuration and Options

By the end of this lesson

Bind configuration sections to typed options classes.

Configuration in ASP.NET Core is one set of key and value pairs, built by reading several sources in order and letting later sources overwrite earlier ones. Your code reads the merged result and does not know which source a value came from.

That indirection is the point. The same build runs on a laptop, in a test environment and in production, with only the values differing. Nothing is recompiled to change a page size or a service address.

CreateBuilder sets up these sources for you, in this order. A key present in a later source replaces the same key from an earlier one:

  1. appsettings.json — values shared by every environment, committed to source control.
  2. appsettings.{Environment}.json — values for one environment, for example appsettings.Development.json.
  3. User secrets — a file kept outside the project folder, used in Development only, for values that must not be committed.
  4. Environment variables — how most hosting platforms supply settings. A colon in a key becomes a double underscore, so the key EmployeeDirectory:PageSize is set as EmployeeDirectory__PageSize.
  5. Command-line arguments — the last word, useful for a one-off override while investigating something.
appsettings.json
JSON
{
  "EmployeeDirectory": {
    "PageSize": 25,
    "UpstreamHrUrl": "https://hr-upstream.internal.example.com",
    "CacheSeconds": 300,
    "IncludeInactiveEmployees": false
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}
Binding the section to a class, and using it
C#
using System.ComponentModel.DataAnnotations;

public sealed class DirectoryOptions
{
    public const string SectionName = "EmployeeDirectory";

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

    [Required]
    [Url]
    public string UpstreamHrUrl { get; set; } = string.Empty;

    public int CacheSeconds { get; set; } = 60;

    public bool IncludeInactiveEmployees { get; set; }
}

// Program.cs — builder phase
builder.Services
    .AddOptions<DirectoryOptions>()
    .Bind(builder.Configuration.GetSection(DirectoryOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// EmployeeQueryService.cs
public sealed class EmployeeQueryService(IOptions<DirectoryOptions> options)
{
    private readonly DirectoryOptions _options = options.Value;

    public int PageSize => _options.PageSize;
}
  • Property names match the keys in the section. Binding is by name and is not case-sensitive, so pagesize would bind to PageSize.
  • The defaults on the properties matter. If a key is absent, the property keeps its initialised value rather than quietly becoming zero or null.
  • Bind attaches the EmployeeDirectory section to the class. GetSection never returns null, so a mistyped section name gives you an empty section rather than an error — which is the reason for the next two lines.
  • ValidateDataAnnotations checks the attributes on the class. ValidateOnStart runs that check as the application starts, so a bad or missing value stops the process instead of failing on whichever request first needs it.
  • Keeping the section name as a constant on the class means the string appears once. Two copies of a section name is a typo waiting to happen.
  • The consuming service asks for IOptions of the class and reads Value once. It never sees IConfiguration, so a test can construct it with a plain object and no configuration system at all.

There are three ways to receive options, and two of them cover most cases. The third, IOptionsMonitor, is a singleton that exposes the current value and can notify you when it changes; reach for it when a long-lived service needs to see updates.

 IOptions of TIOptionsSnapshot of T
LifetimeSingleton. Bound once, on first use.Scoped. Recomputed once per request.
Sees an edit to appsettings.json while running?No. The value stays as it was bound.Yes, from the next request onwards.
Safe to inject into a singleton?YesNo. It is scoped, so holding it in a singleton is a captive dependency.
Use forSettings fixed for the life of the process: a service address, a connection string.Settings you expect to change while the application runs, read inside a request.
CostNone per requestOne bind per request for each options type. Small, and not nothing.

Summary

  • Configuration is one merged set of keys built from ordered providers, where later providers win
  • Environment-specific files overlay the shared file key by key rather than replacing it
  • Bind a section to a typed options class so settings arrive named, typed and checked
  • IOptions binds once, IOptionsSnapshot rebinds per request, IOptionsMonitor reports changes to long-lived services
  • Secrets belong in user secrets locally and in environment or platform secret stores elsewhere, 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 one key override another

Set PageSize to 25 in appsettings.json and to 5 in appsettings.Development.json. Log the bound value at startup.

Run the application in Development, then run it with the environment set to Production, and compare. Then set the same key as an environment variable and run again.

Show solution

Development gives 5, Production gives 25, and the environment variable wins over both. The providers are read in order and later values replace earlier ones, key by key.

Note the phrase key by key. The Development file does not replace the shared file, it overlays it, so keys you did not repeat keep their shared values. This is what makes a small environment file the right pattern and a full copy of appsettings.json the wrong one.

The environment variable result is the one worth remembering, because it is how a deployment supplies values. If a setting is wrong in production and correct in the file, something in the environment is overriding it.

Think about it

Where does the connection string live?

The employees API needs a database connection string for three situations: a developer's machine, an automated test run in your build pipeline, and production.

Decide where each one comes from, and say what would have to be true for a committed file to be the right answer.

Show solution

Locally, the user secrets store. It is read automatically in Development, it sits outside the project folder, and it cannot be committed by accident.

In the build pipeline, an environment variable populated from the pipeline's own secret store. The build should not be able to read production credentials, so this is usually a throwaway test database.

In production, an environment variable or a managed secret store provided by the platform, injected at start. Nothing about the application code changes across all three, which is the property that makes this work.

A committed file is defensible only when the value is not a secret: a connection string to a local database that ships with the repository, with no password, reachable by nobody outside the machine. The test is whether you would be comfortable with the value being public, since the repository history means it effectively is.

Saved in this browser only.