Skip to main content
SEMastery
ASP.NETintermediate

Adding Validation to the Options Pattern in ASP.NET Core

Learn to validate the options pattern in ASP.NET Core using data annotations, IValidateOptions, ValidateOnStart, and source generation, with simple examples and diagrams.

11 min readUpdated September 23, 2025

A safety check before the kitchen opens

Imagine a small restaurant getting ready for the day. Before the cook starts, the manager checks a few things. Is there gas in the stove? Is the fridge cold enough? Is there salt and oil? If even one thing is missing, it is much better to know before the first customer walks in, not in the middle of a busy lunch with twenty hungry people waiting.

Your ASP.NET Core app is just like that kitchen. It reads settings when it starts: a database connection, an email server address, a timeout number, an API key. If one of those settings is missing or wrong, your app is like a kitchen with no gas. It might still open the doors, but it will fail the moment real work arrives.

The options pattern is how ASP.NET Core reads settings into neat C# classes. Validation is the manager's morning checklist. In this article you will learn how to add that checklist so a bad setting stops your app right away, with a clear message, instead of breaking quietly in production.

Let us start from the basics and build up.

A quick refresher on the options pattern

The options pattern lets you read a group of settings into a strongly typed class. Say your appsettings.json has an email section.

{
  "Email": {
    "Host": "smtp.example.com",
    "Port": 587,
    "FromAddress": "[email protected]",
    "TimeoutSeconds": 30
  }
}

You make a class that matches it.

public class EmailOptions
{
    public const string SectionName = "Email";
 
    public string Host { get; set; } = string.Empty;
    public int Port { get; set; }
    public string FromAddress { get; set; } = string.Empty;
    public int TimeoutSeconds { get; set; }
}

Then you bind the section to the class in Program.cs and inject it where you need it. The flow looks like this.

Figure 1: appsettings.json is bound to a typed class, then injected into your services through IOptions.

The problem is that binding does not check anything. If Host is empty or Port is 0, binding happily gives you an empty string and a zero. Your EmailService then tries to connect to nothing, and you get a confusing crash later. Validation closes this gap.

Why "later" is the dangerous word

Without validation, a wrong setting is a hidden bomb. ASP.NET Core builds the options object lazily by default. That means it is only created the first time some code asks for it. If the email feature is used only on a "forgot password" page, the bad setting may stay hidden until a user clicks that link, perhaps days after you deployed.

The table below shows the difference between catching a problem early and catching it late.

When the error is caughtWhat the user seesHow easy it is to fix
At deploy / startupApp refuses to start, clear log messageEasy — you fix config and redeploy
Lazily at first useRandom page fails hours laterHard — looks like a runtime bug
Never (silent default)Wrong behavior, no error at allVery hard — you may not even notice

The goal of validation is to move every problem up to the first row of that table.

Three ways to validate options

ASP.NET Core gives you three main tools. They build on each other, so it helps to see them side by side first.

ApproachBest forNeeds extra code
ValidateDataAnnotationsSimple rules like required, range, lengthNo — just attributes
Custom Validate(...) delegateA quick one-off rule in Program.csA small lambda
IValidateOptions<T>Complex rules, comparing fields, using DIA separate class

And one more, newer tool sits on top of these: the source generator, which makes data-annotation style validation fast and AOT-friendly. We will cover it near the end.

Choosing a Validation Approach

DataAnnotations
Delegate
IValidateOptions
SourceGen

Steps

1

DataAnnotations

Attributes for simple rules

2

Delegate

Quick inline lambda

3

IValidateOptions

Rich logic, uses DI

4

SourceGen

Fast, AOT-friendly

Start simple. Move to a custom validator only when the rules grow.

Let us look at each one.

Way 1: Data annotations

This is the easiest way. You add attributes to your options class, the same kind you may know from model validation on forms.

using System.ComponentModel.DataAnnotations;
 
public class EmailOptions
{
    public const string SectionName = "Email";
 
    [Required]
    public string Host { get; set; } = string.Empty;
 
    [Range(1, 65535)]
    public int Port { get; set; }
 
    [Required]
    [EmailAddress]
    public string FromAddress { get; set; } = string.Empty;
 
    [Range(1, 300)]
    public int TimeoutSeconds { get; set; }
}

Now wire it up in Program.cs. The key line is ValidateDataAnnotations().

builder.Services
    .AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Two methods do the heavy lifting here:

  • ValidateDataAnnotations() tells ASP.NET Core to check the attributes. It lives in the Microsoft.Extensions.Options.DataAnnotations package.
  • ValidateOnStart() moves the check to startup, so a bad value stops the app right away instead of waiting for first use.

If Host is empty, the app will not start. You get an OptionsValidationException with a message telling you exactly which rule failed. That is the morning safety check working.

Way 2: A custom validate delegate

Sometimes a rule does not fit a simple attribute. Maybe two settings must agree with each other. For a quick rule, you can pass a small function to Validate(...).

builder.Services
    .AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
    .Validate(options =>
    {
        // A secure SMTP port should use a sensible timeout.
        if (options.Port == 465 && options.TimeoutSeconds < 10)
        {
            return false;
        }
        return true;
    }, "Secure SMTP (port 465) needs a timeout of at least 10 seconds.")
    .ValidateOnStart();

The first argument returns true if the value is fine, false if not. The second argument is the message shown when it fails. This is handy, but if you have several rules, the lambda gets crowded. That is where the next way shines.

Way 3: IValidateOptions for richer rules

When validation grows, move it into its own class that implements IValidateOptions<T>. This keeps Program.cs clean, lets you write many rules clearly, and even lets you pull in other services through dependency injection.

