Configuration and the Options Pattern
Reviewed & published by Brayan K
Every real application needs settings — connection strings, API keys, feature flags, timeouts. ASP.NET Core gives you a powerful, layered configuration system through IConfiguration, and the Options pattern to turn raw settings into strongly typed classes you can inject and validate. This is the standard, idiomatic way to manage configuration in modern .NET.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Read settings through IConfiguration
- Layer appsettings.json, env vars, and user secrets
- Bind a config section to a typed POCO class
- Register options with services.Configure<T>
- Choose IOptions, IOptionsSnapshot, IOptionsMonitor
- Validate options so bad config fails fast
💡 Real-World Analogy
Think of configuration like the settings on a phone that arrive in layers. The manufacturer ships sensible defaults (appsettings.json), your carrier tweaks a few (appsettings.Production.json), and finally you override what you like in Settings (environment variables). Each layer can override the one before, and the value you actually see is the result of all layers merged. The Options pattern is then like a tidy "Wi-Fi settings" screen: instead of hunting through one giant list of keys, related settings are grouped into a clean, typed object.
The mental model: sources in, typed objects out
IConfiguration is a single read-only key/value view, built by stacking providers. The Options pattern then binds a slice of that view to a class so your code never touches raw string keys.
- 📄 Sources — appsettings.json, appsettings.{Env}.json, user secrets, environment variables, command line.
- 🔼 Layering — added in order; later providers override earlier ones for the same key.
- 📦 services.Configure<T>(...) — bind a section to a POCO and register it for injection.
- 💉 IOptions<T> / IOptionsSnapshot<T> / IOptionsMonitor<T> — the three ways to consume bound options.
Keys use a colon to express hierarchy (Smtp:Host); the same key as an environment variable uses a double underscore (Smtp__Host) because some shells dislike colons.
1. Reading IConfiguration
At its simplest, IConfiguration is a dictionary you read by key. Hierarchy is expressed with a colon, so Smtp:Host reaches into the Smtp section. Use the indexer for strings, or GetValue<T> to parse a typed value. Run the worked example to see it in action.
// appsettings.json (illustrative)
// {
// "Smtp": { "Host": "mail.example.com", "Port": 587 },
// "FeatureFlags": { "NewDashboard": true }
// }
using Microsoft.Extensions.Configuration;
using System;
class Program
{
static void Main()
{
// IConfiguration is a unified, read-only view built from many sources.
// Here we build it from a couple of in-memory pairs to illustrate.
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new System.Collections.Generic.KeyValuePair<string, string?>("Smtp:Host", "mail.example.com"),
new System.Collections.Generic.KeyValuePair<string, string?>("Smtp:Port", "587"),
})
.Build();
// Hierarchy uses a COLON separator: "Smtp:Host".
string host = config["Smtp:Host"]!; // mail.example.com
int port = config.GetValue<int>("Smtp:Port"); // 587
Console.WriteLine($"SMTP -> {host}:{port}");
}
}2. Layered Sources & Overrides
The real power is layering. You stack providers in order — JSON file, then environment-specific JSON, then user secrets, then environment variables, then command line — and later providers override earlier ones for the same key. This is how the same code runs in dev, staging, and production with different values.
using Microsoft.Extensions.Configuration;
using System.Collections.Generic;
using System;
class Program
{
static void Main()
{
// Sources are LAYERED in order. LATER providers OVERRIDE earlier ones.
// Think: appsettings.json -> appsettings.{Env}.json -> user secrets
// -> environment variables -> command line.
var baseSettings = new Dictionary<string, string?> { ["Api:Url"] = "https://prod.example.com" };
var envOverride = new Dictionary<string, string?> { ["Api:Url"] = "https://staging.example.com" };
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(baseSettings) // added first
.AddInMemoryCollection(envOverride) // added later -> WINS
.Build();
// The later source overrode the earlier one for the same key.
Console.WriteLine(config["Api:Url"]); // https://staging.example.com
}
}Your turn. Bind the Smtp section onto a POCO by naming the section. Fill in the ___ blank.
using Microsoft.Extensions.Configuration;
using System.Collections.Generic;
using System;
class Program
{
// A POCO ("plain old C# object") that mirrors the config section shape.
class SmtpOptions
{
public string Host { get; set; } = "";
public int Port { get; set; }
}
static void Main()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Smtp:Host"] = "mail.example.com",
["Smtp:Port"] = "587"
})
.Build();
// 🎯 YOUR TURN — fill in the blanks marked with ___
var options = new SmtpOptions();
// 1) Grab the "Smtp" section by name.
config.GetSection("___").Bind(options); // 👉 the section name: Smtp
Console.WriteLine($"{options.Host}:{options.Port}"); // mail.example.com:587
}
}3. The Options Pattern: Configure<T> + IOptions<T>
Rather than reading keys by hand, register a binding once with services.Configure<T>(config.GetSection("...")). Then any service can inject IOptions<T> and read .Value to get a clean, strongly typed settings object. This decouples your code from raw configuration keys.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System;
class Program
{
class EmailOptions
{
public string From { get; set; } = "";
public int RetryCount { get; set; }
}
static void Main()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Email:From"] = "noreply@app.com",
["Email:RetryCount"] = "3"
})
.Build();
// Register the binding: Configure<T> wires a section to the options.
var services = new ServiceCollection();
services.Configure<EmailOptions>(config.GetSection("Email"));
var provider = services.BuildServiceProvider();
// Consume it: inject IOptions<T> and read .Value (a singleton, bound once).
IOptions<EmailOptions> opt = provider.GetRequiredService<IOptions<EmailOptions>>();
Console.WriteLine($"{opt.Value.From} (retries: {opt.Value.RetryCount})");
// noreply@app.com (retries: 3)
}
}Now you try: register the options and read the bound value. Fill in the two ___ blanks.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System;
class Program
{
class CacheOptions
{
public int DurationSeconds { get; set; }
}
static void Main()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Cache:DurationSeconds"] = "60" })
.Build();
var services = new ServiceCollection();
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Register CacheOptions bound to the "Cache" section.
services.___<CacheOptions>(config.GetSection("Cache")); // 👉 method: Configure
var provider = services.BuildServiceProvider();
var opt = provider.GetRequiredService<IOptions<CacheOptions>>();
// 2) Read the bound value off the options object.
Console.WriteLine(opt.___.DurationSeconds); // 👉 the property: Value
// ✅ Expected output:
// 60
}
}4. Validating Options (Fail Fast)
Bad configuration should break the app at startup, not three layers deep at 2 a.m. Using AddOptions<T>().Bind(...).ValidateDataAnnotations().ValidateOnStart(), you can decorate your POCO with [Required] and [Range] and have the app refuse to start if the config is invalid.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System;
class Program
{
class ApiOptions
{
[Required] public string BaseUrl { get; set; } = "";
[Range(1, 100)] public int MaxRetries { get; set; }
}
static void Main()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Api:BaseUrl"] = "https://api.example.com",
["Api:MaxRetries"] = "5"
})
.Build();
var services = new ServiceCollection();
// AddOptions + Bind + ValidateDataAnnotations + ValidateOnStart means
// a bad config FAILS FAST at startup instead of breaking deep in code.
services.AddOptions<ApiOptions>()
.Bind(config.GetSection("Api"))
.ValidateDataAnnotations()
.ValidateOnStart();
var provider = services.BuildServiceProvider();
var opt = provider.GetRequiredService<IOptions<ApiOptions>>();
Console.WriteLine($"OK: {opt.Value.BaseUrl} (retries {opt.Value.MaxRetries})");
}
}🔎 Deep Dive: IOptions vs IOptionsSnapshot vs IOptionsMonitor
The Options pattern offers three consumption interfaces, and picking the right one matters:
| Interface | Lifetime | Use when |
|---|---|---|
| IOptions<T> | Singleton, bound once | Settings never change after startup (most cases) |
| IOptionsSnapshot<T> | Scoped, per request | You want fresh values each web request |
| IOptionsMonitor<T> | Singleton, live | A singleton must react to reloads via CurrentValue / OnChange |
Rule of thumb: reach for IOptions<T> by default. Use IOptionsSnapshot<T> in request-scoped services that should see per-request changes, and IOptionsMonitor<T> in long-lived singletons (background services) that need to notice config file changes while running:
// A singleton background worker that reacts to live config changes
public Worker(IOptionsMonitor<MyOptions> monitor)
{
_current = monitor.CurrentValue; // latest value right now
monitor.OnChange(updated => _current = updated); // refresh on reload
}Putting It Together: a Typed Settings Service
Here's the idiomatic end-to-end flow: a POCO describes the settings, Configure<T> binds the section, and a service depends on IOptions<T> rather than on raw configuration. The service has no idea where the values came from — file, env var, or secret — and that's exactly the point.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System;
class Program
{
// Strongly typed settings for a notification service.
class NotificationOptions
{
public string Channel { get; set; } = "email";
public bool Enabled { get; set; }
public int BatchSize { get; set; }
}
// A service depends on the OPTIONS, not on raw configuration keys.
class Notifier
{
private readonly NotificationOptions _opts;
public Notifier(IOptions<NotificationOptions> opts) => _opts = opts.Value;
public string Describe() =>
_opts.Enabled
? $"Sending via {_opts.Channel} in batches of {_opts.BatchSize}"
: "Notifications disabled";
}
static void Main()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Notifications:Channel"] = "sms",
["Notifications:Enabled"] = "true",
["Notifications:BatchSize"] = "25"
})
.Build();
var services = new ServiceCollection();
services.Configure<NotificationOptions>(config.GetSection("Notifications"));
services.AddTransient<Notifier>();
var provider = services.BuildServiceProvider();
var notifier = provider.GetRequiredService<Notifier>();
Console.WriteLine(notifier.Describe());
// Sending via sms in batches of 25
}
}Swap the config source and the Notifier code never changes — it just consumes a clean settings object.
Pro Tips
- 💡 Never commit secrets: keep API keys and passwords in user secrets (dev) or a vault / environment variables (prod), out of appsettings.json.
- 💡 Group related settings into one POCO per concern (e.g. SmtpOptions, JwtOptions) instead of one giant settings class.
- 💡 Validate at startup with ValidateOnStart() so misconfiguration is caught immediately, not at first use.
- 💡 Prefer typed options over scattering config["Some:Key"] calls — you get compile-time safety and one place to change the shape.
- 💡 Remember the override order: environment variables and command-line args sit on top, which is how you tweak prod without redeploying files.
Common Errors (and the fix)
- Options come back with default values: the section name or property names don't match the JSON. Binding is by name — check GetSection("X") matches the key and the POCO property names match the children.
- Injecting IOptionsSnapshot<T> into a singleton: it's scoped, so this throws. Use IOptionsMonitor<T> in singletons instead.
- Secrets committed to source control: move them to user secrets or environment variables; never put them in appsettings.json.
- Colon vs double-underscore confusion: in JSON and code use Smtp:Host; as an environment variable use Smtp__Host.
- No validation, silent bad config: add ValidateDataAnnotations().ValidateOnStart() so invalid settings fail fast at startup.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Read a key | config["Smtp:Host"] | Colon = hierarchy |
| Read typed | config.GetValue<int>("Smtp:Port") | Parses the value |
| Get a section | config.GetSection("Smtp") | A sub-tree |
| Bind options | services.Configure<T>(section) | Registers for DI |
| Consume options | IOptions<T>.Value | Typed settings |
| React to changes | IOptionsMonitor<T>.CurrentValue | For singletons |
| Validate | .ValidateOnStart() | Fail fast |
Frequently Asked Questions
Q: If a key is in both the JSON file and an environment variable, which wins?
The environment variable, normally. Sources are layered in order and later providers override earlier ones; environment variables are added after the JSON files, so they win for the same key.
Q: When should I use IOptionsSnapshot<T> vs IOptionsMonitor<T>?
IOptionsSnapshot<T> is scoped — great for getting fresh values per web request. IOptionsMonitor<T> is a singleton with CurrentValue and OnChange — use it in long-lived singletons that must react to config reloads.
Q: Where do I keep secrets during development?
In the Secret Manager (user secrets), which stores them outside your project folder and out of source control. In production use environment variables or a secrets vault — never appsettings.json.
Q: Why bother binding to a class instead of reading keys directly?
Typed options give you compile-time safety, support validation, and decouple your services from raw string keys. Consumers depend on a clean settings object, and the shape lives in one place.
Mini-Challenge: bind a PaymentOptions class
No blanks this time — just a brief and an outline. Define a PaymentOptions POCO, build configuration with a Payment section, register it with Configure<T>, then resolve IOptions<PaymentOptions> and print the provider and fee. Run it and check your output against the expected line.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System;
class Program
{
static void Main()
{
// 🎯 MINI-CHALLENGE: bind and use a PaymentOptions class
// 1. Define a POCO: class PaymentOptions { public string Provider; public decimal FeePercent; }
// (use { get; set; } properties with sensible defaults).
// 2. Build IConfiguration with "Payment:Provider" = "stripe" and
// "Payment:FeePercent" = "2.9".
// 3. Register it with services.Configure<PaymentOptions>(config.GetSection("Payment")).
// 4. Resolve IOptions<PaymentOptions> and print Provider and FeePercent.
//
// ✅ Expected output:
// stripe charges 2.9% per transaction
// your code here
}
}🎉 Lesson Complete
- ✅ IConfiguration is a unified, read-only view built from layered sources
- ✅ Later providers (env vars, command line) override earlier ones (JSON files)
- ✅ Keep secrets in user secrets / env vars, never in appsettings.json
- ✅ services.Configure<T> binds a section to a typed POCO
- ✅ Consume with IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T>
- ✅ Validate options with ValidateDataAnnotations().ValidateOnStart() to fail fast
Practice quiz
What is IConfiguration in ASP.NET Core?
- A unified, read-only view of settings merged from many sources
- A logging framework
- A database connection
- A dependency injection container
Answer: A unified, read-only view of settings merged from many sources. IConfiguration presents a single key/value view built by layering multiple configuration sources (files, env vars, secrets) into one read-only model.
If the same key appears in appsettings.json and in an environment variable, which value usually wins?
- It throws an error
- Whichever is alphabetically first
- The environment variable, because later sources override earlier ones
- appsettings.json always wins
Answer: The environment variable, because later sources override earlier ones. Configuration sources are layered in order; later providers override earlier ones. Environment variables are typically added after JSON files, so they override them.
What does the Options pattern let you do?
- Only read environment variables
- Bind a configuration section to a strongly typed POCO class
- Replace dependency injection
- Disable configuration entirely
Answer: Bind a configuration section to a strongly typed POCO class. The Options pattern binds a section of configuration to a plain class (POCO) so your code consumes strongly typed settings instead of raw string keys.
Which call registers a settings class to bind from a configuration section?
- config.Bind()
- services.AddSingleton<T>()
- services.UseOptions<T>()
- services.Configure<T>(config.GetSection("..."))
Answer: services.Configure<T>(config.GetSection("...")). services.Configure<T>(configuration.GetSection("Name")) registers the binding so T can be injected later as IOptions<T> and friends.
What is the difference between IOptions<T> and IOptionsSnapshot<T>?
- IOptions<T> is a singleton computed once; IOptionsSnapshot<T> is scoped and re-reads per request
- They are identical
- IOptionsSnapshot<T> is never recomputed
- IOptions<T> only works in console apps
Answer: IOptions<T> is a singleton computed once; IOptionsSnapshot<T> is scoped and re-reads per request. IOptions<T> is a singleton bound once at startup. IOptionsSnapshot<T> is scoped and recomputed per request, so it can pick up changes for each request.
Which interface is best for a singleton service that must react to config changes at runtime?
- IOptionsSnapshot<T>
- IOptionsMonitor<T>
- IConfiguration directly
- IOptions<T>
Answer: IOptionsMonitor<T>. IOptionsMonitor<T> exposes CurrentValue and an OnChange callback, so a singleton can read fresh values and respond to reloads at runtime.
What is the recommended place to store secrets like API keys during local development?
- Hard-coded in the source
- In a public README
- Committed in appsettings.json
- The Secret Manager (user secrets), kept out of source control
Answer: The Secret Manager (user secrets), kept out of source control. The user secrets store (Secret Manager) keeps development secrets outside the project folder and out of source control, unlike appsettings.json.
How are nested settings like Logging:LogLevel:Default referenced in configuration keys?
- With a slash
- They cannot be nested
- With a colon separator, Logging:LogLevel:Default
- With a dot, Logging.LogLevel.Default
Answer: With a colon separator, Logging:LogLevel:Default. Configuration keys use a colon to denote hierarchy; environment variables use a double underscore that maps to the colon.
How can you validate bound options so a bad config fails fast?
- You cannot validate options
- Use ValidateDataAnnotations() and ValidateOnStart() on the OptionsBuilder
- Only by manual if-checks in every controller
- By disabling configuration
Answer: Use ValidateDataAnnotations() and ValidateOnStart() on the OptionsBuilder. services.AddOptions<T>().Bind(...).ValidateDataAnnotations().ValidateOnStart() validates the bound values and fails at startup if they're invalid.
Why prefer the Options pattern over reading IConfiguration string keys everywhere?
- It gives strong typing, validation, and decouples code from raw keys
- It is slower but simpler
- It avoids using dependency injection
- It only works for connection strings
Answer: It gives strong typing, validation, and decouples code from raw keys. Binding to typed classes gives compile-time safety, supports validation, and means consumers depend on a clean settings object rather than scattered magic strings.
Continue this course
- Previous: Iterators with yield return
- Next: Concurrency with Channels — Producer/consumer pipelines with System.Threading.Channels and backpressure
- Quick reference: C# cheat sheet