Validating Options
Reject bad or missing settings before the app takes traffic, with DataAnnotations, lambdas and source-generated validators.
builder.Services.AddOptions<PaymentOptions>()
.BindConfiguration("Payments")
.Validate(o => o.Timeout < o.TotalTimeout, "Timeout must be < TotalTimeout")
.ValidateOnStart();Without validation, a missing API key or a zero timeout is discovered by the first customer whose payment fails. Options validation moves that discovery to start-up, where a crash with a clear message is the best possible outcome: the deployment never becomes ready and the old version keeps serving.
Four ways to validate
| Approach | Good for | Notes |
|---|---|---|
.ValidateDataAnnotations() | [Required], [Range], [Url] on properties | Reflection at run time |
[OptionsValidator] partial class | The same attributes, generated at compile time | No reflection, AOT-friendly; register as IValidateOptions<T> |
.Validate(o => ..., "message") | Cross-field rules | Overloads accept dependencies from DI |
IValidateOptions<T> class | Rules that need services or many messages | Returns ValidateOptionsResult |
All validators registered for a type run, and their failures are combined into one OptionsValidationException whose Failures lists every problem, so the operator fixes everything in one pass. Nested objects are only checked when the property carries [ValidateObjectMembers], and collection items when it carries [ValidateEnumeratedItems]; without them, rules on a nested RetryOptions are silently skipped.
When validation runs
Validation runs when an options instance is created: the first .Value, each new snapshot, or the first CurrentValue after a reload. On its own that is still the first request. .ValidateOnStart() asks the host to build and validate the instance during StartAsync, before the server accepts connections. Build() does not validate. Binding errors surface at the same moment, so a value of "ten" for an int also stops the start. When several option types fail, the host throws an AggregateException holding one exception per type.
Choosing
Use the source-generated validator for attribute rules in new code: it is faster at start-up, works with trimming and Native AOT, and can be unit tested by calling Validate directly. Add lambdas for relationships such as "attempt timeout shorter than total timeout". Write an IValidateOptions<T> class when a rule depends on the environment, for example "https only outside Development". Always finish the chain with ValidateOnStart().
Production failure modes
- No
ValidateOnStart(): the pod passes its readiness probe, then every payment request returns 500. - Validation registered for the default name only: named instances (
AddOptions<T>("Dhl")) need their own chain. A generated validator, by contrast, applies to every name. - A live reload with bad values:
IOptionsMonitor<T>.CurrentValuethrows until the file is fixed, becauseValidateOnStartonly guards start-up. - Logging the rejected value. Failure messages name the property, not its value; keep it that way for secrets.
Example
When to use it
- Stop a deployment whose Payments:MerchantAccount is missing before the new pods report ready, so the old version keeps serving.
- Reject a configuration where the per-attempt HTTP timeout is longer than the total timeout for calling the carrier API.
- Fail start-up in Production when the payment base URL still points at the provider's sandbox host.
More examples
Nested settings with DataAnnotations
<code>ValidateDataAnnotations()</code> validates the top-level properties of <code>WarehouseOptions</code>. <code>[ValidateObjectMembers]</code> extends it into the nested <code>Replenishment</code> object and <code>[ValidateEnumeratedItems]</code> into every element of <code>Sites</code>, with failure messages that include the path. Forgetting them is a common way for a <code>ReorderQuantity</code> of zero to reach production even though the attribute is right there on the property.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<WarehouseOptions>()
.BindConfiguration("Warehouse")
.ValidateDataAnnotations()
.ValidateOnStart();
var app = builder.Build();
app.MapGet("/warehouse/sites", (IOptions<WarehouseOptions> options) =>
TypedResults.Ok(options.Value.Sites.Select(s => s.Code)));
app.Run();
public sealed class WarehouseOptions
{
[Required]
public string DefaultSiteCode { get; set; } = "";
// Without these two attributes the rules inside are never checked.
[ValidateObjectMembers]
public ReplenishmentOptions Replenishment { get; set; } = new();
[MinLength(1), ValidateEnumeratedItems]
public List<SiteOptions> Sites { get; set; } = [];
}
public sealed class ReplenishmentOptions
{
[Range(1, 10_000)]
public int ReorderPoint { get; set; } = 25;
[Range(1, 50_000)]
public int ReorderQuantity { get; set; } = 200;
}
public sealed class SiteOptions
{
[Required, RegularExpression("^[A-Z]{3}[0-9]$")]
public string Code { get; set; } = "";
[Range(-90.0, 90.0)]
public double Latitude { get; set; }
}A validator that depends on the environment
An <code>IValidateOptions<T></code> class is resolved from DI, so it can take <code>IHostEnvironment</code>, <code>TimeProvider</code> or any other service. <code>TryAddEnumerable</code> registers it alongside other validators without adding a duplicate if a feature module registers it twice. A nullable <code>BaseUrl</code> with no default turns a missing key into a failure instead of a silent localhost. Rules like "no sandbox hosts in Production" catch the copy-paste mistakes that pass type checks: a staging value promoted to production by hand.
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<PaymentOptions>().BindConfiguration("Payments").ValidateOnStart();
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<PaymentOptions>, PaymentEnvironmentValidator>());
var app = builder.Build();
app.MapGet("/payments/endpoint", (IOptions<PaymentOptions> options) => options.Value.BaseUrl!.Host);
app.Run();
public sealed class PaymentOptions
{
// No default: a missing key must fail validation, not silently point at localhost.
public Uri? BaseUrl { get; set; }
}
public sealed class PaymentEnvironmentValidator(IHostEnvironment environment) : IValidateOptions<PaymentOptions>
{
public ValidateOptionsResult Validate(string? name, PaymentOptions options)
{
if (options.BaseUrl is null)
{
return ValidateOptionsResult.Fail("Payments:BaseUrl is required.");
}
var failures = new List<string>();
if (!environment.IsDevelopment() && options.BaseUrl.IsLoopback)
{
failures.Add("Payments:BaseUrl points at a loopback host outside Development.");
}
if (!environment.IsDevelopment() && options.BaseUrl.Scheme != Uri.UriSchemeHttps)
{
failures.Add("Payments:BaseUrl must use https outside Development.");
}
if (environment.IsProduction() && options.BaseUrl.Host.Contains("sandbox", StringComparison.OrdinalIgnoreCase))
{
failures.Add("Payments:BaseUrl points at a sandbox host in Production.");
}
return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
}
}Unit testing a generated validator
The generated validator is a plain class with a parameterless constructor, so a test calls <code>Validate</code> directly with no host, no configuration and no DI. Tests like these pin down the rules the team relies on, for example that six retries is rejected, and they fail in the CI test run, before anything is deployed. The same class is what <code>AddSingleton<IValidateOptions<PaymentOptions>, PaymentOptionsValidator>()</code> registers in the app.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
using Xunit;
public sealed class PaymentOptions
{
[Required, Url]
public string BaseUrl { get; set; } = "";
[Range(0, 5)]
public int MaxRetries { get; set; } = 2;
}
[OptionsValidator]
public partial class PaymentOptionsValidator : IValidateOptions<PaymentOptions>;
public class PaymentOptionsValidatorTests
{
private readonly PaymentOptionsValidator _validator = new();
private static PaymentOptions Valid() => new()
{
BaseUrl = "https://checkout-test.payments.example/v71/",
MaxRetries = 3
};
[Fact]
public void Accepts_a_complete_configuration()
{
var result = _validator.Validate(Options.DefaultName, Valid());
Assert.True(result.Succeeded);
}
[Theory]
[InlineData(-1)]
[InlineData(6)]
public void Rejects_retry_counts_outside_zero_to_five(int retries)
{
var options = Valid();
options.MaxRetries = retries;
var result = _validator.Validate(Options.DefaultName, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures!, f => f.Contains("MaxRetries"));
}
}
Discussion