Configuration mistakes rarely surface in code review; they surface at 2 a.m., when a feature flag flips for the wrong tenant or a secret rotates in one environment but not another. Interviewers use this topic to separate engineers who have only followed an appsettings.json tutorial from those who have owned a service through a production incident that configuration caused. At the senior and architect level, expect questions to move past "which interface do I inject" into "what breaks when this value changes while the process is already running, and how do you find out before a customer does." The ten questions below cover provider precedence, the IOptions<T> family, startup validation, secrets, feature flags with Microsoft.FeatureManagement, per-tenant settings and configuration drift.

Q1 Walk through exactly how ASP.NET Core resolves a configuration value when more than one provider sets the same key. Why does provider order matter more than most developers assume?#

Short answer: IConfiguration is a single flat, case-insensitive key-value store assembled from an ordered list of providers. For any key, the provider added last wins, full stop; there is no merging of values, only whole-value replacement. The default host order is appsettings.json, then the environment-specific JSON file, then user secrets in Development only, then environment variables, then command-line arguments, and anything registered afterward, such as Key Vault, overrides all of it.

Under the hood, ConfigurationRoot walks its provider list from the end backward until one reports the key, which is the literal mechanism behind "last wins": reordering Add... calls during a routine refactor can silently change which value production uses. Keys are hierarchical — a JSON object nesting Smtp then Host becomes the flat key Smtp:Host — and because shells cannot represent a colon in a variable name, the environment-variable provider maps a double underscore to that separator instead.

C#
var builder = WebApplication.CreateBuilder(args);
// Order here is the precedence order. Anything added after this line beats it all.
builder.Configuration.AddJsonFile("appsettings.json", optional: true)
                      .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
                      .AddEnvironmentVariables()
                      .AddCommandLine(args);

A common incident starts with someone adding AddEnvironmentVariables() before the JSON files "to make environment variables the default," which inverts precedence: an ambient variable from a shared build or hosting platform now permanently masks a per-environment setting that should have won.

What interviewers look for: the "last provider wins" rule stated without hesitation, an accurate mental model of hierarchical keys versus the flat dictionary they become, awareness that user secrets only load in Development, and the instinct to ask "what order was this registered in" before debugging a configuration value that looks wrong.

Common mistakes: assuming providers merge object or array values instead of replacing the whole leaf; assuming the first provider wins, by analogy with middleware; forgetting that command-line arguments beat everything in the default host and can override a security-relevant setting at launch.

Follow-up questions:

  • How would you stop an operator's command-line override from disabling authentication by accident?
  • What happens when two JSON files set the same key to different data types?
  • How do you audit, in production, which provider actually supplied a given value?

Q2 What is the practical difference between IOptions<T>, IOptionsSnapshot<T> and IOptionsMonitor<T>, and how do you decide which one a given class should inject?#

Short answer: The three interfaces trade off DI lifetime against freshness. IOptions<T> is a singleton computed once and cached forever, so it never reflects a configuration reload. IOptionsSnapshot<T> is scoped and recomputed once per scope, which suits request-scoped code. IOptionsMonitor<T> is a singleton that always returns the current value through CurrentValue and exposes OnChange for reacting to updates, which makes it the right choice inside singletons, hosted services and long-lived connections.

InterfaceDI lifetimeSees reloadsChange eventsTypical consumer
IOptions<T>SingletonNoNoStatic settings read once at startup
IOptionsSnapshot<T>ScopedYes, once per scopeNoControllers, request handlers
IOptionsMonitor<T>SingletonYes, immediatelyYes, OnChangeHosted services, caches, HTTP clients

The lifetime mismatch is where this becomes a real interview question rather than trivia. Injecting IOptionsSnapshot<T> into a singleton is a captive dependency: the singleton resolves the scoped service once, at construction, and holds it forever, so the "per-scope" recompute never happens again and the class behaves like IOptions<T> while looking like it should update. ASP.NET Core's default DI scope validation, enabled in Development, throws on this at startup — a good reason to keep it on wherever the extra check is affordable.

