Skip to content

Options Pattern Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

Options pattern interview questions — IOptions vs IOptionsSnapshot vs IOptionsMonitor, named options, validation, and binding from configuration.

Read the in-depth guideThe .NET Options Pattern: IOptions, IOptionsSnapshot, and IOptionsMonitor(opens in new tab)
15 of 15

The options pattern provides a strongly-typed, validated, and injectable wrapper around configuration sections. Instead of accessing raw string keys from IConfiguration, you bind a configuration section to a POCO class and inject IOptions<T>.

// Define a settings class — plain C# record or class:
public class SmtpSettings
{
    public string Host    { get; init; } = "";
    public int    Port    { get; init; } = 587;
    public bool   UseTls  { get; init; } = true;
    public string ApiKey  { get; init; } = "";
}

// appsettings.json:
// {
// "Smtp": { "Host": "smtp.sendgrid.net", "Port": 587, "ApiKey": "SG.xxx" }
// }

// Registration — binds "Smtp" section to SmtpSettings:
builder.Services.Configure<SmtpSettings>(
    builder.Configuration.GetSection("Smtp"));

// Injection — no raw IConfiguration in application code:
public class EmailService
{
    private readonly SmtpSettings _settings;

    public EmailService(IOptions<SmtpSettings> options)
    {
        _settings = options.Value; // strongly typed; compile-time property names
    }

    public Task SendAsync(string to, string subject, string body)
        => SendViaSMTP(_settings.Host, _settings.Port, _settings.ApiKey, to, subject, body);
}

Benefits over raw IConfiguration:

  • Compile-time safety — property names checked by the compiler.
  • Testability — inject Options.Create(new SmtpSettings { ... }) in tests.
  • Validation — validate settings at startup, not at first use.
  • Decoupling — application code has no knowledge of where settings come from.

Rule of thumb: Never inject IConfiguration into application services. Bind it to a typed settings class at startup and inject IOptions<T> instead.

The three interfaces differ in when they read configuration and how they reflect runtime changes.

// IOptions<T> — Singleton; reads config once at startup; never reloads:
public class EmailService
{
    public EmailService(IOptions<SmtpSettings> options)
    {
        var settings = options.Value; // same object for app lifetime; no live reloads
    }
}
builder.Services.AddSingleton<EmailService>(); // safe — Singleton can use IOptions<T>

// IOptionsSnapshot<T> — Scoped; re-reads config on each HTTP request:
public class PricingService
{
    public PricingService(IOptionsSnapshot<PricingSettings> options)
    {
        var settings = options.Value; // fresh snapshot per request; reflects reloads
    }
}
// Cannot be injected into Singleton services — Scoped lifetime.

// IOptionsMonitor<T> — Singleton; fires a callback when config changes:
public class FeatureFlagService
{
    private FeatureFlags _current;

    public FeatureFlagService(IOptionsMonitor<FeatureFlags> monitor)
    {
        _current = monitor.CurrentValue;  // read current value
        monitor.OnChange(updated =>       // callback on any reload
        {
            _current = updated;
            Console.WriteLine("Feature flags reloaded");
        });
    }
}
builder.Services.AddSingleton<FeatureFlagService>(); // safe — IOptionsMonitor is Singleton
Interface Lifetime Reloads? Use when
IOptions<T> Singleton Never Static config; inject into any lifetime
IOptionsSnapshot<T> Scoped Per request Per-request fresh values; not in singletons
IOptionsMonitor<T> Singleton On change + callback Reactive updates; safe in singletons

Rule of thumb: Use IOptions<T> for static config. Use IOptionsSnapshot<T> for per-request values in scoped services. Use IOptionsMonitor<T> for live reloads in singletons.

Three equivalent patterns are available — Configure, Bind, and the newer AddOptions<T>().BindConfiguration() builder style.

// appsettings.json:
// {
// "Database": { "Host": "db.example.com", "Port": 5432, "MaxPoolSize": 20 }
// }

public class DatabaseSettings
{
    public string Host        { get; set; } = "localhost";
    public int    Port        { get; set; } = 5432;
    public int    MaxPoolSize { get; set; } = 10;
}

// Style 1 — Configure (most common):
builder.Services.Configure<DatabaseSettings>(
    builder.Configuration.GetSection("Database"));