using Microsoft.Extensions.Options;
 
public class EmailOptionsValidator : IValidateOptions<EmailOptions>
{
    public ValidateOptionsResult Validate(string? name, EmailOptions options)
    {
        var failures = new List<string>();
 
        if (string.IsNullOrWhiteSpace(options.Host))
        {
            failures.Add("Email:Host must not be empty.");
        }
 
        if (options.Port is < 1 or > 65535)
        {
            failures.Add("Email:Port must be between 1 and 65535.");
        }
 
        if (options.Port == 465 && options.TimeoutSeconds < 10)
        {
            failures.Add("Secure SMTP needs a timeout of at least 10 seconds.");
        }
 
        return failures.Count > 0
            ? ValidateOptionsResult.Fail(failures)
            : ValidateOptionsResult.Success;
    }
}

Register the validator as a service, then keep ValidateOnStart() on the options.

builder.Services
    .AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
    .ValidateOnStart();
 
builder.Services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();

The Validate method returns a ValidateOptionsResult. You return Success when everything is fine, or Fail with a list of clear messages when something is wrong. Because this is a normal class, you can add a constructor that takes other services, which is something attributes can never do.

The sequence below shows what happens when the app starts and the validator finds a problem.

Figure 2: At startup, ValidateOnStart runs each validator. A failure throws before the app serves traffic.

How ValidateOnStart changes the timing

It is worth seeing clearly what ValidateOnStart() actually moves. Without it, the validation code still exists, but it only runs when someone first reads IOptions<EmailOptions>.Value. With it, the host resolves every IValidateOptions<T> during startup and runs them all at once.

With and Without ValidateOnStart

Startup
First Use
Without
With

Steps

1

Without

Checks run at first use, maybe in prod

2

With

Checks run at startup, before traffic

3

Startup

Best moment to fail fast

4

First Use

Risky, hides errors

The same checks run; ValidateOnStart only changes when they run.

In short: always add ValidateOnStart() to important options. The small cost is a slightly slower startup. The big reward is that a broken deployment fails loudly and instantly, which is exactly what you want.

The newer way: source-generated validation

There is one catch with data annotations. They use reflection, which reads attribute information at runtime. Reflection is slower and does not work well with Native AOT and trimming, where unused code is stripped away at build time.

.NET solved this with a compile-time options validation source generator. You write a validator class, mark it with [OptionsValidator], and the build step writes the IValidateOptions<T> code for you. No reflection, faster checks, and it works under AOT.

using Microsoft.Extensions.Options;
 
[OptionsValidator]
public partial class EmailOptionsValidator
    : IValidateOptions<EmailOptions>
{
    // The generator fills in Validate() at build time,
    // based on the data annotations on EmailOptions.
}

Notice the class is partial. That is how the generator adds the rest of the code next to yours. You still put attributes like [Required] and [Range] on EmailOptions, but now the check is generated, not reflected. Behind the scenes the generated code uses a ValidateOptionsResultBuilder to collect each error and build the final result.

Here is how the pieces fit together at build time and run time.

Figure 3: The source generator turns your attributes into real C# at build time, so no reflection is needed at run time.

For most apps, the choice is simple: if you target Native AOT or care about speed, prefer the source generator. If you have an older project and do not use AOT, plain ValidateDataAnnotations() is perfectly fine.

A popular choice for richer rules is FluentValidation, which lets you write validators in a readable, chained style. You can plug it into the options pattern by writing a small IValidateOptions<T> adapter that calls your FluentValidation validator. It stays free and open source, so it is a safe pick.

Be careful with two other names you may have read about in older tutorials. MediatR and MassTransit are now under commercial licensing for many uses. They are not needed for options validation at all, so do not reach for them here. The built-in tools plus FluentValidation cover everything in this article.

Putting it all together

A solid, real-world setup for one options class usually looks like this:

  1. Make a class with a SectionName constant.
  2. Add data annotations for the simple rules.
  3. Add an [OptionsValidator] partial class if you want AOT and speed, or a hand-written IValidateOptions<T> if your rules are complex.
  4. Always finish with ValidateOnStart().
builder.Services
    .AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

With this in place, a missing Host or a Port of 0 will stop the app at startup with a message that points straight at the bad setting. Your future self, debugging at midnight, will thank you.

Common mistakes to avoid

  • Forgetting ValidateOnStart(). Without it, validation still runs, but lazily, so errors hide until first use.
  • Binding without validating. Binding never checks anything. An empty string and a zero look perfectly valid to the binder.
  • Putting complex logic in attributes. If a rule compares two fields or needs another service, use IValidateOptions<T> instead.
  • Relying on reflection under AOT. If you publish with Native AOT, switch to the source generator so validation is not trimmed away.
  • Vague failure messages. Always include the setting name in the message, like Email:Host, so the fix is obvious.

Quick recap

  • The options pattern binds settings into typed classes, but binding alone does not check them.
  • Use ValidateDataAnnotations() with attributes like [Required] and [Range] for simple rules.
  • Use a Validate(...) delegate for a quick one-off rule in Program.cs.
  • Use IValidateOptions<T> for complex rules, cross-field checks, and rules that need dependency injection.
  • Always add ValidateOnStart() so bad settings stop the app at startup, not later in production.
  • For Native AOT and best performance, use the [OptionsValidator] source generator so validation has no reflection.
  • FluentValidation is a good free option for rich rules; avoid MediatR and MassTransit here since they are now commercially licensed and not needed.

References and further reading

Related Posts