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.
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.
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
Steps
Section
Group keys in appsettings.json
Class
Make a matching C# class
Bind
Call AddOptions and BindConfiguration
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.
Here is a table to keep handy.
| Interface | Lifetime | Reads fresh values? | Best for |
|---|---|---|---|
IOptions<T> | Singleton | No, cached once | Settings that never change while running |
IOptionsSnapshot<T> | Scoped | Yes, once per request | Settings that may change between requests |
IOptionsMonitor<T> | Singleton | Yes, anytime | Singleton 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
Steps
Singleton?
If yes and live values needed, use IOptionsMonitor
Needs live change?
If reacting to edits, use IOptionsMonitor
Per request?
If fresh per request, use IOptionsSnapshot
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.
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.
| Approach | Use when |
|---|---|
| Default options | You have one set of values for a class |
| Named options | You 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.
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
- Options pattern in ASP.NET Core (Microsoft Learn)
- Options pattern in .NET (Microsoft Learn)
- Adding validation to the options pattern (Milan Jovanovic)
- ASP.NET Core Configuration, Options Pattern (Code Maze)
- Understanding IOptions, IOptionsSnapshot, and IOptionsMonitor (Felipe Gavilan)
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 callAddOptionsandBindConfiguration. - Inject
IOptions<T>for settings that never change,IOptionsSnapshot<T>for fresh values per request, andIOptionsMonitor<T>for singletons or live reload. - Never inject scoped
IOptionsSnapshot<T>into a singleton service. - Add
ValidateDataAnnotationsandValidateOnStartso a bad config stops the app at boot, not later. - Use
ValidateorIValidateOptions<T>for complex rules, and named options when one class needs many value sets. - Binding to a class instead of reading raw
IConfigurationstrings gives you type safety, validation, and easy testing.
Related Posts
Scheduling Background Jobs with Quartz.NET in ASP.NET Core
Learn Quartz.NET step by step in ASP.NET Core: jobs, triggers, cron schedules, dependency injection, and database persistence, explained for beginners.
TickerQ: The Modern .NET Job Scheduler That Beats Quartz and Hangfire
Learn TickerQ, the fast, reflection-free .NET job scheduler with cron and time jobs, EF Core storage, retries, and a live dashboard, explained for beginners.
Adding Real-Time Functionality to .NET Apps with SignalR
Learn ASP.NET Core SignalR step by step: hubs, clients, groups, and scaling with Redis or Azure, explained for absolute beginners.
Improving ASP.NET Core Dependency Injection with Scrutor
Learn how Scrutor makes ASP.NET Core dependency injection easier with assembly scanning and decoration, explained in simple, beginner-friendly steps.
How to Use the Options Pattern in ASP.NET Core 7
A beginner-friendly guide to the options pattern in ASP.NET Core: bind appsettings to classes, and pick between IOptions, IOptionsSnapshot, and IOptionsMonitor.
How to Add JWT Authentication to SignalR Hubs in ASP.NET Core
A beginner-friendly guide to securing SignalR hubs with JWT tokens in ASP.NET Core, including the access_token query string trick and the [Authorize] attribute.