// Style 2 — Bind directly to a pre-built instance:
var dbSettings = new DatabaseSettings();
builder.Configuration.GetSection("Database").Bind(dbSettings);
builder.Services.AddSingleton(dbSettings); // inject as a concrete instance

// Style 3 — AddOptions<T> builder (supports validation and PostConfigure fluently):
builder.Services.AddOptions<DatabaseSettings>()
    .BindConfiguration("Database")
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Injecting the typed options:
public class DatabaseFactory
{
    private readonly DatabaseSettings _settings;

    public DatabaseFactory(IOptions<DatabaseSettings> options)
        => _settings = options.Value;

    public NpgsqlConnection CreateConnection()
        => new($"Host={_settings.Host};Port={_settings.Port}");
}

Rule of thumb: Use AddOptions<T>().BindConfiguration() when you also need validation — it chains .ValidateDataAnnotations() and .ValidateOnStart() cleanly. Use Configure<T>(section) for simple bindings.

Named options allow multiple configurations of the same options type under distinct names — useful when the same service class serves different logical contexts (e.g., multiple email providers, multiple external APIs).

public class ApiClientSettings
{
    public string BaseUrl { get; set; } = "";
    public string ApiKey  { get; set; } = "";
    public int    TimeoutMs { get; set; } = 5000;
}

// appsettings.json:
// {
// "Stripe": { "BaseUrl": "https://api.stripe.com", "ApiKey": "sk_live_..." },
// "Sendgrid": { "BaseUrl": "https://api.sendgrid.com", "ApiKey": "SG.xxx" }
// }