C#
public sealed class SmtpConnectionPool(IOptionsMonitor<SmtpOptions> monitor) : IDisposable
{
    private readonly IDisposable? _subscription = monitor.OnChange(updated =>
        Console.WriteLine($"SMTP relay changed to {updated.Host}"));

    public SmtpOptions Current => monitor.CurrentValue;

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

What interviewers look for: the lifetime table recited without notes, an example of a captive dependency and why DI validation catches it, and named-options awareness (Configure<T>("Name", ...) plus Get("Name")) for scenarios with several instances of the same options type.

Common mistakes: expecting IOptions<T> to update after a reload; injecting IOptionsSnapshot<T> into a singleton and being surprised it never changes; not knowing IOptionsMonitor<T>.OnChange can fire more than once per logical change because multiple providers may re-trigger the composite reload.

Follow-up questions:

  • Why might OnChange fire twice for what looks like a single edit to appsettings.json?
  • How do named options change the registration and consumption code?
  • When would you still choose IOptions<T> on purpose?

Q3 How do you make configuration reload at runtime without restarting the process, and what are the practical limits of that mechanism?#

Short answer: JSON file providers reload automatically because the host sets reloadOnChange to true by default, watching the file for changes and rebuilding the composite configuration. Cloud providers such as Azure App Configuration add polling, usually gated by a sentinel key so a client only re-reads the full set of values after that one key changes. The limit is that only providers designed for it participate: environment variables and command-line arguments are captured once at startup and never change again for the life of the process.

File-watch reload is less reliable than it looks. Container volumes, network shares and some Kubernetes ConfigMap mounts use atomic symlink swaps or don't propagate inotify events the way a local disk does, so the change is invisible to the default watcher; setting DOTNET_USE_POLLING_FILE_WATCHER to true trades a short delay for reliability there. Cloud providers avoid the problem by polling on their own schedule instead of depending on file system events, at the cost of a short delay before the app notices.

Reload only matters to the consumer that reads it: code holding an IOptions<T> never sees the new value, only IOptionsSnapshot<T> and IOptionsMonitor<T> do, so "does configuration reload" and "which options interface" are really the same question twice. The other trap is mid-operation consistency — a long-running unit of work that reads CurrentValue more than once can see two different values if a reload lands in between, so capture it once at the start instead of re-reading it at every step.

What interviewers look for: distinguishing "the provider reloads" from "my code sees the new value," naming a concrete reason file-watch reload can silently fail in a container, and recognizing the mid-operation consistency hazard as a correctness issue, not just a nice-to-have.

Common mistakes: assuming every provider reloads; not realizing environment variables and the command line are frozen at startup; re-reading IOptionsMonitor<T>.CurrentValue several times inside one logical operation and getting inconsistent values if a reload happens in between.

Follow-up questions:

  • How would you detect that a JSON file reload silently stopped working in a given environment?
  • What is a sentinel key, and why do App Configuration-style providers use one?
  • How do you unit test a class that depends on IOptionsMonitor<T>.OnChange?

Q4 How do you validate configuration and fail a deployment fast, instead of discovering a bad value in production hours or days later?#

Short answer: Bind the section to a typed options class, add validation, and call ValidateOnStart() (or the shortcut AddOptionsWithValidateOnStart<T>()) so the check runs during host startup rather than lazily on first use. A misconfigured release then fails its health check and never takes traffic, instead of surfacing as an obscure runtime exception once a code path happens to touch the bad value.

C#
[OptionsValidator]
public sealed partial class SmtpOptionsValidator : IValidateOptions<SmtpOptions> { }

builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
builder.Services.AddOptionsWithValidateOnStart<SmtpOptions>()
    .BindConfiguration(SmtpOptions.SectionName)
    .Validate(o => o.Host == "localhost" || !string.IsNullOrEmpty(o.Password),
        "Smtp:Password is required for a remote host.");

ValidateDataAnnotations() checks attributes such as [Required] and [Range] through reflection, which is simple but costs startup time and doesn't work with trimming or Native AOT. The options validation source generator, marked with [OptionsValidator] on an empty partial class, produces the same checks at compile time with no reflection, and is the better default for a trimmed or latency- sensitive service. Cross-field rules attributes can't express, such as "password required unless the host is localhost," belong in a Validate(...) delegate. A senior answer also names the failure mode validation alone misses: a misspelled section name binds silently to an empty object with every property at its default, so [Required] on those properties is what turns that into a startup error, not a silent no-op.

What interviewers look for: ValidateOnStart() named specifically, not just "add some validation," awareness of the source generator as the AOT- and trim-friendly option, and the silent-empty-section failure mode as evidence of production experience, not textbook knowledge.

Common mistakes: validating with data annotations but never calling ValidateOnStart(), so the first failure happens on a live request instead of at boot; relying on defaults to make a misspelled section "just work" without noticing the values are wrong; skipping cross-field validation entirely.

Follow-up questions:

