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

💡 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.

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:

InterfaceLifetimeUse when
IOptions<T>Singleton, bound onceSettings never change after startup (most cases)
IOptionsSnapshot<T>Scoped, per requestYou want fresh values each web request
IOptionsMonitor<T>Singleton, liveA 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

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Read a keyconfig["Smtp:Host"]Colon = hierarchy
Read typedconfig.GetValue<int>("Smtp:Port")Parses the value
Get a sectionconfig.GetSection("Smtp")A sub-tree
Bind optionsservices.Configure<T>(section)Registers for DI
Consume optionsIOptions<T>.ValueTyped settings
React to changesIOptionsMonitor<T>.CurrentValueFor 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

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