// Register with names:
builder.Services.Configure<ApiClientSettings>("Stripe",
    builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<ApiClientSettings>("Sendgrid",
    builder.Configuration.GetSection("Sendgrid"));

// Resolve by name using IOptionsSnapshot or IOptionsMonitor:
public class PaymentService
{
    private readonly ApiClientSettings _stripe;

    public PaymentService(IOptionsSnapshot<ApiClientSettings> options)
    {
        _stripe = options.Get("Stripe");    // named resolution
    }
}

public class EmailService
{
    private readonly ApiClientSettings _sendgrid;

    public EmailService(IOptionsMonitor<ApiClientSettings> monitor)
    {
        _sendgrid = monitor.Get("Sendgrid"); // named resolution
    }
}

// IOptions<T>.Value always returns the unnamed (default) instance:
// options.Value == options.Get(Options.DefaultName) == options.Get("")

Rule of thumb: Use named options when the same settings shape maps to multiple logical configs (two payment providers, three S3 buckets). Pair with IOptionsSnapshot or IOptionsMonitor to call .Get("name").

.NET supports three validation approaches: Data Annotations, IValidateOptions (custom logic), and delegate validation. All can be triggered at startup with ValidateOnStart().

// Approach 1 — Data Annotations (simplest):
public class SmtpSettings
{
    [Required]
    public string Host { get; set; } = "";

    [Range(1, 65535)]
    public int Port { get; set; } = 587;

    [Required, MinLength(20)]
    public string ApiKey { get; set; } = "";
}

builder.Services.AddOptions<SmtpSettings>()
    .BindConfiguration("Smtp")
    .ValidateDataAnnotations()  // validates using System.ComponentModel.DataAnnotations
    .ValidateOnStart();          // throws at startup if validation fails

// Approach 2 — IValidateOptions<T> (complex cross-property rules):
public class SmtpSettingsValidator : IValidateOptions<SmtpSettings>
{
    public ValidateOptionsResult Validate(string? name, SmtpSettings options)
    {
        if (options.UseTls && options.Port == 25)
            return ValidateOptionsResult.Fail(
                "TLS is enabled but port 25 is for unencrypted SMTP. Use 587 or 465.");

        return ValidateOptionsResult.Success;
    }
}

builder.Services.AddSingleton<IValidateOptions<SmtpSettings>, SmtpSettingsValidator>();

// Approach 3 — inline delegate:
builder.Services.AddOptions<SmtpSettings>()
    .BindConfiguration("Smtp")
    .Validate(s => !string.IsNullOrEmpty(s.Host), "Smtp:Host is required")
    .ValidateOnStart();

Without ValidateOnStart(), validation only runs the first time options.Value is accessed — which could be on the first production request rather than at startup.

Rule of thumb: Always pair validation with ValidateOnStart() so misconfigured environments fail immediately with a clear error, not silently under load.

Configure<T> sets option values. PostConfigure<T> runs after all Configure<T> calls and is used to override, sanitize, or augment values — useful in framework/library code that needs to ensure invariants regardless of what the app set.

// Base configuration from appsettings:
builder.Services.Configure<CacheSettings>(
    builder.Configuration.GetSection("Cache"));
// CacheSettings.MaxSize = 500 (from appsettings)

// Library code sets a sensible default if not configured:
builder.Services.Configure<CacheSettings>(settings =>
{
    if (settings.MaxSize == 0)
        settings.MaxSize = 100; // only sets if not already configured — check needed
});

// PostConfigure — runs last; overrides whatever Configure set:
builder.Services.PostConfigure<CacheSettings>(settings =>
{
    // Enforce a hard cap regardless of what appsettings or Configure said:
    if (settings.MaxSize > 10_000)
        settings.MaxSize = 10_000;

    // Ensure derived values are consistent:
    settings.EvictionBatchSize = Math.Min(settings.EvictionBatchSize, settings.MaxSize / 10);
});

// Order of execution for a single options type:
// 1. All Configure<T> calls (in registration order)
// 2. All PostConfigure<T> calls (in registration order)
// Result: PostConfigure always wins — useful for library guarantees.

// PostConfigureAll — applies to all named instances:
builder.Services.PostConfigureAll<ApiClientSettings>(settings =>
{
    // Ensure every named ApiClientSettings has a trailing slash on BaseUrl:
    if (!settings.BaseUrl.EndsWith('/'))
        settings.BaseUrl += '/';
});

Rule of thumb: Use Configure<T> for setting values. Use PostConfigure<T> in library code to enforce invariants or caps that must hold regardless of what application code configured.

Use Microsoft.Extensions.Options.Options.Create<T>() to wrap an in-memory instance as IOptions<T> — no IConfiguration, IServiceCollection, or appsettings.json needed.

// System under test:
public class EmailService
{
    private readonly SmtpSettings _settings;
    public EmailService(IOptions<SmtpSettings> options)
        => _settings = options.Value;

    public string BuildSubject(string template)
        => template.Replace("{domain}", _settings.Domain);
}

// Unit test — no container, no config file:
public class EmailServiceTests
{
    [Fact]
    public void BuildSubject_ReplacesTokenWithDomain()
    {
        // Arrange — wrap an in-memory instance:
        var options = Options.Create(new SmtpSettings
        {
            Host   = "smtp.test.com",
            Port   = 587,
            Domain = "test.com"
        });

        var sut = new EmailService(options);

        // Act:
        var result = sut.BuildSubject("Hello from {domain}");

        // Assert:
        Assert.Equal("Hello from test.com", result);
    }
}

// For IOptionsSnapshot<T> tests, use a mock or TestOptionsManager:
var snapshot = Substitute.For<IOptionsSnapshot<SmtpSettings>>();
snapshot.Value.Returns(new SmtpSettings { Host = "smtp.test.com" });
snapshot.Get("Stripe").Returns(new SmtpSettings { Host = "stripe.smtp.com" });

Rule of thumb: Use Options.Create(new T { ... }) in unit tests — it's simpler than building a full DI container and keeps tests fast and deterministic.

AddOptions<T>() returns an OptionsBuilder<T> that chains binding, validation, and post-configure steps fluently — cleaner than separate Configure, AddSingleton (for validators), and PostConfigure calls.

// Verbose equivalent (three separate calls):
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
builder.Services.AddSingleton<IValidateOptions<JwtSettings>, JwtSettingsValidator>();
builder.Services.PostConfigure<JwtSettings>(s => s.Issuer = s.Issuer.ToLower());

// Fluent equivalent with OptionsBuilder:
builder.Services
    .AddOptions<JwtSettings>()
    .BindConfiguration("Jwt")
    .Validate(s => s.Secret.Length >= 32,
        "Jwt:Secret must be at least 32 characters for HS256")
    .Validate(s => Uri.TryCreate(s.Issuer, UriKind.Absolute, out _),
        "Jwt:Issuer must be a valid absolute URI")
    .ValidateDataAnnotations()
    .ValidateOnStart()       // ← key: throws at startup, not first request
    .PostConfigure(s => s.Issuer = s.Issuer.TrimEnd('/').ToLower());

// Full example with a realistic settings class:
public class JwtSettings
{
    [Required, MinLength(32)]
    public string Secret { get; set; } = "";

    [Required]
    public string Issuer { get; set; } = "";

    [Required]
    public string Audience { get; set; } = "";

    [Range(1, 1440)]
    public int ExpiryMinutes { get; set; } = 60;
}

Rule of thumb: Prefer AddOptions<T>().BindConfiguration().Validate*().ValidateOnStart() over scattered Configure + PostConfigure + manual validator registrations. Chaining keeps the intent visible in one place.

ASP.NET Core's file-based configuration providers (like appsettings.json) support hot reload — they watch the file and re-read it on change. The options interfaces differ in how they expose reloaded values.

// Enable reloadOnChange (default in WebApplication.CreateBuilder):
builder.Configuration.AddJsonFile("appsettings.json",
    optional: false, reloadOnChange: true);  // watches the file

// IOptions<T> — NEVER reloads; snapshot taken at startup:
public class StaticService
{
    public StaticService(IOptions<AppSettings> options)
    {
        // options.Value is frozen at startup — file changes ignored
    }
}

// IOptionsSnapshot<T> — re-reads per HTTP request (reflects file changes):
public class PerRequestService
{
    private readonly AppSettings _settings;

    public PerRequestService(IOptionsSnapshot<AppSettings> options)
    {
        // options.Value is fresh on every request — picks up file changes
        _settings = options.Value;
    }
}

// IOptionsMonitor<T> — registers a change callback:
public class LiveSettingsService
{
    private AppSettings _current;
    private readonly IDisposable? _changeToken;

    public LiveSettingsService(IOptionsMonitor<AppSettings> monitor)
    {
        _current = monitor.CurrentValue;
        _changeToken = monitor.OnChange(updated =>
        {
            _current = updated;
            Console.WriteLine($"Settings reloaded at {DateTime.UtcNow:O}");
        });
    }

    public void Dispose() => _changeToken?.Dispose();
}

Rule of thumb: For feature flags and A/B config that must update without restart, use IOptionsMonitor<T> in singletons. For values that are safe to cache per-request, use IOptionsSnapshot<T>. Never use IOptions<T> for values that need live reloads.

Configure<T> registers a named IConfigureOptions<T> service. When IOptions<T> is first accessed, the options framework runs every registered IConfigureOptions<T> in order to build the final settings object.

// What Configure<T>(section) actually does:
builder.Services.Configure<SmtpSettings>(
    builder.Configuration.GetSection("Smtp"));

// Internally equivalent to:
builder.Services.AddSingleton<IConfigureOptions<SmtpSettings>>(
    new ConfigureNamedOptions<SmtpSettings>(
        Options.DefaultName,           // name = "" (the default name)
        opts => builder.Configuration  // action binds the section
            .GetSection("Smtp").Bind(opts)));

// Registering multiple Configure<T> calls stacks the actions:
builder.Services.Configure<SmtpSettings>(s => s.Port = 587);    // action 1
builder.Services.Configure<SmtpSettings>(s => s.UseTls = true); // action 2
// Both run in order when IOptions<SmtpSettings>.Value is first accessed.

// You can inspect what's registered:
var descriptors = builder.Services
    .Where(d => d.ServiceType == typeof(IConfigureOptions<SmtpSettings>))
    .ToList();
// Lists all Configure<SmtpSettings> registrations in order

Understanding this lets you reason about layering: each Configure<T> call adds another action that runs against the same object in sequence, so later calls can override earlier ones.

Rule of thumb: Because Configure<T> is additive, library code should use TryAdd-based patterns or PostConfigure to avoid overwriting application settings. Application code registers last and wins.

IConfiguration is the raw key-value source. IOptions<T> is the strongly typed, validated, injectable wrapper. As a rule, only infrastructure/startup code should touch IConfiguration directly.

// Application service reading raw IConfiguration — fragile, untestable:
public class PaymentService
{
    public PaymentService(IConfiguration config)
    {
        // Magic strings; no compile-time check; null if key is missing:
        var apiKey = config["Stripe:ApiKey"];
        var timeout = int.Parse(config["Stripe:TimeoutMs"] ?? "5000");
    }
}

// Application service using IOptions<T> — typed, validated, testable:
public record StripeSettings(string ApiKey, int TimeoutMs = 5000);

public class PaymentService
{
    private readonly StripeSettings _stripe;

    public PaymentService(IOptions<StripeSettings> options)
        => _stripe = options.Value; // compile-time names; validated at startup

    // Test: Options.Create(new StripeSettings("test_key"))
}

// Legitimate IConfiguration use — startup wiring, extension methods:
builder.Services.Configure<StripeSettings>(
    builder.Configuration.GetSection("Stripe")); // infrastructure, not app code

builder.Services.AddSingleton<IConnectionFactory>(sp =>
{
    var connStr = sp.GetRequiredService<IConfiguration>()
        .GetConnectionString("Default");     // acceptable in factory delegates
    return new SqlConnectionFactory(connStr!);
});

Rule of thumb: Inject IOptions<T> in application services. Reserve IConfiguration for Program.cs, startup extensions, and factory delegates where you're wiring infrastructure — not implementing business logic.

ASP.NET Core's configuration system layers sources in order — last one wins. The standard appsettings.{Environment}.json override pattern and environment variables both work automatically with the options pattern.

// appsettings.json (base defaults):
// { "Email": { "Host": "smtp.example.com", "Port": 587 } }

// appsettings.Development.json (developer overrides):
// { "Email": { "Host": "localhost", "Port": 1025 } }  // MailHog/Papercut

// appsettings.Production.json (production values):
// { "Email": { "Host": "smtp.sendgrid.net", "Port": 465 } }

// WebApplication.CreateBuilder wires these automatically in order:
// 1. appsettings.json
// 2. appsettings.{ASPNETCORE_ENVIRONMENT}.json  (overlays base)
// 3. User secrets (Development only)
// 4. Environment variables (overlay everything)
// 5. Command-line args (highest priority)

// Options registration is unchanged — same code for all environments:
builder.Services.AddOptions<EmailSettings>()
    .BindConfiguration("Email")
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Override a single property via environment variable — no code change needed:
// DOTNET_Email__Host=smtp.override.com  (double underscore = config key separator)
// ASPNETCORE_Email__Port=2525

// Override in tests using WebApplicationFactory:
await using var factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(host =>
        host.UseSetting("Email:Host", "smtp.test.local")
            .UseSetting("Email:Port", "1025"));

// Or via in-memory configuration:
await using var factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(host =>
        host.ConfigureAppConfiguration(cfg =>
            cfg.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Email:Host"] = "smtp.test.local",
                ["Email:Port"] = "1025"
            })));

