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.
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.
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 caught | What the user sees | How easy it is to fix |
|---|---|---|
| At deploy / startup | App refuses to start, clear log message | Easy — you fix config and redeploy |
| Lazily at first use | Random page fails hours later | Hard — looks like a runtime bug |
| Never (silent default) | Wrong behavior, no error at all | Very 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.
| Approach | Best for | Needs extra code |
|---|---|---|
ValidateDataAnnotations | Simple rules like required, range, length | No — just attributes |
Custom Validate(...) delegate | A quick one-off rule in Program.cs | A small lambda |
IValidateOptions<T> | Complex rules, comparing fields, using DI | A 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
Steps
DataAnnotations
Attributes for simple rules
Delegate
Quick inline lambda
IValidateOptions
Rich logic, uses DI
SourceGen
Fast, AOT-friendly
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 theMicrosoft.Extensions.Options.DataAnnotationspackage.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.
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
Steps
Without
Checks run at first use, maybe in prod
With
Checks run at startup, before traffic
Startup
Best moment to fail fast
First Use
Risky, hides errors
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.
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 note on related libraries
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:
- Make a class with a
SectionNameconstant. - Add data annotations for the simple rules.
- Add an
[OptionsValidator]partial class if you want AOT and speed, or a hand-writtenIValidateOptions<T>if your rules are complex. - 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 inProgram.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
- Options pattern in ASP.NET Core — Microsoft Learn
- Options pattern in .NET — Microsoft Learn
- Compile-time options validation source generation — Microsoft Learn
- Adding Validation to the Options Pattern in ASP.NET Core — Milan Jovanović
- Options Pattern Validation with FluentValidation — Milan Jovanović
Related Posts
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
API Key Authentication in ASP.NET Core: The Secure Way
Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.
Options Pattern Validation in ASP.NET Core With FluentValidation
Validate the options pattern in ASP.NET Core using FluentValidation, IValidateOptions, and ValidateOnStart. Simple steps, diagrams, and full code examples.
How to Use the Options Pattern in ASP.NET Core 7
A beginner-friendly guide to the options pattern in ASP.NET Core: bind appsettings to classes, and pick between IOptions, IOptionsSnapshot, and IOptionsMonitor.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.