Updated on

Options validation turns a bad configuration value into a clear failure instead of a strange bug three layers down. ASP.NET Core offers three ways to write the rules and one way to decide when they run.

The rules go on the options class as data annotations, in a delegate on the registration, or in a class implementing IValidateOptions<T>. Each is a chained call on AddOptions<T>(), so they combine freely.

When they run is the separate question, and the default answer is usually the wrong one. Validation is lazy, it fires the first time something resolves the options, which is typically the first request. ValidateOnStart() moves it to startup, where a misconfigured deployment fails before it can serve anybody.

Just as a reminder, in the previous article, we’ve talked about the options interfaces and how to implement them. Now we need to protect our application from invalid configuration values.

Whether we work on a large project with many configuration parameters, or we rely on external configuration providers, validation plays an important role in an application lifecycle.

To download the source code for this article, you can visit the OptionsValidation folder in our GitHub repository. The source code for the whole series is here.

Let’s dive in.

How Do We Validate Options With Data Annotations?

Data annotations are the shortest route. Put [Required], [Range], [MaxLength] or [RegularExpression] on the options class properties, then tell the registration to honour them.

The registration changes shape to do it. builder.Services.AddOptions<TitleConfiguration>().Bind(builder.Configuration.GetSection("Pages:HomePage")).ValidateDataAnnotations() replaces the plain Configure<T> call, because validation is only available on OptionsBuilder<T>.

When a rule fails, the application throws OptionsValidationException with the property name and the rule that broke, which is a considerable improvement on a null reaching a service that expected a connection string.

The catch is when it throws. Nothing is validated until something resolves the options, so a required setting missing from production surfaces on the first request that needs it rather than at deployment.

Data annotations also stop at the top level. A nested options object is not validated unless the property holding it is marked for recursion, and a collection of them is not validated either.

You might have seen data annotations used in other scenarios like validating forms in ASP.NET Core MVC or Blazor. Here we use the same attributes to validate configuration on application start or configuration reload.

Let’s add some data annotations to our model TitleConfiguration:

public class TitleConfiguration
{
    [Required]
    [MaxLength(60)]
    public string WelcomeMessage { get; set; } = string.Empty;

    public bool ShowWelcomeMessage { get; set; }

    public string Color { get; set; } = string.Empty;

    public bool UseRandomTitleColor { get; set; }
}

We’ve added both [Required] and [MaxLength] attributes, so we can state that our WelcomeMessage is mandatory and that it isn’t longer than 60 characters.

Now that we’ve decorated our WelcomeMessage property, we need to configure our validator in order to check these values.

In the previous part, we configured our options in Program.cs:

builder.Services.Configure<TitleConfiguration>("HomePage", builder.Configuration.GetSection("Pages:HomePage"));

We can remove that line because we need to do it a bit differently in order to enable configuration validation:

builder.Services.AddOptions<TitleConfiguration>()
    .Bind(builder.Configuration.GetSection("Pages:HomePage"))
    .ValidateDataAnnotations();

We’re using the AddOptions() method to add the configuration and the Bind() method to bind it to a specific configuration subsection, in our case “Pages:HomePage”. After that, we can call ValidateDataAnnotations() method to make sure our validation triggers for the data annotations we’ve set.

We can also quickly revert TitleColorService, ITitleColorService, and HomeController to not use the named options, since we don’t need them anymore:

public class TitleColorService(IOptionsMonitor<TitleConfiguration> titleConfiguration) : ITitleColorService
{
    private readonly string[] _colors = ["red", "green", "blue", "black", "purple", "yellow", "brown", "pink"];
    private readonly IOptionsMonitor<TitleConfiguration> _titleConfiguration = titleConfiguration;

    public string GetTitleColor()
    {
        var configuration = _titleConfiguration.CurrentValue;

        return configuration.UseRandomTitleColor
            ? _colors[Random.Shared.Next(_colors.Length)]
            : configuration.Color;
    }
}
public interface ITitleColorService
{
    string GetTitleColor();
}
public class HomeController(
    ILogger<HomeController> logger,
    IOptionsSnapshot<TitleConfiguration> homePageTitleConfiguration,
    ITitleColorService titleColorService) : Controller
{
    private readonly ILogger<HomeController> _logger = logger;
    private readonly TitleConfiguration _homePageTitleConfiguration = homePageTitleConfiguration.Value;
    private readonly ITitleColorService _titleColorService = titleColorService;

    public IActionResult Index()
    {
        var homeModel = new HomeModel
        {
            Configuration = _homePageTitleConfiguration
        };

        homeModel.Configuration.Color = _titleColorService.GetTitleColor();

        return View(homeModel);
    }
}