Rule of thumb: Keep appsettings.json as the documented baseline with safe defaults. Let appsettings.{Environment}.json and environment variables layer over it. Never hard-code environment checks in application code — the config system handles it.

Secrets should never live in appsettings.json (committed to source control). The options pattern is compatible with all three standard secret sources: User Secrets (development), environment variables (CI/CD and containers), and Azure Key Vault (production).

public class StripeSettings
{
    // Non-secret — safe in appsettings.json:
    public string BaseUrl    { get; set; } = "https://api.stripe.com";
    public int    TimeoutMs  { get; set; } = 5000;

    // Secret — must come from a secret store, NOT appsettings.json:
    public string SecretKey  { get; set; } = "";
    public string WebhookKey { get; set; } = "";
}

// Development: User Secrets (stored outside the repo in %APPDATA%/Microsoft/UserSecrets):
// dotnet user-secrets set "Stripe:SecretKey" "sk_test_..."
// dotnet user-secrets set "Stripe:WebhookKey" "whsec_..."
// WebApplication.CreateBuilder auto-loads user secrets in Development.

// Production/CI: environment variables (double underscore as separator):
// Stripe__SecretKey=sk_live_...
// Stripe__WebhookKey=whsec_...

// Azure Key Vault (for production managed secrets):
builder.Configuration.AddAzureKeyVault(
    new Uri("https://myvault.vault.azure.net/"),
    new DefaultAzureCredential());