  • What is the difference in behavior between lazy validation and ValidateOnStart()?
  • How does the options validation source generator interact with nested objects and collections?
  • How would you surface a validation failure to an orchestrator's readiness probe?

Q5 How should secrets such as connection strings, API keys and certificates be handled across local development, CI and production?#

Short answer: Use a different mechanism for each stage instead of one shared one: the Secret Manager (user secrets) for local development, the CI system's own secret store injected as pipeline variables for build and test, and a managed secret store such as Azure Key Vault, accessed through a managed identity, in production. None of those secrets ever belong in appsettings.json, encrypted or not, because a file in source control is discoverable by anyone with repository access, past or present.

User secrets are a convenience, not a vault: the Secret Manager stores them in a JSON file in the developer's profile, outside the repository, but the file itself is plain text. In CI, secrets should come from the pipeline's secret store as masked variables, never a YAML file or a container image layer, which both leak easily. In production, prefer a specific credential over the convenience of DefaultAzureCredential, which tries several credential types in sequence and suits local development; authenticate with one deliberate managed identity so a credential failure is diagnosable, not a mystery.

C#
if (!builder.Environment.IsDevelopment())
{
    // Vault secret name "Smtp--Password" becomes the configuration key "Smtp:Password".
    builder.Configuration.AddAzureKeyVault(
        new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
        new ManagedIdentityCredential());
}

Rotation matters as much as storage: a secret that never rotates is a liability wherever it lives, and versioned secrets plus an app that re-reads the current version on a schedule turn rotation into a non-event instead of a deployment.

What interviewers look for: a distinct story for each of the three environments, not one blanket answer; a specific, named credential in production rather than "whatever works locally"; and rotation treated as a design requirement, not an afterthought.

Common mistakes: treating user secrets as encrypted storage; putting secrets in environment variables that then leak through process listings, crash dumps or docker inspect; leaving DefaultAzureCredential in production code where a silent fallback can mask a misconfiguration.

Follow-up questions:

