Your app starts fine. The first few requests work. Then, hours later, one feature reaches for a setting that was never configured - and throws, deep in a request, far from the real cause.
I've been through this multiple times.
Configuration is just JSON or strings until you validate it.
ASP .NET Core can check your configuration the moment the app starts, so a typo in appsettings.json stops the app right away instead of returning a 500 Internal Error later.
There are two ways to do this: Data Annotations and FluentValidation.
In this post, we will explore:
- Why configuration validation matters
- Validate with Data Annotations
- Fail fast at startup with ValidateOnStart
- Validate with FluentValidation
- Cross-field rules and named options
- Data Annotations vs FluentValidation: when to use each
Let's dive in.
Why Configuration Validation Matters
Most apps bind configuration to a strongly typed class with the Options pattern.
Here is a settings class for talking to the GitHub API:
csharppublic sealed class GitHubSettings { public string Token { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty; public int RetryCount { get; set; } public int RetryDelaySeconds { get; set; } }
It is bound from appsettings.json:
json{ "GitHubSettings": { "Token": "ghp_xxx", "BaseUrl": "https://api.github.com", "RetryCount": 3, "RetryDelaySeconds": 2 } }
csharpbuilder.Services .AddOptions<GitHubSettings>() .Bind(builder.Configuration.GetSection(nameof(GitHubSettings)));
This binds the section, but it does not check anything.
If Token is missing or RetryCount is 0, binding still succeeds - you get an empty string and a zero.
The problem only shows up later, when some code actually uses those values.
An empty Token sails through binding and only fails when you make your first GitHub call - as a 401, or a NullReferenceException three layers deep, with nothing pointing back to the real cause: a missing config value.
Validation closes that gap. It turns a silent, half-configured object into an immediate failure.
If the Options pattern is new to you, start with Master Configuration in ASP .NET Core with the Options Pattern.
Validate with Data Annotations
The quickest way to validate is with Data Annotations - the same attributes you already use on request models.
Add them to the settings class:
csharpusing System.ComponentModel.DataAnnotations; public sealed class GitHubSettings { [Required] public string Token { get; set; } = string.Empty; [Required] [Url] public string BaseUrl { get; set; } = string.Empty; [Range(1, 10)] public int RetryCount { get; set; } public int RetryDelaySeconds { get; set; } }
Then tell the Options builder to validate them with ValidateDataAnnotations:
csharpbuilder.Services .AddOptions<GitHubSettings>() .Bind(builder.Configuration.GetSection(nameof(GitHubSettings))) .ValidateDataAnnotations();
Now [Required] rejects a missing token, [Url] rejects a malformed base URL, and [Range(1, 10)] keeps the retry count sane.
When validation fails, ASP .NET Core throws an OptionsValidationException with a message describing exactly which rule failed.
For a missing token, it looks like this:
OptionsValidationException: DataAnnotation validation failed for 'GitHubSettings' members: 'Token' with the error: 'The Token field is required.'
There is one catch: by default, that exception is not thrown until something first resolves IOptions<GitHubSettings>.
Let's fix that next.
Fail Fast at Startup with ValidateOnStart
By default, the Options system validates the first time the configuration is resolved - which might be on the first request that needs it, or much later. You want the opposite: a bad configuration should stop the app from starting at all.
ValidateOnStart does that. Chain it onto the builder:
csharpbuilder.Services .AddOptions<GitHubSettings>() .Bind(builder.Configuration.GetSection(nameof(GitHubSettings))) .ValidateDataAnnotations() .ValidateOnStart();
Now validation runs during application startup.
If the configuration is invalid, the host throws an OptionsValidationException and the app refuses to start.
This is exactly what you want: a broken deployment fails immediately and visibly during application startup.
Note:
ValidateOnStartis the single most valuable line here. Always add it. Catching a configuration mistake at startup makes a real difference between a failed deploy and a runtime incident.
Validate with FluentValidation
Data Annotations are fine for simple, per-property checks. But they have limits.
They scatter validation rules across your settings class as attributes, they cannot express rules that span several properties, and complex conditions get awkward fast. And if you already use FluentValidation in your project, you will want your configuration validated the same way.
First, install the packages:
bashdotnet add package FluentValidation dotnet add package FluentValidation.DependencyInjectionExtensions
Write a validator for the settings class - the same AbstractValidator<T> you use everywhere else:
csharpusing FluentValidation; public sealed class GitHubSettingsValidator : AbstractValidator<GitHubSettings> { public GitHubSettingsValidator() { RuleFor(x => x.Token).NotEmpty(); RuleFor(x => x.BaseUrl) .NotEmpty() .Must(url => Uri.TryCreate(url, UriKind.Absolute, out _)) .WithMessage("BaseUrl must be a valid absolute URL."); RuleFor(x => x.RetryCount).InclusiveBetween(1, 10); } }
Here is the gap: FluentValidation has no built-in support for the Options pattern.
The Options system validates through the IValidateOptions<TOptions> interface, and FluentValidation does not implement it.
So we provide a small, reusable bridge that runs any FluentValidation validator as an Options validator:
csharpusing FluentValidation; using Microsoft.Extensions.Options; namespace Configuration.Validation; public class FluentValidateOptions<TOptions> : IValidateOptions<TOptions> where TOptions : class { private readonly IServiceProvider _serviceProvider; private readonly string? _name; public FluentValidateOptions(IServiceProvider serviceProvider, string? name) { _serviceProvider = serviceProvider; _name = name; } public ValidateOptionsResult Validate(string? name, TOptions options) { if (_name is not null && _name != name) { return ValidateOptionsResult.Skip; } ArgumentNullException.ThrowIfNull(options); using var scope = _serviceProvider.CreateScope(); var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>(); var result = validator.Validate(options); if (result.IsValid) { return ValidateOptionsResult.Success; } var type = options.GetType().Name; var errors = new List<string>(); foreach (var failure in result.Errors) { errors.Add($"Validation failed for {type}.{failure.PropertyName} " + $"with the error: {failure.ErrorMessage}"); } return ValidateOptionsResult.Fail(errors); } }
A few things make this work:
- It implements
IValidateOptions<TOptions>, the hook the Options system calls. IValidateOptionsis registered as a singleton, but FluentValidation validators are usually scoped - so we open a scope with_serviceProvider.CreateScope()and resolveIValidator<TOptions>from it. Resolving a scoped service straight into a singleton would throw.- The
_namecheck returnsValidateOptionsResult.Skipfor named options that do not match, which we will use shortly.
Now wrap it in an extension method, so registering it reads cleanly:
csharppublic static class OptionsBuilderExtensions { public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>( this OptionsBuilder<TOptions> builder) where TOptions : class { builder.Services.AddSingleton<IValidateOptions<TOptions>>( serviceProvider => new FluentValidateOptions<TOptions>( serviceProvider, builder.Name)); return builder; } }
Finally, register the validator itself and enable validation.
This is the step that is easy to forget - without it, the IValidator<GitHubSettings> resolve inside the bridge fails:
csharpbuilder.Services.AddValidatorsFromAssemblyContaining<GitHubSettingsValidator>(); builder.Services .AddOptions<GitHubSettings>() .Bind(builder.Configuration.GetSection(nameof(GitHubSettings))) .ValidateFluentValidation() .ValidateOnStart();
AddValidatorsFromAssemblyContaining scans the assembly and registers every validator, including GitHubSettingsValidator.
ValidateFluentValidation plugs the bridge in, and ValidateOnStart runs it at startup - so the same fail-fast behavior you got from Data Annotations now applies to your FluentValidation rules.
Now, an invalid configuration fails at boot with the message your bridge is formatted:
OptionsValidationException: Validation failed for GitHubSettings.Token with the error: 'Token' must not be empty.
Note: validation runs against the final bound object, so it works the same no matter where the values come from - appsettings.json, environment variables, or a secret store like Azure Key Vault.
Cross-Field Rules and Named Options
This is where FluentValidation earns its place.
A rule that depends on more than one property is hard to express with attributes but trivial in a validator.
Say RetryDelaySeconds only matters when retries are enabled - add this rule to the validator:
csharpRuleFor(x => x.RetryDelaySeconds) .GreaterThan(0) .When(x => x.RetryCount > 1);
The delay must be positive, but only when RetryCount is greater than 1.
There is no clean way to write that with Data Annotations.
The bridge also supports named options.
If you register the same settings type more than once under different names, each gets its own configuration and its own validation:
csharpbuilder.Services .AddOptions<GitHubSettings>("Primary") .Bind(builder.Configuration.GetSection("GitHub:Primary")) .ValidateFluentValidation() .ValidateOnStart();
Because FluentValidateOptions captures the builder's name and compares it in Validate, returning ValidateOptionsResult.Skip when the names do not match, each named instance is validated independently.
One reusable bridge covers every setting type and every name.
Data Annotations vs FluentValidation: When to Use Each
Both approaches fail fast at startup. The difference lies in how the rules are written and how far they stretch.
| Data Annotations | FluentValidation | |
|---|---|---|
| Setup | None - built in | A validator plus a small bridge |
| Where rules live | Attributes on the settings class | A separate validator class |
| Simple rules | Great ([Required], [Range]) | Great |
| Cross-field / conditional rules | Awkward or impossible | Easy (When, Must) |
| Reuse and testing | Limited | Validators are plain classes you can unit-test |
Use Data Annotations when the rules are simple per-property checks and you want zero extra setup.
Use FluentValidation when rules span multiple properties, depend on conditions, or when your project already standardizes on it - so configuration is validated the same way as everything else.
One more advantage: a validator is a plain class, so you can unit-test your configuration rules directly, with no host or DI container:
csharpvar validator = new GitHubSettingsValidator(); var result = validator.TestValidate(new GitHubSettings { Token = "" }); result.ShouldHaveValidationErrorFor(x => x.Token);
Note: you do not have to choose globally. Validate simple settings with attributes and complex ones with FluentValidation, in the same app.
For a deeper tour of FluentValidation itself, see The Best Way to Validate Objects in .NET.
Summary
Let's recap the key takeaways:
- Validate your configuration. Binding succeeds even when values are missing or out of range - validation is what turns a half-configured object into an immediate, clear error.
- Fail fast with
ValidateOnStart. It moves validation to application startup, so a bad deploy stops at startup instead of failing later in production. Always add it. - Use Data Annotations for simple rules.
[Required],[Url], and[Range]plusValidateDataAnnotationscover most per-property checks with no setup. - Use FluentValidation for complex rules. The reusable
FluentValidateOptions<T>bridge runs any validator as an options validator, and handles cross-field rules, conditions, and named options.
Bad configuration is one of the most common ways a deployment breaks. Validating it at startup takes a few lines of code and pays for itself the first time it prevents a broken config from reaching production.
Hope you find this newsletter useful. See you next time.


Comments