Options Pattern¶
Frequently corrected
Do not build custom singleton resolution when IOptions<T> plus PostConfigure already exists. Reaching for a bespoke resolver, plan object, or info service is the most common mistake in this area.
The standard shape¶
services.AddOptions<DownloadOptions>()
.Bind(configuration.GetSection(DownloadOptions.SectionName))
.ValidateOnStart();
services.AddSingleton<IValidateOptions<DownloadOptions>, DownloadOptionsValidator>();
- Use
AddOptions<T>().Bind(section).ValidateOnStart(). - Do not construct the options object manually and register it with
AddSingleton. - Consumers inject
IOptions<T>(orIOptionsMonitor<T>when values may change at runtime).
Computed and derived values: PostConfigure¶
When a value depends on other registrations or other options (for example, resolving requirements from the set of registered features), compute it in PostConfigure.
services.AddOptions<PipelineOptions>()
.Bind(configuration.GetSection("Pipeline"))
.PostConfigure<IEnumerable<IFeature>>((options, features) =>
{
options.Requirements = features
.SelectMany(f => f.Requirements)
.Distinct()
.ToArray();
})
.ValidateOnStart();
PostConfigure runs after all Configure / Bind calls, so it sees the final bound values and can safely derive from them.
Note
PostConfigure<TDep> accepts injected dependencies, so there is no need for a hand-written factory or a lazily built singleton.
Validation: IValidateOptions¶
Use a dedicated IValidateOptions<T> class. Do not use inline lambdas in .Validate(...) for anything beyond a trivial one-liner.
public sealed class DownloadOptionsValidator : IValidateOptions<DownloadOptions>
{
public ValidateOptionsResult Validate(string? name, DownloadOptions options)
{
var failures = new List<string>();
if (options.MaxParallel < 1)
{
failures.Add($"{nameof(options.MaxParallel)} must be at least 1.");
}
if (string.IsNullOrWhiteSpace(options.TargetDirectory))
{
failures.Add($"{nameof(options.TargetDirectory)} is required.");
}
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
}
}
A class is testable in isolation, can take injected dependencies, and reports all failures at once.
Defaults¶
Defaults belong in appsettings.json or environment variables, not in code.
| Do | Don't |
|---|---|
"MaxParallel": 2 in appsettings.json |
public int MaxParallel { get; set; } = 2; scattered across classes |
Override with Download__MaxParallel=4 |
options ??= new DownloadOptions { ... } |
Required values should be required properties or validated, so a missing setting fails at startup instead of silently using a code default.
Anti-patterns¶
- A
FetchPlan/ServiceInfo-style object that recomputes state from several options. UsePostConfigureor a dedicated service that injectsIOptions<T>. - Custom singleton resolution or static accessors for options.
new MyOptions(...)in application code.- Validation that only runs on first use. Always call
ValidateOnStart().
Decision guide¶
- Plain settings:
Bind+ValidateOnStart. - Rules over those settings:
IValidateOptions<T>. - Values derived from settings or registrations:
PostConfigure. - Behavior (not data) built from multiple options: a dedicated service injecting
IOptions<T>.