Note that TitleColorService holds the IOptionsMonitor<T> and reads CurrentValue inside GetTitleColor() rather than capturing the value in the constructor. A singleton that captures the value once keeps serving the value it read at startup, no matter what the configuration file says later.

Now let’s head back to our appsettings.json and remove the WelcomeMessage option (we can remove the “ProductPage” section altogether too since we won’t need it):

"Pages": {
    "HomePage": {
        "ShowWelcomeMessage": true,
        "Color": "black",
        "UseRandomTitleColor": true
    }
},

Sure enough, if we run the application now, we’ll get OptionsValidationException:

options validation exception

Moreover, we’ll get the details of the field that was problematic, and that’s WelcomeMessage in our case.

We can also try the MaxLength validation by adding a few words to the WelcomeMessage option:

"Pages": {
    "HomePage": {
        "WelcomeMessage": "Hi human, and welcome to the ProjectConfigurationDemo Home Page",
        "ShowWelcomeMessage": true,
        "Color": "black",
        "UseRandomTitleColor": true
    }
},

Now we get the maximum length exceeded exception:

options validation maxlength exception

Great, works, like a charm.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

And not only that, but it works even if we change the configuration and correct it whilst the application is running. Try it out! Revert the WelcomeMessage to the valid value and refresh the page to see what happens.

Needless to say, that’s fantastic stuff.

One more thing is worth knowing before we move on. Attributes on a nested options object are ignored by default, which is the second surprise in this section. Marking the property that holds the nested object with [ValidateObjectMembers] tells the validator to recurse into it, and [ValidateEnumeratedItems] does the same for a collection property, validating each item in turn. Both of them work on the plain ValidateDataAnnotations() path shown above.

But what if we need a more flexible validation logic?

How Do We Validate Options With a Delegate?

A delegate puts the rule on the registration instead of on the class. .Validate(config => …) takes a predicate that returns true when the options are acceptable.

It earns its place when one rule spans two properties, which an attribute cannot express. A minimum that must be below a maximum, or a setting that is only required when a flag is on, fits here and nowhere simpler.

Always pass the failure message as the second argument. Without it the exception says only that a validation failed, which sends the reader to the registration code to find out which one.

Delegates and data annotations combine. .ValidateDataAnnotations() and .Validate(…) chain onto the same AddOptions<T>(), so per-property rules stay as attributes and the cross-property rule stays as the lambda.

Past two or three rules, the lambda stops being readable and the next section is the answer.

The fastest way to do it is by using an anonymous function inside the Validate() method that’s an extension of the OptionsBuilder we’ve used previously:

builder.Services.AddOptions<TitleConfiguration>()
    .Bind(builder.Configuration.GetSection("Pages:HomePage"))
    .ValidateDataAnnotations()
    .Validate(config => !config.UseRandomTitleColor || config.ShowWelcomeMessage);

This is the kind of rule an attribute cannot express, because it reads two properties at once: a random title color only makes sense while the welcome message is actually shown. The attributes stay on the class and keep doing the per-property work.

Now if we set ShowWelcomeMessage to false while UseRandomTitleColor stays true and run the application again, we get a bit different kind of exception:

custom options validation ex

This message is a bit generic, but that’s to be expected since we’re doing our own custom validation logic. We can make it a bit better by defining a failure message:

builder.Services.AddOptions<TitleConfiguration>()
    .Bind(builder.Configuration.GetSection("Pages:HomePage"))
    .ValidateDataAnnotations()
    .Validate(config => !config.UseRandomTitleColor || config.ShowWelcomeMessage,
        "A random title color is pointless when the welcome message is hidden.");

Now our exception shows this message:

custom options validation ex message

Of course, we can do some pretty nice stuff with delegates, so if you’re not familiar with delegates that much check out our article about delegates in C#.

Great, we learned how to do custom validation if needed. With these methods, we’re able to implement validation quickly.

But let’s see what we can do in those really complex validation scenarios.

