Tame Configuration in ASP.NET Core with IValidateOptions
Silent misconfiguration is sneaky and expensive. Learn how to validate strongly typed settings in .NET using IValidateOptions, add cross property rules, inject services, support named options, and fail fast at startup with ValidateOnStart.
If you have ever shipped a service that waited until 2 AM to reveal a missing config value, you know the special kind of adrenaline that only a pager can deliver.
This post shows how to stop silent misconfiguration by validating options up front using IValidateOptions. We will bind strongly typed settings, add cross property rules, inject services into validators, support named options for multiple clients, and fail fast at startup with ValidateOnStart.
The silent failure problem
Binding settings to a class is great, but binding does not imply valid. Missing or out of range values will happily slide through.
Here is a tiny options class we will use in examples.
public sealed class SmtpOptions{ public string? Host { get; set; } public int Port { get; set; } = 25; public bool UseSsl { get; set; } public string? Sender { get; set; }}And a minimal Program.cs that binds configuration. Nothing here prevents a null Host from sneaking into production.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<SmtpOptions>() .Bind(builder.Configuration.GetSection("Smtp"));
var app = builder.Build();app.MapGet("/", () => "It boots, but is it valid?");app.Run();Meet IValidateOptions
IValidateOptions
Let us encode a few rules: Host is required, Port must be a real port, and if UseSsl is true then Port must be 465. Short and readable beats mysterious runtime exceptions.
public sealed class SmtpOptionsValidator : IValidateOptions<SmtpOptions>{ public ValidateOptionsResult Validate(string? name, SmtpOptions o) { if (string.IsNullOrWhiteSpace(o.Host)) return ValidateOptionsResult.Fail("Smtp:Host is required."); if (o.Port is < 1 or > 65535) return ValidateOptionsResult.Fail("Smtp:Port must be 1-65535."); if (o.UseSsl && o.Port != 465) return ValidateOptionsResult.Fail("UseSsl requires port 465."); return ValidateOptionsResult.Success; }}Register the validator and ask the host to validate at startup. If validation fails, the app will fail fast during startup with an OptionsValidationException. Your on call self will thank you.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<SmtpOptions>() .Bind(builder.Configuration.GetSection("Smtp")) .ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
var app = builder.Build();app.MapGet("/", () => "Startup validation passed");app.Run();Cross property rules that data annotations cannot express
Attributes are great for simple checks on a single property. Once conditions span properties, code reads clearer and scales better. The validator above already shows a cross property rule by coupling UseSsl with Port.
If you prefer a different mapping, update the rule. For example, allow 587 for environments that terminate TLS elsewhere.
if (o.UseSsl && o.Port is not (465 or 587)) return ValidateOptionsResult.Fail("UseSsl requires port 465 or 587.");Inject services for contextual checks
Because a validator is a normal DI service, you can bring in the environment, a logger, or even a small lookup service. That unlocks rules like different defaults per environment.
public sealed class SmtpEnvValidator : IValidateOptions<SmtpOptions>{ private readonly IHostEnvironment _env; public SmtpEnvValidator(IHostEnvironment env) => _env = env; public ValidateOptionsResult Validate(string? name, SmtpOptions o) { if (_env.IsProduction() && !o.UseSsl) return ValidateOptionsResult.Fail("SSL is required in Production."); return ValidateOptionsResult.Success; }}Register multiple validators for the same options type. The framework runs them all and aggregates failures.
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpEnvValidator>();Named options for multiple clients
Sometimes you configure the same shape more than once. Two upstream APIs with different endpoints and keys is a classic example. Named options keep instances separate and still validated.
public sealed class UpstreamApiOptions{ public string? BaseAddress { get; set; } public string? ApiKey { get; set; }}Bind two named instances.
builder.Services.AddOptions<UpstreamApiOptions>("github") .Bind(builder.Configuration.GetSection("Apis:GitHub"));
builder.Services.AddOptions<UpstreamApiOptions>("jira") .Bind(builder.Configuration.GetSection("Apis:Jira"));Write one validator that understands names. Return Skip to ignore names you do not own.
public sealed class UpstreamApiValidator : IValidateOptions<UpstreamApiOptions>{ public ValidateOptionsResult Validate(string? name, UpstreamApiOptions o) { if (name is not ("github" or "jira")) return ValidateOptionsResult.Skip; if (string.IsNullOrWhiteSpace(o.BaseAddress)) return ValidateOptionsResult.Fail("BaseAddress is required."); if (string.IsNullOrWhiteSpace(o.ApiKey)) return ValidateOptionsResult.Fail("ApiKey is required."); return ValidateOptionsResult.Success; }}And register it once.
builder.Services.AddSingleton<IValidateOptions<UpstreamApiOptions>, UpstreamApiValidator>();Quick checks with OptionsBuilder.Validate
For tiny rules that do not deserve a class, OptionsBuilder has a Validate overload that takes a predicate. It is perfect for a one liner.
builder.Services.AddOptions<SithDoorOptions>() .Bind(builder.Configuration.GetSection("DeathStarDoor")) .Validate(o => o.OpenHoursStart >= 0 && o.OpenHoursEnd <= 23, "Open hours must be within 0 and 23.") .ValidateOnStart();A dedicated validator class wins once you need multiple checks, DI, or reuse.
Using the options in a Minimal API
When you retrieve options, you are guaranteed they passed validation when using ValidateOnStart. You can inject IOptionsMonitor for reloadable settings or IOptionsSnapshot in scoped contexts.
app.MapGet("/email/ping", (IOptionsMonitor<SmtpOptions> opt) =>{ var cfg = opt.CurrentValue; return Results.Ok(new { cfg.Host, cfg.Port, cfg.UseSsl });});Testing the validator
The best part is how easy validators are to test. There is no need to spin up a host. You instantiate the class and call Validate. Here is a quick example.
var validator = new SmtpOptionsValidator();var bad = new SmtpOptions { Host = "", Port = 25, UseSsl = false };var result = validator.Validate(Options.DefaultName, bad);Console.WriteLine(result.Failed); // TrueYou can assert on the returned errors without dealing with the rest of the application.
Common questions
- Do I need both data annotations and IValidateOptions? You can mix them, but a single validator class often replaces most attributes and keeps rules in one place.
- When does ValidateOnStart run? During application startup, before the app begins listening for requests.
- Can validators be async? The interface is sync. If you must check something external, consider validating a cached snapshot during startup instead of every access.
Wrap up
Configuration should fail loud and early. IValidateOptions gives you readable rules, cross property checks, DI support, named options, and a clean way to stop bad settings before they bite. Pair it with ValidateOnStart, and misconfiguration moves from late night mystery to compile visible certainty. Your logs and your sleep will be happier than a hobbit with second breakfast.
Sign in to join in. Reading needs nothing.