// Key Vault secrets map: "Stripe--SecretKey" → config key "Stripe:SecretKey"

// Validation ensures secrets are present before the app starts:
builder.Services.AddOptions<StripeSettings>()
    .BindConfiguration("Stripe")
    .Validate(s => !string.IsNullOrEmpty(s.SecretKey),
        "Stripe:SecretKey is required — set via user secrets or environment variable")
    .ValidateOnStart();

Rule of thumb: Keep secrets out of source control entirely. Use User Secrets for development, environment variables or a secrets manager for production. Options validation with ValidateOnStart() catches missing secrets at startup with a clear error message.

The configuration binder handles nested objects, arrays, dictionaries, and enums automatically — you just need the POCO structure to match the JSON shape.

// appsettings.json:
// {
//   "Notification": {
//     "Email": { "Host": "smtp.example.com", "Port": 587 },
//     "Sms":   { "Provider": "Twilio", "From": "+15555550100" },
//     "EnabledChannels": ["Email", "Sms"],
//     "Templates": {
//       "Welcome": "Welcome to {AppName}!",
//       "Reset":   "Your reset code is {Code}."
//     },
//     "RetryPolicy": { "MaxAttempts": 3, "BackoffStrategy": "Exponential" }
//   }
// }

public enum BackoffStrategy { Linear, Exponential, Fixed }