  • What changes about this story for a service with dozens of downstream credentials?
  • How would you detect a secret that has not been rotated in over a year?
  • What is the blast radius if a single shared Key Vault access policy is misconfigured?

Q6 Design a feature-flag rollout with Microsoft.FeatureManagement: a gradual percentage rollout, a hard kill switch and per-tenant overrides. What are the building blocks?#

Short answer: Register the library with AddFeatureManagement(), gate code with IFeatureManager or the [FeatureGate] attribute, and let filters decide the outcome per request: a PercentageFilter for gradual rollout, a TargetingFilter evaluated against a TargetingContext (user ID plus group membership) for tenant- or cohort-scoped overrides, and a flag with no enabled filters, or a filter that always evaluates to false, as the kill switch.

C#
builder.Services.AddFeatureManagement()
    .WithTargeting<TenantTargetingContextAccessor>();

public sealed class TenantTargetingContextAccessor(ITenantContext tenant) : ITargetingContextAccessor
{
    public ValueTask<TargetingContext> GetContextAsync() =>
        ValueTask.FromResult(new TargetingContext { UserId = tenant.Id, Groups = tenant.Plans });
}

// Elsewhere: does this tenant get the feature?
var enabled = await featureManager.IsEnabledAsync("NewCheckout", targetingContext);

Configuration-driven rollout means the percentage and target groups live in the FeatureManagement section, which can be swapped for a dynamically refreshed source such as Azure App Configuration with no redeploy; App Configuration polls a sentinel key and re-reads flag values on a timer, so a kill switch takes effect in seconds instead of requiring a rollback. A senior answer draws a hard line between two things that look alike but aren't: short-lived release flags that get deleted once a feature is fully rolled out, and long-lived entitlement flags that gate a paid tier or a tenant contract. Letting the second kind accumulate inside the system meant for the first turns the flag store into an unaudited, undocumented authorization system — a governance problem, not just technical debt.

What interviewers look for: IFeatureManager, [FeatureGate] and TargetingContext named correctly, a design that separates percentage rollout from tenant targeting from the kill switch instead of conflating them, and explicit concern for flag lifecycle and cleanup, not just how to turn one on.

Common mistakes: treating every flag as permanent and never removing rollout flags after full release; using a feature flag as a substitute for real authorization on a paid feature; evaluating flags against IFeatureManager from a singleton in a way that ignores per-request targeting context.

Follow-up questions:

  • How do you unit test code that depends on IFeatureManager without a live configuration source?
  • What is the difference between IFeatureManager and IFeatureManagerSnapshot in a web request?
  • How would you audit which flags are stale and safe to delete?

Q7 How do you implement per-tenant configuration in a multi-tenant SaaS application without hard-coding tenant IDs into your options classes?#

Short answer: Treat per-tenant configuration as a runtime lookup keyed by the current request's tenant, not as something the options system binds at startup. A scoped service resolves the tenant from the request (subdomain, header or a claim), loads that tenant's overrides from a store such as a database or a labeled configuration source, merges them onto application-wide defaults, and caches the result per tenant with an explicit invalidation path.

The options pattern binds configuration to the application's lifetime, and no IOptions<T> variant, even named options, can express "the value for whichever tenant is making this request," because tenant identity is runtime request data, not a startup-time configuration key. The clean separation: application defaults stay in the normal configuration system as global options, and tenant overrides live in tenant-owned storage behind a scoped ITenantConfiguration abstraction middleware populates after routing resolves the tenant.

C#
public sealed class TenantConfiguration(ITenantContext tenant, HybridCache cache, ITenantStore store)
{
    public async Task<CheckoutOptions> GetCheckoutOptionsAsync(CancellationToken ct) =>
        await cache.GetOrCreateAsync($"tenant:{tenant.Id}:checkout",
            async token => await store.LoadCheckoutOverridesAsync(tenant.Id, token) ?? Defaults.Checkout,
            cancellationToken: ct);
}

The scale problem also differs from the standard options story: with hundreds or thousands of tenants you cannot validate every tenant's configuration at host startup the way ValidateOnStart() does for global options. Validate at write time instead, when an administrator saves a tenant's overrides, keep a versioned schema so old records stay valid as it evolves, and invalidate the cache on write rather than on a blanket time-based expiry.

What interviewers look for: recognizing this as a data and request-scoping problem rather than an options-binding problem, a clear split between global defaults and tenant overrides, and a concrete answer for validating and caching thousands of tenant records instead of a few global settings.

Common mistakes: trying to force per-tenant values through named options resolved from DI, which has no notion of "current request"; forgetting to invalidate a tenant's cached configuration on write; validating tenant configuration only at startup, which does nothing for tenants added afterward.

Follow-up questions:

