The ASP.NET Core configuration system aggregates key-value pairs from multiple
configuration providers into a unified IConfiguration interface. Providers are
layered — later providers override earlier ones for the same key.
// Default provider order (WebApplication.CreateBuilder):
// 1. appsettings.json
// 2. appsettings.{Environment}.json (e.g., appsettings.Production.json)
// 3. User Secrets (Development only)
// 4. Environment variables
// 5. Command-line arguments
var builder = WebApplication.CreateBuilder(args);
// builder.Configuration is already populated from the sources above
// Read a value:
string? conn = builder.Configuration["ConnectionStrings:DefaultConnection"];
// or via GetConnectionString shortcut:
string? conn2 = builder.Configuration.GetConnectionString("DefaultConnection");
// Read a section:
IConfigurationSection mailSection = builder.Configuration.GetSection("Mail");
string? host = mailSection["Host"];
int port = mailSection.GetValue<int>("Port", defaultValue: 587);
appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=MyDb;Trusted_Connection=True"
},
"Mail": {
"Host": "smtp.example.com",
"Port": 587
}
}
Rule of thumb: Never hard-code secrets or environment-specific values in code. Use the configuration system so values can be overridden per environment through appsettings files, environment variables, or secrets management tools.
ASP.NET Core supports many built-in providers. WebApplication.CreateBuilder sets up
the default stack automatically; you can customize with ConfigureAppConfiguration.
// Default stack — later entries WIN (override earlier):
// appsettings.json → appsettings.{Env}.json → User Secrets → Env vars → CLI args
// Customizing the stack:
builder.Configuration
// 1. Clear existing providers:
.Sources.Clear();
builder.Host.ConfigureAppConfiguration((ctx, config) =>
{
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
config.AddJsonFile(
$"appsettings.{ctx.HostingEnvironment.EnvironmentName}.json",
optional: true,
reloadOnChange: true);
if (ctx.HostingEnvironment.IsDevelopment())
config.AddUserSecrets<Program>(); // safe dev-only secrets
config.AddEnvironmentVariables(prefix: "MYAPP_"); // MYAPP_Mail__Host → Mail:Host
config.AddCommandLine(args); // --Mail:Host=smtp.example.com
// Custom: Azure Key Vault (requires Azure.Extensions.AspNetCore.Configuration.Secrets):
// config.AddAzureKeyVault(new Uri("https://myvault.vault.azure.net/"), new DefaultAzureCredential());
// Custom: AWS Secrets Manager, HashiCorp Vault, etc.
});
Environment variable key mapping:
- Double underscore
__maps to:(section separator). MYAPP_Mail__Host→Mail:HostMYAPP_ConnectionStrings__DefaultConnection→ConnectionStrings:DefaultConnection
Rule of thumb: For secrets (API keys, connection strings), use User Secrets in
development and environment variables / a secrets manager (Azure Key Vault, AWS
Secrets Manager) in production — never commit secrets to appsettings.json.
The Options pattern binds a configuration section to a strongly-typed class and injects it via DI. Three interfaces serve different lifetime / reload needs:
IOptions<T>— singleton; reads config once at startup; does not see changes.IOptionsSnapshot<T>— scoped; re-reads config once per request; sees reloaded values from the next request onward.IOptionsMonitor<T>— singleton; sees config changes immediately via a change-notification callback.
// POCO:
public class MailOptions
{
public const string SectionName = "Mail";
public string Host { get; set; } = default!;
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true;
}
// Register:
builder.Services.Configure<MailOptions>(
builder.Configuration.GetSection(MailOptions.SectionName));
// Inject and use:
public class EmailService
{
private readonly MailOptions _opts;
// IOptions<T> — fixed at startup (singleton):
public EmailService(IOptions<MailOptions> opts) =>
_opts = opts.Value;
// IOptionsSnapshot<T> — re-read per request (scoped service):
public EmailService(IOptionsSnapshot<MailOptions> opts) =>
_opts = opts.Value;
// IOptionsMonitor<T> — live updates (singleton service):
public EmailService(IOptionsMonitor<MailOptions> monitor)
{
_opts = monitor.CurrentValue;
monitor.OnChange(newValue =>
{
// Called whenever config changes on disk
Console.WriteLine($"Config changed: Host={newValue.Host}");
});
}
}
| Interface | Lifetime | Sees reloads? | Best for |
|---|---|---|---|
IOptions<T> |
Singleton | No | Startup-only config, singletons |
IOptionsSnapshot<T> |
Scoped | Per-request | Web requests, feature flags |
IOptionsMonitor<T> |
Singleton | Immediately | Long-running services, background workers |
Rule of thumb: Use IOptions<T> for configuration that never changes at
runtime. Use IOptionsSnapshot<T> in request-scoped services when you want
per-request config refresh. Use IOptionsMonitor<T> in singletons or background
services that need live config updates without restart.
ASP.NET Core lets you validate Options at startup using Data Annotations,
IValidateOptions<T>, or the fluent Validate overload.
// Data Annotations on the POCO:
public class SmtpOptions
{
[Required]
public string Host { get; set; } = default!;
[Range(1, 65535)]
public int Port { get; set; }
[Required, EmailAddress]
public string FromAddress { get; set; } = default!;
}
// Register with annotation validation + eager validation at startup:
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.ValidateDataAnnotations()
.ValidateOnStart(); // throws on startup instead of first use!
// Custom validation:
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.Validate(opts =>
{
if (opts.Port == 25 && opts.Host.EndsWith(".example.com"))
return false; // invalid combination
return true;
}, "Port 25 is not allowed for example.com SMTP servers")
.ValidateOnStart();
Without ValidateOnStart(), validation runs on first access of .Value. With it,
bad config causes the app to refuse to start — a much better failure mode.
Rule of thumb: Always add .ValidateDataAnnotations().ValidateOnStart() to
any critical options binding. Failing fast at startup is far better than a
production runtime error when a feature is first exercised.
User Secrets store sensitive config values (API keys, connection strings) outside
the project directory during development, so they are never committed to source
control. They are stored in %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json
(Windows) or ~/.microsoft/usersecrets/<id>/secrets.json (Linux/macOS).
# Initialize user secrets for the project (adds <UserSecretsId> to .csproj):
dotnet user-secrets init
# Set secrets:
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=.;..."
dotnet user-secrets set "Stripe:SecretKey" "sk_test_abc123"
dotnet user-secrets set "Mail:Password" "my-smtp-password"
# List all secrets:
dotnet user-secrets list
# Remove a secret:
dotnet user-secrets remove "Mail:Password"
They are automatically loaded in the Development environment by
WebApplication.CreateBuilder:
// Automatically added in Development:
if (app.Environment.IsDevelopment())
builder.Configuration.AddUserSecrets<Program>();
// WebApplication.CreateBuilder does this by default — you usually don't need to call it manually.
// Access normally via IConfiguration:
var key = builder.Configuration["Stripe:SecretKey"];
In production, use environment variables or a secrets manager — User Secrets are a development-only mechanism.
Rule of thumb: Use User Secrets for any credential a dev needs locally but
must not check in. Never add secrets.json to .gitignore — it's outside the
repo directory by design.
The hosting environment (ASPNETCORE_ENVIRONMENT env var) controls which
appsettings.{Environment}.json file is loaded. The environment-specific file
merges with and overrides values from appsettings.json.
// appsettings.json (base — checked into source control):
{
"Logging": { "LogLevel": { "Default": "Warning" } },
"FeatureFlags": { "NewCheckout": false },
"ConnectionStrings": { "DefaultConnection": "" }
}
// appsettings.Development.json (local overrides — can be committed for shared dev):
{
"Logging": { "LogLevel": { "Default": "Debug" } },
"FeatureFlags": { "NewCheckout": true }
}
// appsettings.Production.json (minimal; secrets come from env vars):
{
"Logging": { "LogLevel": { "Default": "Error" } }
}
// Check environment in code:
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
else
app.UseExceptionHandler("/error");
// Custom environments:
// ASPNETCORE_ENVIRONMENT=Staging → loads appsettings.Staging.json
if (app.Environment.IsEnvironment("Staging"))
EnableStagingFeatures();
IWebHostEnvironment (injected via DI) exposes:
EnvironmentName— the string valueIsDevelopment(),IsStaging(),IsProduction()— convenience methodsContentRootPath,WebRootPath
Rule of thumb: Keep appsettings.json free of secrets and environment-specific
values. Use appsettings.{Env}.json for non-secret environment differences. Put
secrets in environment variables or a secrets manager, not in any config file that
gets committed.
IConfiguration provides several methods to read values: GetValue<T>, GetSection,
Bind, and Get<T>.
// appsettings.json:
// {
// "App": { "Name": "MyApp", "MaxItems": 100, "Tags": ["api","web"] }
// }
// GetValue<T> — read a single value with optional default:
string name = config.GetValue<string>("App:Name") ?? "Default";
int maxItems = config.GetValue<int>("App:MaxItems", defaultValue: 50);
// GetSection — get a sub-section (does NOT throw if missing):
IConfigurationSection appSection = config.GetSection("App");
string? sectionName = appSection["Name"]; // "MyApp"
// Bind — populate an existing object from config:
var opts = new AppOptions();
config.GetSection("App").Bind(opts);
// opts.Name == "MyApp", opts.MaxItems == 100
// Get<T> — returns a new instance of T bound from the section:
AppOptions opts2 = config.GetSection("App").Get<AppOptions>()!;
// Bind arrays:
string[] tags = config.GetSection("App:Tags").Get<string[]>()!;
// tags == ["api", "web"]
public class AppOptions
{
public string Name { get; set; } = default!;
public int MaxItems { get; set; }
public List<string> Tags { get; set; } = new();
}
GetSection("missing") returns an empty IConfigurationSection (not null) —
check section.Exists() before using it:
var section = config.GetSection("FeatureFlags");
if (section.Exists())
section.Bind(featureFlags);
Rule of thumb: Prefer the Options pattern (IOptions<T>) over directly calling
IConfiguration in application services — it gives you DI, validation, and reload
support. Use IConfiguration directly only in startup code or simple console apps.
Yes — by default, AddJsonFile is registered with reloadOnChange: true, so
appsettings.json and appsettings.{Env}.json are re-read when the file changes
on disk (via FileSystemWatcher). IConfiguration and IOptionsMonitor<T> reflect
the new values immediately; IOptionsSnapshot<T> reflects them on the next request;
IOptions<T> never reloads.
// Reload is on by default — this is what WebApplication.CreateBuilder does:
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
config.AddJsonFile($"appsettings.{env}.json", optional: true, reloadOnChange: true);
// IOptionsMonitor: subscribes to live changes
public class FeatureFlagService
{
private readonly IOptionsMonitor<FeatureOptions> _monitor;
public FeatureFlagService(IOptionsMonitor<FeatureOptions> monitor)
=> _monitor = monitor;
public bool IsEnabled(string flag)
=> _monitor.CurrentValue.EnabledFlags.Contains(flag); // always fresh
}
// IOptionsSnapshot: fresh per-request (scoped services)
public class RequestHandler
{
private readonly FeatureOptions _opts;
public RequestHandler(IOptionsSnapshot<FeatureOptions> opts)
=> _opts = opts.Value; // bound once per request scope
}
// IOptions: frozen at startup (no reload)
public class CachedService
{
private readonly FeatureOptions _opts;
public CachedService(IOptions<FeatureOptions> opts)
=> _opts = opts.Value; // static — never changes
}
Rule of thumb: Use IOptionsMonitor<T> in singletons or background workers
that need live config without a restart. In production, use reload carefully for
critical values — an invalid config file mid-flight can crash the app or produce
unexpected behavior.
Named options allow multiple instances of the same options class, each configured differently. They are useful when you have multiple instances of a service with different configuration (e.g., two SMTP servers, multiple HTTP clients).
// Two SMTP configurations in appsettings.json:
// {
// "Smtp": {
// "Transactional": { "Host": "smtp1.example.com", "Port": 587 },
// "Marketing": { "Host": "smtp2.example.com", "Port": 465 }
// }
// }
// Register named options:
builder.Services.Configure<SmtpOptions>("Transactional",
builder.Configuration.GetSection("Smtp:Transactional"));
builder.Services.Configure<SmtpOptions>("Marketing",
builder.Configuration.GetSection("Smtp:Marketing"));
// Inject and resolve by name:
public class EmailDispatcher
{
private readonly SmtpOptions _transactional;
private readonly SmtpOptions _marketing;
public EmailDispatcher(IOptionsMonitor<SmtpOptions> monitor)
{
_transactional = monitor.Get("Transactional");
_marketing = monitor.Get("Marketing");
}
public void SendTransactional(string to, string body)
=> Send(_transactional, to, body);
public void SendMarketing(string to, string body)
=> Send(_marketing, to, body);
}
IHttpClientFactory uses named options internally:
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
});
// Inject named client:
var client = httpClientFactory.CreateClient("github");
Rule of thumb: Use named options when you need multiple configurations for the same options type — commonly for HTTP clients, SMTP servers, external service endpoints, or feature flag namespaces.
Configure<T> sets up options values. PostConfigure<T> runs after all
Configure registrations for the same type — it is the last step and can override
or validate values set by earlier Configure calls or by third-party libraries.
// Scenario: override a value set by an external library's Configure call
// External library registers its defaults:
builder.Services.Configure<CacheOptions>(opts =>
{
opts.MaxSize = 100;
opts.Expiry = TimeSpan.FromMinutes(10);
});
// Your code runs AFTER — PostConfigure:
builder.Services.PostConfigure<CacheOptions>(opts =>
{
// Runs after ALL Configure calls for CacheOptions
if (opts.MaxSize > 500)
opts.MaxSize = 500; // enforce a cap regardless of what lib set
});
// PostConfigureAll — applies to ALL named instances:
builder.Services.PostConfigureAll<SmtpOptions>(opts =>
opts.UseSsl = true); // enforce SSL on every named SMTP config
Execution order for options pipeline:
- All
Configure<T>calls (in registration order) - All
PostConfigure<T>calls (in registration order) IValidateOptions<T>validators
// Confirm ordering with a simple example:
builder.Services.Configure<MyOpts>(o => o.Value = "first");
builder.Services.Configure<MyOpts>(o => o.Value = "second");
builder.Services.PostConfigure<MyOpts>(o => o.Value = "post"); // wins
// Result: opts.Value == "post"
Rule of thumb: Use PostConfigure when you need to enforce global constraints
on options that third-party libraries configure, or when you need a guaranteed
"last word" on a value regardless of other configuration sources.
The configuration system is part of Microsoft.Extensions.Configuration and works
in any .NET app — not just ASP.NET Core. Use Host.CreateDefaultBuilder or the
newer Host.CreateApplicationBuilder to get the same provider stack.
// Worker service / console app:
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
var host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((ctx, config) =>
{
// Same providers as WebApplication.CreateBuilder:
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
config.AddJsonFile($"appsettings.{ctx.HostingEnvironment.EnvironmentName}.json",
optional: true);
config.AddEnvironmentVariables();
if (ctx.HostingEnvironment.IsDevelopment())
config.AddUserSecrets<Program>();
})
.ConfigureServices((ctx, services) =>
{
services.Configure<WorkerOptions>(ctx.Configuration.GetSection("Worker"));
services.AddHostedService<MyWorker>();
})
.Build();
await host.RunAsync();
// Background service receives options via DI:
public class MyWorker : BackgroundService
{
private readonly WorkerOptions _opts;
public MyWorker(IOptions<WorkerOptions> opts) => _opts = opts.Value;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
DoWork(_opts.IntervalSeconds);
await Task.Delay(TimeSpan.FromSeconds(_opts.IntervalSeconds), ct);
}
}
}
Rule of thumb: Use Host.CreateDefaultBuilder or Host.CreateApplicationBuilder
in console apps and background services to get the same configuration, logging, and
DI infrastructure as ASP.NET Core — avoid reinventing it with manual provider setup.
IConfigureOptions<T> is an interface that lets you encapsulate options
configuration logic in a dedicated class, which is itself resolved from DI.
This is useful when the configuration logic requires services (e.g., reading from
a database or calling an API) that are not available during the Configure delegate.
// Plain Configure<T> — closure-based, no DI services available:
builder.Services.Configure<JwtOptions>(opts =>
{
opts.Secret = builder.Configuration["Jwt:Secret"]!;
// Cannot inject ISomeService here — DI is not yet built
});
// IConfigureOptions<T> — runs after DI is built; can inject services:
public class JwtOptionsConfigurator : IConfigureOptions<JwtOptions>
{
private readonly IConfiguration _config;
private readonly ISecretVaultService _vault;
// Dependencies resolved from the built DI container:
public JwtOptionsConfigurator(IConfiguration config, ISecretVaultService vault)
{
_config = config;
_vault = vault;
}
public void Configure(JwtOptions options)
{
options.Issuer = _config["Jwt:Issuer"]!;
options.Audience = _config["Jwt:Audience"]!;
// Fetch secret from vault — possible because this runs post-DI-build:
options.Secret = _vault.GetSecret("jwt-signing-key");
}
}
// Register the configurator as a DI service:
builder.Services.AddSingleton<IConfigureOptions<JwtOptions>, JwtOptionsConfigurator>();
// IPostConfigureOptions<T> — same pattern but runs after all Configure calls:
public class JwtPostConfigurator : IPostConfigureOptions<JwtOptions>
{
public void PostConfigure(string? name, JwtOptions options)
{
if (string.IsNullOrEmpty(options.Secret))
throw new InvalidOperationException("JWT secret must be configured");
}
}
builder.Services.AddSingleton<IPostConfigureOptions<JwtOptions>, JwtPostConfigurator>();
Rule of thumb: Use services.Configure<T> for simple config binding from
IConfiguration. Use IConfigureOptions<T> when configuration requires injected
services (vault clients, DB lookups, computed values) — it runs after the container
is fully built and gives you access to the complete DI graph.
Azure Key Vault is added as a configuration provider using
Azure.Extensions.AspNetCore.Configuration.Secrets. Secrets are loaded alongside
appsettings.json and accessible through the standard IConfiguration interface.
// Package: Azure.Extensions.AspNetCore.Configuration.Secrets
// Azure.Identity
using Azure.Identity;
var builder = WebApplication.CreateBuilder(args);
// Add Key Vault as a configuration source (production pattern):
if (!builder.Environment.IsDevelopment())
{
var keyVaultUri = new Uri(builder.Configuration["KeyVault:Uri"]!);
// DefaultAzureCredential: tries Managed Identity, then az login, etc.
builder.Configuration.AddAzureKeyVault(
keyVaultUri,
new DefaultAzureCredential());
}
// Secrets are available via IConfiguration using -- as the key separator:
// Key Vault secret "ConnectionStrings--DefaultConnection"
// → accessible as Configuration["ConnectionStrings:DefaultConnection"]
// Access via Options pattern (preferred):
builder.Services.Configure<DbOptions>(builder.Configuration.GetSection("ConnectionStrings"));
// Key Vault secret naming conventions:
// "Mail--Host" → Mail:Host
// "Jwt--Secret" → Jwt:Secret
// Named version: "Mail--Smtp1--Host" → Mail:Smtp1:Host
// Prefix filtering — only load secrets starting with "MyApp-":
builder.Configuration.AddAzureKeyVault(
keyVaultUri,
new DefaultAzureCredential(),
new AzureKeyVaultConfigurationOptions
{
Manager = new KeyVaultSecretManager() // custom: override to filter/rename keys
});
Key Vault secrets are fetched once at startup (no reload by default). Enable polling:
new AzureKeyVaultConfigurationOptions
{
ReloadInterval = TimeSpan.FromMinutes(30) // re-fetch secrets every 30 min
}
Rule of thumb: In production, replace User Secrets and env-var secrets with
Azure Key Vault. Use DefaultAzureCredential so the same code works with Managed
Identity in Azure and with az login locally. Never store Key Vault URIs or
credentials in appsettings.json.
When using WebApplicationFactory<T> for integration tests, you can override
configuration via ConfigureAppConfiguration in a custom factory, or by setting
environment variables before the test host starts.
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
// Custom factory that overrides configuration for tests:
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((ctx, config) =>
{
// Add in-memory config — overrides appsettings.json (added later = wins):
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = "DataSource=:memory:",
["Mail:Host"] = "localhost",
["Mail:Port"] = "1025",
["FeatureFlags:NewCheckout"] = "true",
["Jwt:Secret"] = "test-only-signing-key-minimum-32-chars!!"
});
});
// Also swap out services for test doubles:
builder.ConfigureServices(services =>
{
// Replace real DB with in-memory:
services.RemoveAll<DbContextOptions<AppDb>>();
services.AddDbContext<AppDb>(o => o.UseInMemoryDatabase("TestDb"));
});
}
}
// Test class:
public class OrdersApiTests : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client;
public OrdersApiTests(TestWebApplicationFactory factory)
=> _client = factory.CreateClient();
[Fact]
public async Task GetOrders_ReturnsOk()
{
var response = await _client.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}
}
Rule of thumb: Use AddInMemoryCollection inside ConfigureAppConfiguration
to inject test-specific values — it is the last provider added and therefore wins.
Keep test config minimal: only override values that differ from defaults or would
cause the test to contact real external services.
IChangeToken is the low-level primitive that the configuration system uses to
signal when a source has changed. IConfiguration.GetReloadToken() returns a change
token that fires once when any provider triggers a reload. You can use it to react
to config changes in code that does not have access to IOptionsMonitor<T>.
// Low-level change-token subscription:
void WatchForChanges(IConfiguration config)
{
// GetReloadToken returns a one-shot token — must re-register after each fire:
ChangeToken.OnChange(
changeTokenProducer: () => config.GetReloadToken(),
changeTokenConsumer: () =>
{
Console.WriteLine("Configuration reloaded at " + DateTime.UtcNow);
// Re-read any cached config values here
});
}
// IOptionsMonitor<T> uses the same mechanism internally — prefer it for options:
public class FeatureFlagCache
{
private HashSet<string> _enabledFlags;
public FeatureFlagCache(IOptionsMonitor<FeatureOptions> monitor)
{
_enabledFlags = BuildCache(monitor.CurrentValue);
// OnChange callback fires whenever config changes on disk:
monitor.OnChange(opts =>
{
_enabledFlags = BuildCache(opts); // rebuild the local cache
Console.WriteLine("Feature flags reloaded");
});
}
private static HashSet<string> BuildCache(FeatureOptions opts)
=> opts.EnabledFlags.ToHashSet(StringComparer.OrdinalIgnoreCase);
public bool IsEnabled(string flag) => _enabledFlags.Contains(flag);
}
// Custom IChangeToken implementation (e.g., for a DB-backed config provider):
public class DbChangeToken : IChangeToken
{
private readonly CancellationToken _token;
public DbChangeToken(CancellationToken token) => _token = token;
public bool HasChanged => _token.IsCancellationRequested;
public bool ActiveChangeCallbacks => true;
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state)
=> _token.Register(callback, state);
}
Rule of thumb: Use IOptionsMonitor<T>.OnChange for most config-reload
scenarios — it is the safe, high-level API. Drop down to IChangeToken directly
only when building a custom configuration provider or when you need to watch for
changes outside the Options pattern.
More ASP.NET Core interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.