Configuration and the Options Pattern
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.
Learn Configuration and the Options Pattern in our free C# course — an interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 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.
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.
Your turn. Bind the Smtp section onto a POCO by naming the section. Fill in the ___ blank.
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.
Now you try: register the options and read the bound value. Fill in the two ___ blanks.
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.
🔎 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:
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.
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.
🎉 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
- ✅ Next lesson: Concurrency with Channels — the producer/consumer pattern
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