How Does IValidateOptions Handle Complex Rules?

IValidateOptions<T> moves validation into its own class, which is where it belongs once the rules stop fitting on one line.

The interface declares one method: ValidateOptionsResult Validate(string? name, T options). The name identifies which named options instance is being checked, and is null when all of them are.

Returning ValidateOptionsResult.Fail("message") reports a failure and ValidateOptionsResult.Success reports none, so the class can explain what is wrong rather than just that something is.

Two things this buys that a delegate does not. The validator is a service, so it can take dependencies through its constructor and check a value against something the application already knows. And it is an ordinary class, so it can be unit tested without starting a host.

Returning on the first failure hides the rest. Collect them instead, and report every broken rule at once.

In order to do that, let’s create a TitleConfigurationValidation class first and implement IValidateOptions interface. For that purpose, we can create a separate folder called ConfigurationValidation and then create a new class TitleConfigurationValidation inside it:

public class TitleConfigurationValidation : IValidateOptions<TitleConfiguration>
{
    public ValidateOptionsResult Validate(string? name, TitleConfiguration options)
    {
        throw new NotImplementedException();
    }
}

We are going to implement the IValidateOptions interface using the TitleConfiguration as the options parameter for the Validate() method.

The IValidateOptions interface declares only one method, Validate(), and it accepts two arguments, name and options. The name is nullable on purpose: null means the validator is being asked about every named instance rather than one in particular. We can also see that this method returns ValidateOptionsResult which is a convenient way to provide result information. Much more convenient than just true or false like we did previously.

First, we can move our existing options validation into this method, and instead of returning on the first broken rule, we can collect the failures with ValidateOptionsResultBuilder:

public ValidateOptionsResult Validate(string? name, TitleConfiguration options)
{
    var builder = new ValidateOptionsResultBuilder();

    if (string.IsNullOrEmpty(options.WelcomeMessage) || options.WelcomeMessage.Length > 60)
        builder.AddError("Welcome message must be defined and it must be less than 60 characters long.",
            nameof(options.WelcomeMessage));

    return builder.Build();
}

That certainly looks better. We can now clean up Program.cs, and register our TitleConfigurationValidation:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<TitleConfiguration>()
    .Bind(builder.Configuration.GetSection("Pages:HomePage"))
    .ValidateDataAnnotations();

builder.Services.TryAddEnumerable(
    ServiceDescriptor.Singleton<IValidateOptions<TitleConfiguration>, TitleConfigurationValidation>());

builder.Services.TryAddSingleton<ITitleColorService, TitleColorService>();

builder.Services.AddControllersWithViews();

We register it with TryAddEnumerable rather than TryAddSingleton so that a second validator can be added later. TryAddSingleton does nothing at all once any IValidateOptions<TitleConfiguration> is registered, so a second validator added that way would silently never run.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

If we run the application again, we should get the same result as we did previously.

Now let’s show off the full power of the IValidateOptions interface by implementing the title color validation. Say, for example, we want to make sure that the title color is just among the colors we provided in order to make our application look consistent.

We just need to extend our Validation class to support this logic:

public class TitleConfigurationValidation : IValidateOptions<TitleConfiguration>
{
    private readonly string[] _colors = ["red", "green", "blue", "black", "purple", "yellow", "brown", "pink"];

    public ValidateOptionsResult Validate(string? name, TitleConfiguration options)
    {
        var builder = new ValidateOptionsResultBuilder();

        if (string.IsNullOrEmpty(options.WelcomeMessage) || options.WelcomeMessage.Length > 60)
            builder.AddError("Welcome message must be defined and it must be less than 60 characters long.",
                nameof(options.WelcomeMessage));

        if (!_colors.Contains(options.Color))
            builder.AddError($"Provided title color '{options.Color}' is not among allowed colors.",
                nameof(options.Color));

        return builder.Build();
    }
}

Now the title color must be among those we’ve provided. If we provide different color, for example, gray:

"Pages": {
    "HomePage": {
        "WelcomeMessage": "Welcome to the ProjectConfigurationDemo Home Page",
        "ShowWelcomeMessage": true,
        "Color": "gray",
        "UseRandomTitleColor": true
    }
},

After running the application we’ll get a message saying “gray” is not valid color:

color validation