  • Where does tenant resolution have to happen in the middleware pipeline, and why?
  • How would you support a tenant temporarily overriding one setting for a support investigation?
  • What happens to cached tenant configuration when a pod restarts mid-rollout?

Q8 What is configuration drift between environments, how does it happen even with infrastructure as code and CI/CD in place, and how do you detect and prevent it?#

Short answer: Configuration drift is any gap between what your infrastructure-as-code or pipeline declares and what is actually running, and it happens whenever someone changes a live setting outside that declared path: a manual portal edit, an emergency kubectl edit, a feature flag flipped by hand during an incident and never reverted, or a secret rotated in one environment but not the others. Prevention means making the declared source of truth the only path for change, and detection means regularly comparing it against reality.

The practical failure pattern is almost always an emergency fix: someone under pressure edits a live App Service setting or a ConfigMap directly, the incident closes, and the matching change never lands in the Bicep, Terraform or Helm source. The next terraform plan either silently reverts the fix on the next deploy, or nobody runs that comparison and the environments quietly diverge for months. Detection tools exist for exactly this: a scheduled terraform plan with no apply, Azure Policy compliance scans, or kubectl diff against the source manifests, run on a timer and alerting on any non-empty diff.

A senior answer distinguishes intentional environment-specific configuration, such as a smaller compute SKU in staging, from unintentional drift: anything the source of truth doesn't know about. Fix the first by naming those differences explicitly in per-environment IaC parameters; fix the second by removing every path to change a live setting except a reviewed pull request, including the portal access that enabled the emergency edit, and replacing it with a fast, reviewable emergency deploy path so the pressure to bypass the pipeline goes away.

What interviewers look for: a definition that separates drift from deliberate per-environment configuration, a concrete detection mechanism named by tool or technique, and a root-cause fix that removes the incentive for manual changes rather than just asking people to be more disciplined.

Common mistakes: treating every environment difference as drift, including intentional ones; detecting drift only during an incident instead of on a schedule; fixing a drifted value manually again without ever updating the IaC source, which guarantees the same drift returns at the next deploy.

Follow-up questions:

  • How would you roll out a config-as-code process to a team that is used to portal edits?
  • What is the risk of an automated drift-detection tool that also auto-remediates?
  • How do you handle a setting that legitimately must differ per environment without flagging it as drift?

Q9 A teammate reads an option value once in Program.cs, caches it in a static field, and now wonders why the app never picks up a change to appsettings.json. Diagnose and fix it.#

Short answer: Reading IConfiguration or binding an options object directly during startup captures a point-in-time snapshot and steps outside the options system entirely, so nothing about it can ever reflect a later reload; a static field makes that snapshot permanent for the life of the process. The fix is to stop caching the value at all and instead inject IOptionsMonitor<T> into whichever service actually needs the live value, reading CurrentValue on each use or subscribing to OnChange.

C#
// Before: a frozen snapshot, invisible to every later reload.
public static class SmtpSettings
{
    public static string Host { get; } =
        WebApplication.CreateBuilder().Configuration["Smtp:Host"] ?? "smtp.contoso.com";
}

// After: always current, and testable through the normal DI container.
public sealed class DigestSender(IOptionsMonitor<SmtpOptions> smtp)
{
    public string CurrentHost => smtp.CurrentValue.Host;
}

Two related bugs travel with this pattern. First, if the static field were instead populated from an IOptionsSnapshot<T> resolved inside a singleton's constructor, that's a captive dependency: the scoped service is created once and held forever, so it looks like it should refresh but never does, and DI scope validation, on by default in Development, throws on exactly this rather than letting it ship. Second, a static cache defeats testability, since every test now shares mutable global state instead of an isolated instance from its own service provider. The principle: configuration values belong behind an interface the container manages, never ad hoc static state, so "does this value update" has one answer instead of one exception per class.

What interviewers look for: correctly diagnosing the static-cache-as-startup-snapshot problem without prompting, naming IOptionsMonitor<T> as the fix, and connecting it to the captive-dependency failure mode and to DI validation as the tool that catches the underlying class of bug automatically.

Common mistakes: fixing the symptom by re-reading configuration manually somewhere else instead of removing the static cache; missing that the same defect can hide inside a singleton via a captive IOptionsSnapshot<T> dependency; assuming DI scope validation is only a Development-time nuisance rather than a real safety net worth keeping close to on in every environment.

Follow-up questions:

