Skip to main content
SEMastery
ASP.NETbeginner

Master Configuration in ASP.NET Core with the Options Pattern

Learn the ASP.NET Core options pattern step by step: bind appsettings, use IOptions, IOptionsSnapshot, IOptionsMonitor, and validate config safely.

11 min readUpdated December 7, 2025

Imagine your mother keeps a small recipe diary in the kitchen. The diary says how much salt, how much sugar, and how long to cook. She does not memorise these numbers. She just opens the diary and reads them. If the family wants less sugar next month, she changes one line in the diary. She does not rewrite the whole recipe.

Your ASP.NET Core app needs a diary too. Things like the database address, an email server name, or how many items to show on a page should not be typed inside your code. They live in a settings file. The options pattern is the clean, official way your app reads that diary.

In this guide you will learn what the options pattern is, why it helps, and how to use it well in .NET 10. We will go slowly, with simple words and real code.

What problem are we solving?

Without a pattern, people scatter settings everywhere. One file reads a value like this, another file reads it a different way. Some read it as text and forget to convert it to a number. When the setting changes, you have to hunt through many files.

The options pattern fixes this by giving you one strongly typed class for a group of settings. Your code asks for that class. It never touches raw strings.

Settings flow from a file into a typed class, then into your code

Here is the idea in plain terms. You have a file. The file has values. You make a C# class that matches those values. The framework copies the values into the class. Your code uses the class. That is the whole journey.

Step 1: Put your settings in appsettings.json

Settings usually live in appsettings.json. Group related settings under one section name. Below is a section called Email with three values.

// appsettings.json
{
  "Email": {
    "Host": "smtp.example.com",
    "Port": 587,
    "FromAddress": "[email protected]"
  }
}

The word Email is the section name. Think of it like a heading in the recipe diary. Everything under it belongs together.

Step 2: Make a class that matches the settings

Now make a plain C# class. Each property name must match a key in the section. The types must match too. Port is a number, so it is an int.

public class EmailOptions
{
    public const string SectionName = "Email";
 
    public string Host { get; set; } = string.Empty;
    public int Port { get; set; }
    public string FromAddress { get; set; } = string.Empty;
}

The SectionName constant is a small trick. It stores the section name Email in one place. Later, if you rename the section, you change it here only. This avoids typos.

Step 3: Bind the section to the class

Binding means "copy the values from the file into the class". You do this once, in Program.cs. The modern .NET 10 way is short and readable.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName);
 
var app = builder.Build();

AddOptions starts the setup. BindConfiguration tells the framework which section to read. After this, your EmailOptions class is ready to be injected anywhere.

Wiring the options pattern

Section
Class
Bind

Steps

1

Section

Group keys in appsettings.json

2

Class

Make a matching C# class

3

Bind

Call AddOptions and BindConfiguration

The three small steps you do once in Program.cs

Step 4: Use the settings in a service

Now the fun part. Your service asks for the settings through its constructor. This is dependency injection at work. You inject IOptions<EmailOptions> and read its Value.

public class EmailSender
{
    private readonly EmailOptions _options;
 
    public EmailSender(IOptions<EmailOptions> options)
    {
        _options = options.Value;
    }
 
    public void Send(string to, string body)
    {
        // Use _options.Host, _options.Port, _options.FromAddress
        Console.WriteLine($"Sending from {_options.FromAddress} via {_options.Host}");
    }
}

Notice you never wrote Configuration["Email:Host"]. You just used _options.Host. It is type-safe. If you spell a property wrong, the code will not even compile. That is a big safety win.

The three flavours: IOptions, IOptionsSnapshot, IOptionsMonitor

This is the part that confuses many beginners. There are three interfaces you can inject. They look similar but behave differently. Pick the right one and your app behaves correctly.

Think of three students reading the same noticeboard.

  • One student reads the notice once in the morning and never looks again.
  • One student reads a fresh copy each time the school bell rings.
  • One student keeps watching the board and shouts the moment a notice changes.

That is exactly how the three interfaces work.

How each interface reads configuration over time

Here is a table to keep handy.

InterfaceLifetimeReads fresh values?Best for
IOptions<T>SingletonNo, cached onceSettings that never change while running
IOptionsSnapshot<T>ScopedYes, once per requestSettings that may change between requests
IOptionsMonitor<T>SingletonYes, anytimeSingleton services and live reload

IOptions: simple and fast

IOptions<T> is a singleton. It reads the value once and keeps it. It is the fastest because it caches. Use it when your settings do not change while the app runs. Most apps use this for most settings.

public class ReportService
{
    private readonly EmailOptions _options;
 
    public ReportService(IOptions<EmailOptions> options)
    {
        _options = options.Value;
    }
}

IOptionsSnapshot: fresh each request

IOptionsSnapshot<T> is scoped. In a web app, a scope is usually one HTTP request. So you get fresh values once per request. This matters if someone edits the settings file while the app is running and you want the next request to see the new value.

One rule to remember: you cannot inject a scoped service into a singleton. So you cannot inject IOptionsSnapshot<T> into a singleton service. The app will throw an error if you try.

public class CheckoutService
{
    private readonly EmailOptions _options;
 
    public CheckoutService(IOptionsSnapshot<EmailOptions> options)
    {
        _options = options.Value; // fresh for this request
    }
}

IOptionsMonitor: always current and reactive

IOptionsMonitor<T> is a singleton, but it always gives the current value through CurrentValue. It can also tell you the moment a setting changes, using OnChange. This is the only safe choice when a long-living singleton needs up-to-date settings.