Because the builder collects failures instead of returning on the first one, a configuration that breaks both rules reports both of them in a single run, which is one deploy cycle instead of two.

And because the controller resolves IOptionsSnapshot<TitleConfiguration>, which is re-created and re-validated on every request, we can simply revert the color to “black” and refresh the page to get it working again. The validator does not need a restart to notice.

Great! We’ve learned how to validate our configuration in several different ways. This kind of class is also easy to test on its own, without a host: create the validator, hand it an options instance, and assert on the ValidateOptionsResult it returns. If you need the same trick for the consuming code, we cover how to mock IOptions<T> in a test separately.

How Do We Fail at Startup Instead of on First Request?

All three approaches share one default, and it is the wrong one for a deployment. Validation is lazy: the rules run the first time something resolves the options, which in a web application means the first request that touches them.

A deployment with a missing connection string therefore starts, reports healthy, and fails on a user’s request rather than on the deploy.

ValidateOnStart() fixes it. Chained onto the registration, it runs every rule while the host starts, so the process fails to start rather than starting broken.

AddOptionsWithValidateOnStart<TitleConfiguration>() does the same thing at the other end, replacing AddOptions<T>() so the eager behaviour is declared where the options are declared rather than at the end of a chain.

There is a two-argument form as well. AddOptionsWithValidateOnStart<TitleConfiguration, TitleConfigurationValidation>() registers the validator class at the same time.

Here is the registration from the finished sample, with the eager form and the cross-property rule together:

builder.Services
    .AddOptionsWithValidateOnStart<TitleConfiguration>()
    .Bind(builder.Configuration.GetSection("Pages:HomePage"))
    .ValidateDataAnnotations()
    .Validate(config => !config.UseRandomTitleColor || config.ShowWelcomeMessage,
        "A random title color is pointless when the welcome message is hidden.");

One detail worth holding on to: eager validation is per registration, not global. It covers every rule on the builder it was chained to, including named instances nothing ever resolves, and it covers nothing on a builder that did not ask for it. Registering a second options type without ValidateOnStart() leaves that one lazy.

Failing fast on bad configuration is one of a handful of decisions that separate an application that runs from one that can be deployed on a Friday. The Ultimate ASP.NET Core Web API course builds the rest of them into the same project, health checks, environment-aware settings, and a startup path that either works or says why.

Which Options Validation Approach Should We Use?

Start with data annotations. Most configuration rules are per-property, attributes read well on the class, and the registration is one chained call.

Add a delegate when a rule needs two properties at once. Keep it to one or two; past that the lambda is harder to read than the class it is avoiding.

Move to IValidateOptions<T> when the rules need another service, need testing on their own, or have simply grown. The cost is a file; the benefit is that validation stops being an argument to a registration call.

Then reach for the source generator. Marking an empty partial class [OptionsValidator] makes the compiler write the IValidateOptions<T> implementation from the same data annotations, with no reflection at runtime and no trimming warnings under native AOT.

Whichever is chosen, add ValidateOnStart(). The rules are worth little if nothing runs them until a user does.

ApproachRules live inReach for it whenRegistration
Data annotationsAttributes on the options classRules are per-property: required, range, length, pattern.ValidateDataAnnotations()
DelegateA lambda on the registrationOne rule spans two properties and is short.Validate(o => …, "message")
IValidateOptions<T>Its own classRules are long, need other services, or should be unit testedTryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<T>, V>())
[OptionsValidator] source generatorAttributes on the class, validator generatedSame rules as data annotations, without reflection at runtime[OptionsValidator] partial class V : IValidateOptions<T>

The source generator has its own walkthrough, with the generated code shown side by side with the class it came from: using source generators to validate IOptions.

Validation is only half of the job, of course. Where the values themselves come from, and how the sensitive ones stay off a developer machine, is the subject of secret management in .NET.

Conclusion

In this article, we’ve covered options validation in three different ways. The first way is by using DataAnnotations, which is a common way of validating fields in ASP.NET Core, we’ve seen how to configure it by using delegates, and finally, we’ve learned how to use IValidateOptions to validate complex scenarios and clean up our code. On top of that, we’ve seen how ValidateOnStart() moves all of them from the first request to the moment the host starts.

In the next part, we’re going to learn more about different configuration providers, and you can find other parts of this series on the ASP.NET Core Web API page.

Tested with .NET 10.0.10.