Validating Options

Reject bad or missing settings before the app takes traffic, with DataAnnotations, lambdas and source-generated validators.

Syntaxbuilder.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

ApproachGood forNotes
.ValidateDataAnnotations()[Required], [Range], [Url] on propertiesReflection at run time
[OptionsValidator] partial classThe same attributes, generated at compile timeNo reflection, AOT-friendly; register as IValidateOptions<T>
.Validate(o => ..., "message")Cross-field rulesOverloads accept dependencies from DI
IValidateOptions<T> classRules that need services or many messagesReturns 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>.CurrentValue throws until the file is fixed, because ValidateOnStart only guards start-up.
  • Logging the rejected value. Failure messages name the property, not its value; keep it that way for secrets.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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&lt;T&gt;</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.

Example · csharp
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&lt;IValidateOptions&lt;PaymentOptions&gt;, PaymentOptionsValidator&gt;()</code> registers in the app.

Example · csharp
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

  • Be the first to comment on this lesson.