Welcome back. In the previous lesson, you isolated outbound market-price calls behind a typed HttpClient and chose an explicit two-second timeout. Its base URL, timeout, and provider credential are still configuration concerns—but reading them individually with builder.Configuration["MarketPrice:BaseUrl"] leaves string keys scattered through startup code and makes omissions easy to miss.
In this lesson, you will replace that approach with a strongly typed, validated options object. You will also establish a practical configuration policy: shared defaults in source control, development overrides in environment-specific files, local secrets outside the repository, and deployment overrides through environment variables. By the end, a missing or invalid market-price setting will stop the API during startup rather than becoming a confusing runtime failure.
Configuration is an application contract
Configuration is not merely a collection of strings. It controls operational behavior: where the application sends outbound requests, how long it waits, whether it can connect to dependencies, and which environment it is operating in.
The weak version of the previous registration was:
var marketPriceBaseUrl =
builder.Configuration["MarketPrice:BaseUrl"]
?? throw new InvalidOperationException(
"MarketPrice:BaseUrl must be configured.");
This is acceptable for a one-off value, but it degrades as the integration grows:
- Configuration keys become magic strings spread across the application.
- Related settings are not visible as one dependency.
- Binding can silently leave missing values at defaults.
- Validation logic becomes ad hoc.
- It is harder to explain what a service requires during code review.
The options pattern treats a related configuration section as a typed object. Your market-price client should depend on a clearly named configuration contract, not on the entire IConfiguration tree.
OPTIONS PATTERN in ASP.NET Core | Getting Started With ASP.NET Core Series
Watch Rahul Nath's “OPTIONS PATTERN in ASP.NET Core” for a concise architectural explanation of why injecting the whole IConfiguration object into application code creates unnecessary coupling, and how typed options make dependencies explicit.
Watch the coupling problem to see why a controller should not need to know configuration paths. Then watch the typed binding setup, focusing on the options class, its section-name constant, DI registration, and IOptions<T> consumption. The video uses an older startup style, but the design applies directly to .NET 8's Program.cs.
For the capstone, the configuration contract will be:
| Setting | Purpose | Must be valid? |
|---|---|---|
BaseUrl | The market-price provider origin | Yes: absolute HTTPS URL with a trailing slash |
TimeoutSeconds | Time allowed for an interactive quote lookup | Yes: bounded between 1 and 10 seconds |
ApiKey | Credential for a provider that requires one | Yes: supplied outside tracked JSON files |
The exact header format depends on the provider you eventually select. The important design decision is that the key itself is never committed to appsettings.json or baked into an image.
Bind a dedicated MarketPriceOptions type
Create a folder such as:
PortfolioTracker.Api/
Integrations/
MarketPrices/
MarketPriceOptions.cs
Add this options class:
using System.ComponentModel.DataAnnotations;
namespace PortfolioTracker.Api.Integrations.MarketPrices;
public sealed class MarketPriceOptions
{
public const string SectionName = "MarketPrice";
[Required]
[Url]
public string BaseUrl { get; set; } = string.Empty;
[Range(1, 10)]
public int TimeoutSeconds { get; set; } = 2;
[Required]
public string ApiKey { get; set; } = string.Empty;
}
A configuration-bound options class should be a simple, non-abstract type with public properties that the binder can set. The section-name constant prevents "MarketPrice" from being repeated as a magic string across Program.cs, tests, and future validators.
The annotations provide basic property-level rules:
[Required]preventsnullor empty required settings.[Url]checks URL-shaped text.[Range(1, 10)]makes an accidental0,100, or negative timeout fail validation.
However, annotations alone cannot express every operational rule. For example, [Url] does not capture your policy that this third-party provider must use HTTPS, nor does it ensure that a base URL ends with / for the relative paths used by the typed client.
Options pattern in ASP.NET Core | Microsoft Learn
Read the relevant sections of Microsoft Learn's “Options pattern in ASP.NET Core.” They establish the framework behavior behind DI registration, the lifetime implications of the different IOptions interfaces, and startup validation.
In “Bind options to the dependency injection service container,” review the binding registration example and note that a configuration section becomes an injectable IOptions<T> wrapper. In “Options interfaces,” compare IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T>; pay particular attention to why a service that is scoped cannot enter a singleton. Finally, in “Options validation,” read the validation and ValidateOnStart material. Find the paragraph beginning startup validation.
Register binding, validation, and fail-fast startup
Replace the direct MarketPrice:BaseUrl lookup from the prior lesson with this registration in Program.cs:
using Microsoft.Extensions.Options;
using PortfolioTracker.Api.Integrations.MarketPrices;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<MarketPriceOptions>()
.Bind(builder.Configuration.GetSection(
MarketPriceOptions.SectionName))
.ValidateDataAnnotations()
.Validate(
options =>
Uri.TryCreate(
options.BaseUrl,
UriKind.Absolute,
out var baseUri) &&
baseUri.Scheme == Uri.UriSchemeHttps &&
options.BaseUrl.EndsWith(
'/',
StringComparison.Ordinal),
"MarketPrice:BaseUrl must be an absolute HTTPS URL ending with '/'.")
.Validate(
options => !string.IsNullOrWhiteSpace(options.ApiKey),
"MarketPrice:ApiKey must be supplied through user secrets or deployment configuration.")
.ValidateOnStart();
builder.Services
.AddHttpClient<IMarketPriceClient, MarketPriceClient>(
(serviceProvider, client) =>
{
var options = serviceProvider
.GetRequiredService<IOptions<MarketPriceOptions>>()
.Value;
client.BaseAddress = new Uri(
options.BaseUrl,
UriKind.Absolute);
client.Timeout = TimeSpan.FromSeconds(
options.TimeoutSeconds);
client.DefaultRequestHeaders.Add(
"X-Api-Key",
options.ApiKey);
});
// Existing registrations
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
"X-Api-Key" is an illustrative provider-specific header. If the actual provider specifies a different header or authentication mechanism, change it at this integration boundary. Do not put API keys in a query string, controller, Angular application, log message, or exception response.
Why ValidateOnStart() matters
Without ValidateOnStart(), the framework validates options only when something first accesses .Value. Your API might start successfully, pass a deployment health check, and only fail when a user requests a portfolio valuation.
With ValidateOnStart():
- The application binds
MarketPriceconfiguration during startup. - Data annotation and custom rules run immediately.
- Invalid configuration produces an
OptionsValidationException. - The process does not begin serving requests.
That behavior is particularly valuable in CI/CD and cloud deployments: a bad environment variable should create a failed deployment rather than a partially functional application.
Avoid including secret values in validator error messages. State the key name and expected condition, not its value.
Why use IOptions<MarketPriceOptions> here?
For this initial capstone version, market-provider configuration is expected to be stable for the lifetime of an application revision. IOptions<MarketPriceOptions> is therefore the right default.
| Interface | Lifetime and behavior | Appropriate use |
|---|---|---|
IOptions<T> | Singleton; exposes a stable configured value | Settings intended to remain fixed until restart or redeployment |
IOptionsSnapshot<T> | Scoped; recomputed once per request | Request-scoped services that genuinely need reloadable configuration |
IOptionsMonitor<T> | Singleton; exposes current values and change notifications | Long-lived services that deliberately support dynamic updates |
Do not choose IOptionsSnapshot<T> merely because it sounds newer. It cannot be injected into a singleton, and recalculating options per request has a cost. Conversely, dynamic reload of an API key or provider base URL should be an explicit operational decision, not accidental behavior.
For your current typed-client registration, fixed configuration plus restart or a new deployment revision is simpler and safer.
Layer configuration by environment
ASP.NET Core combines configuration from multiple providers. The source with higher priority supplies the effective value for a matching key.
The most relevant default application sources, from highest to lowest priority, are:
| Priority | Source | Capstone use |
|---|---|---|
| Highest | Command-line arguments | One-off local diagnostics or controlled operational overrides |
| High | Environment variables | Containers and cloud deployment settings |
| High in Development | User secrets | Local credentials outside the repository |
| Medium | appsettings.{Environment}.json | Non-secret development or environment-specific overrides |
| Baseline | appsettings.json | Shared, non-secret defaults and configuration shape |
A later provider overrides the same key from an earlier provider. Each property is independently represented as a configuration key, so an environment-specific file can override only BaseUrl without duplicating the whole MarketPrice section.
Configuration in ASP.NET Core | Microsoft Learn
Read the relevant parts of Microsoft Learn's “Configuration in ASP.NET Core.” This is the reference for provider precedence and the portable environment-variable syntax you will use in Docker and Azure.
Start with “Default app configuration sources” and examine provider precedence. Then read “Environment Variables Configuration Provider,” focusing on why environment variables override JSON and user secrets, and on double underscore syntax. In “App settings file configuration,” review the Development and Production examples beginning at environment file overrides. Finish with “Configuration keys and values” for the relationship between colon-separated application keys and double-underscore environment-variable keys.
Baseline values: appsettings.json
Commit non-secret shared settings:
{
"MarketPrice": {
"BaseUrl": "https://api.market-provider.example/",
"TimeoutSeconds": 2
}
}
Do not include ApiKey here. Since it is required by MarketPriceOptions, the application will rightly fail to start until the local developer or deployment environment supplies it.
Development overrides: appsettings.Development.json
Use this file for values that are safe to track but differ locally:
{
"MarketPrice": {
"BaseUrl": "https://sandbox.market-provider.example/",
"TimeoutSeconds": 3
}
}
When ASPNETCORE_ENVIRONMENT is Development, this file overrides the corresponding settings in appsettings.json. The API key remains absent from both files.
A useful convention is:
appsettings.jsondefines the expected configuration shape and safe defaults.- Environment-specific JSON contains non-secret differences.
- User secrets or deployment secrets contain credentials.
- Environment variables supply deploy-time values and controlled overrides.
Local secrets: user secrets
From the API project directory, initialize and set a local development secret:
dotnet user-secrets init
dotnet user-secrets set "MarketPrice:ApiKey" "replace-with-your-sandbox-key"
User secrets are stored outside the project directory and are loaded automatically in the Development environment. They are not encrypted vault storage and should not be treated as a production secret-management system, but they prevent an API key from entering Git history during local development.
Verify that your ignore rules protect common local secret files such as .env if you use them later for container tooling.
Deployment overrides: environment variables
Configuration hierarchy uses colons in .NET:
MarketPrice:TimeoutSeconds
For cross-platform environment variables, use a double underscore:
MarketPrice__TimeoutSeconds
For example, a container or cloud environment can set:
ASPNETCORE_ENVIRONMENT=Production
MarketPrice__BaseUrl=https://api.market-provider.example/
MarketPrice__TimeoutSeconds=2
MarketPrice__ApiKey=provided-by-deployment-secret
The configuration binder converts the string "2" to the int property. If it cannot convert a value, or if the resulting options violate your rules, startup should fail rather than continue with a misleading default.
Later, when you deploy to Azure Container Apps, the API key will be stored as a Container Apps secret and referenced as an environment variable. The principle remains the same: the application receives normal configuration; the deployment platform is responsible for protecting the secret value.
A short implementation pass
Apply the following changes in your capstone:
- Add
MarketPriceOptionswith the section-name constant, basic annotations, and safe defaults. - Replace direct string lookups in
Program.cswithAddOptions<MarketPriceOptions>(). - Add binding, data-annotation validation, your HTTPS/trailing-slash validation rule, and
ValidateOnStart(). - Update the typed
HttpClientregistration to read values fromIOptions<MarketPriceOptions>. - Add the safe baseline configuration to
appsettings.json. - Add an appropriate non-secret development override to
appsettings.Development.json. - Supply the API key through user secrets locally.
- Start the application and confirm it starts normally with valid settings.
Then deliberately test the failure modes:
| Change | Expected result |
|---|---|
Remove MarketPrice:ApiKey from user secrets | Startup fails and identifies the missing configuration requirement |
Set TimeoutSeconds to 0 | Startup fails range validation |
Set BaseUrl to http://provider.example/ | Startup fails your HTTPS policy |
Remove the trailing / from BaseUrl | Startup fails your custom URI policy |
Misspell MarketPrice in the binding section name | Startup fails because required values are missing rather than silently operating with defaults |
This is a high-value review habit: test configuration failure intentionally, not only the happy path.
Interview-ready explanation
A concise senior-level explanation could be:
I group related operational settings into a typed options class rather than inject the full
IConfigurationobject into application services. I bind one named section, use data annotations for simple property constraints, add custom validation for operational rules such as HTTPS-only endpoints, and callValidateOnStartso an invalid deployment fails before accepting traffic. Shared non-secret defaults stay inappsettings.json; development-only non-secrets useappsettings.Development.json; local credentials use user secrets; and container or cloud values use environment variables or secret references. Environment variables use double underscores for hierarchical configuration and override file-based values.
This answer shows more than framework familiarity: it explains how configuration supports secure, reproducible deployment.
Key takeaways
- The options pattern turns a related configuration section into an explicit, typed dependency.
- Binding alone is insufficient: a wrong section name or missing property can otherwise result in default values.
- Use annotations for straightforward rules and
Validate(...)for cross-property or operational rules. ValidateOnStart()converts invalid configuration from a delayed runtime surprise into a failed startup.appsettings.jsonprovides committed, non-secret defaults;appsettings.{Environment}.jsonprovides safe environment differences.- User secrets are appropriate for local development credentials; deployment secrets should come from the hosting platform.
- Environment variables override JSON configuration, and
__maps to:for portable hierarchical keys.
Next, you will move from implementation details to an API evolution decision: choosing an API-versioning and backward-compatibility strategy when a client-visible change is required.
Can't find a good explanation? Sign up and we'll make it for you
Sign up