public class CacheCleaner
{
    private readonly IOptionsMonitor<EmailOptions> _monitor;
 
    public CacheCleaner(IOptionsMonitor<EmailOptions> monitor)
    {
        _monitor = monitor;
        _monitor.OnChange(updated =>
            Console.WriteLine($"Email host changed to {updated.Host}"));
    }
 
    public void Run()
    {
        var host = _monitor.CurrentValue.Host; // always latest
    }
}

Choosing the right interface

Singleton?
Needs live change?
Per request?

Steps

1

Singleton?

If yes and live values needed, use IOptionsMonitor

2

Needs live change?

If reacting to edits, use IOptionsMonitor

3

Per request?

If fresh per request, use IOptionsSnapshot

A quick decision path for picking an options interface

Step 5: Validate your settings

A wrong setting can break your app at the worst time, like in the middle of the night when a user is checking out. It is far better to catch a bad setting the moment the app starts. The options pattern lets you do this.

First, add simple rules to your class using data annotations. These are small attributes that describe what a valid value looks like.

using System.ComponentModel.DataAnnotations;
 
public class EmailOptions
{
    public const string SectionName = "Email";
 
    [Required]
    public string Host { get; set; } = string.Empty;
 
    [Range(1, 65535)]
    public int Port { get; set; }
 
    [Required, EmailAddress]
    public string FromAddress { get; set; } = string.Empty;
}

Then turn on validation in Program.cs. The two important calls are ValidateDataAnnotations and ValidateOnStart.

builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

ValidateDataAnnotations checks the attribute rules. ValidateOnStart runs those checks when the app boots. If Host is missing or Port is 0, the app refuses to start and tells you why. This is exactly what you want. A broken config should stop you on the ground, not crash you mid-flight.

Validation happens at startup, before requests arrive

When data annotations are not enough

Data annotations are great for simple rules like "required" or "must be a number in this range". But some rules are more complex. For example, "if UseSsl is true then Port must be 465". For these, you write a custom rule with Validate.

builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName)
    .Validate(o => o.Port != 0 || o.Host.Length > 0,
        "Email needs a valid port or host.")
    .ValidateOnStart();

For very large or shared rules, you can move the logic into a class that implements IValidateOptions<EmailOptions>. That keeps Program.cs tidy. Both approaches run at startup when you add ValidateOnStart.

Named options: many settings of the same shape

Sometimes you have two of the same kind of thing. Maybe a primary email server and a backup email server. Both use EmailOptions, but with different values. Named options solve this.

builder.Services.Configure<EmailOptions>(
    "Primary", builder.Configuration.GetSection("PrimaryEmail"));
 
builder.Services.Configure<EmailOptions>(
    "Backup", builder.Configuration.GetSection("BackupEmail"));

To read a named option, use IOptionsMonitor<T> and call Get("Primary") or Get("Backup"). This way one class serves many configurations.

ApproachUse when
Default optionsYou have one set of values for a class
Named optionsYou have many sets of values for the same class

A common mistake to avoid

Do not inject raw IConfiguration everywhere and read strings by hand. It works, but it loses every benefit of the options pattern. You lose type safety, you lose startup validation, and you lose easy testing. Bind to a class instead. Your future self will thank you.

Another mistake is injecting IOptionsSnapshot<T> into a singleton. Remember the lifetime rule. Scoped cannot go inside singleton. If a singleton needs fresh values, use IOptionsMonitor<T>.

How this fits the bigger picture

Configuration in ASP.NET Core comes from many places: appsettings.json, environment variables, user secrets, and command line arguments. They all feed into one IConfiguration. The options pattern sits on top of that and gives your code a clean, typed view.

Many sources combine, then the options pattern serves typed objects

Because the options pattern reads from IConfiguration, it does not care where the value came from. A value in appsettings.json on your laptop can become an environment variable in production. Your code stays the same. Only the source changes.

Testing becomes easy

One quiet benefit is testing. Because your service depends on IOptions<EmailOptions>, you can hand it a fake value in a unit test. No file needed.

var options = Options.Create(new EmailOptions
{
    Host = "test-host",
    Port = 25,
    FromAddress = "[email protected]"
});
 
var sender = new EmailSender(options);

Options.Create wraps any object in an IOptions<T>. Now your test controls the settings fully. This is much harder if your code reads IConfiguration directly.

Putting it all together

Let us review the full setup in one place. This is what a clean registration looks like in .NET 10.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();
 
builder.Services.AddScoped<EmailSender>();
 
var app = builder.Build();
app.Run();

That is the whole pattern. A section in a file, a class that matches it, a bind call, and validation. From there, inject the right interface for your need and read the values safely.

References and further reading

Quick recap

  • The options pattern reads settings from configuration into a strongly typed class, like reading a recipe diary instead of memorising numbers.
  • Group settings under a section in appsettings.json, make a matching class, then call AddOptions and BindConfiguration.
  • Inject IOptions<T> for settings that never change, IOptionsSnapshot<T> for fresh values per request, and IOptionsMonitor<T> for singletons or live reload.
  • Never inject scoped IOptionsSnapshot<T> into a singleton service.
  • Add ValidateDataAnnotations and ValidateOnStart so a bad config stops the app at boot, not later.
  • Use Validate or IValidateOptions<T> for complex rules, and named options when one class needs many value sets.
  • Binding to a class instead of reading raw IConfiguration strings gives you type safety, validation, and easy testing.

Related Posts