  • How would you find every static configuration cache like this across a large codebase?
  • Why does IOptionsSnapshot<T> injected into a singleton not throw immediately without validation on?
  • What is an acceptable reason to still cache a configuration value, and for how long?

Q10 A service integrates with many optional providers, such as several payment processors or notification channels. How do you structure configuration and options so adding one more provider doesn't require touching a central switch statement?#

Short answer: Give each integration its own configuration section, its own options type with its own validation, and its own keyed DI registration, then select an implementation at the call site with .NET's keyed services instead of a growing switch over a provider name. Adding a provider becomes adding one new registration and one new configuration section, with no edits to existing code.

C#
builder.Services.AddOptionsWithValidateOnStart<StripeOptions>()
    .BindConfiguration("Payments:Stripe").ValidateDataAnnotations();
builder.Services.AddKeyedSingleton<IPaymentProvider, StripeProvider>("stripe");
builder.Services.AddKeyedSingleton<IPaymentProvider, AdyenProvider>("adyen");

public sealed class CheckoutService([FromKeyedServices("stripe")] IPaymentProvider defaultProvider)
{
    // A factory resolved from IServiceProvider picks the keyed instance by a config-driven name
    // instead of hard-coding "stripe" here, keeping this class provider-agnostic.
}

The configuration design matters as much as the DI design. Each provider's options class should be independently validatable, so a misconfigured but currently unused provider doesn't block startup for everyone; gate ValidateOnStart() per provider behind that provider's own "Enabled" flag. Keep every schema under its own subsection, such as Payments:Stripe and Payments:Adyen, rather than one flat options class with optional properties for every provider that every integration partially fills in. This is really an extensibility question in configuration clothing: whether you reach for open/closed- friendly patterns and per-module validation, or a hand-rolled dictionary and a switch statement that every new integration has to edit.

What interviewers look for: keyed services named specifically as the .NET-native tool for this, independent per-provider options and validation, and an explicit rejection of a central switch statement or a single flat options class as the integration point.

Common mistakes: one shared options class with nullable properties per provider; validating every provider's configuration at startup even when a given deployment never enables most of them; resolving providers by a hand-rolled Dictionary<string, IPaymentProvider> instead of keyed DI, which loses constructor injection and lifetime management for each entry.

Follow-up questions:

  • How would you let an operator enable a new provider without a redeploy?
  • How does this design change if two providers must run simultaneously, not just one active one?
  • How would you test that every registered provider's options validate independently?

Quick-Fire Round#

QuestionAnswer
Which provider wins when two define the same key?The one added last, regardless of source.
Does IOptions<T> see a configuration reload?No; it is computed once and cached for the app's life.
DI lifetime of IOptionsSnapshot<T>?Scoped, recomputed once per scope.
DI lifetime of IOptionsMonitor<T>?Singleton, with live values and OnChange.
How do environment variables express a nested key?A double underscore (__) in place of :.
What does ValidateOnStart() change?Validation runs at host startup, not on first lazy access.
Where should a production secret live?A managed store such as Key Vault, via managed identity.
What decides if a flag is on for one user?The registered IFeatureFilter implementations, such as TargetingFilter.

How to Prepare#

  • Be able to recite the default provider precedence list and explain that it is implemented as "walk the providers backward," not "first match wins."
  • Draw the IOptions<T> / IOptionsSnapshot<T> / IOptionsMonitor<T> lifetime table without notes, and have a captive-dependency example ready.
  • Know how to make a service fail fast on bad configuration, including the AOT-friendly validation source generator, not just ValidateDataAnnotations().
  • Have an opinionated, environment-by-environment story for secrets: user secrets, CI variables, and a managed store with a specific credential in production.
  • Be ready to design a feature-flag rollout end to end, including cleanup of flags after full release.
  • Treat per-tenant configuration as a request-scoped data problem, not an options-binding problem.
  • Define configuration drift precisely, and separate it from deliberate per-environment settings.