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, IValidateOptionsValidateOnStart().
// 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 Dependency Injection interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.