public class RetryPolicySettings
{
    public int             MaxAttempts     { get; set; } = 3;
    public BackoffStrategy BackoffStrategy { get; set; } = BackoffStrategy.Exponential;
}

public class NotificationSettings
{
    // Nested object — bound recursively:
    public EmailConfig Email { get; set; } = new();
    public SmsConfig   Sms   { get; set; } = new();

    // Array — bound from JSON array:
    public List<string> EnabledChannels { get; set; } = new();

    // Dictionary — bound from JSON object:
    public Dictionary<string, string> Templates { get; set; } = new();

    // Nested object with enum — enum bound by name (case-insensitive):
    public RetryPolicySettings RetryPolicy { get; set; } = new();
}

builder.Services.AddOptions<NotificationSettings>()
    .BindConfiguration("Notification")
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Usage:
public class NotificationService
{
    private readonly NotificationSettings _settings;

    public NotificationService(IOptions<NotificationSettings> opts)
        => _settings = opts.Value;

    public bool IsChannelEnabled(string channel)
        => _settings.EnabledChannels.Contains(channel, StringComparer.OrdinalIgnoreCase);

    public string GetTemplate(string name)
        => _settings.Templates.TryGetValue(name, out var t) ? t : "";
}

Rule of thumb: Match your POCO property names and nesting to the JSON structure — the binder is case-insensitive and handles most types automatically. Use init setters for immutability if you don't need post-binding mutation.

IOptionsMonitor<T> tracks config changes via IChangeToken — the same abstraction that IFileProvider and IConfiguration use internally. Understanding this mechanism helps when building custom configuration sources that support hot reload.

// IOptionsMonitor<T> exposes the OnChange callback — backed by change tokens internally:
public class FeatureFlagMonitor : IDisposable
{
    private FeatureFlags _current;
    private readonly IDisposable? _subscription;

    public FeatureFlagMonitor(IOptionsMonitor<FeatureFlags> monitor)
    {
        _current      = monitor.CurrentValue;
        // OnChange returns an IDisposable subscription — dispose to unsubscribe:
        _subscription = monitor.OnChange((updated, name) =>
        {
            // name is null for the default (unnamed) options instance:
            if (name is null or "")
            {
                _current = updated;
                // Note: this callback may fire on a thread pool thread — protect shared
                // state if needed (Interlocked.Exchange, lock, etc.)
            }
        });
    }

    public bool IsEnabled(string flag)
        => _current.Flags.TryGetValue(flag, out var v) && v;

    public void Dispose() => _subscription?.Dispose(); // unsubscribe on cleanup
}

// Custom configuration source with change token support:
public class DatabaseConfigSource : IConfigurationSource
{
    public IConfigurationProvider Build(IConfigurationBuilder builder)
        => new DatabaseConfigProvider();
}

public class DatabaseConfigProvider : ConfigurationProvider, IDisposable
{
    private readonly Timer _timer;

    public DatabaseConfigProvider()
    {
        // Poll the database every 60 seconds and signal a change if values differ:
        _timer = new Timer(_ => CheckForChanges(), null,
            TimeSpan.Zero, TimeSpan.FromSeconds(60));
    }

    private void CheckForChanges()
    {
        Load(); // re-read from DB
        OnReload(); // fires IChangeToken — triggers IOptionsMonitor callbacks
    }

    public override void Load() { /* read key-values from DB into Data dict */ }
    public void Dispose() => _timer.Dispose();
}

Rule of thumb: Always dispose the IDisposable returned by OnChange — a leaked subscription holds a reference to your callback (and its closure) for the app lifetime. In Singleton services, store the subscription and dispose it in IDisposable.Dispose.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel