newsletter

How to Validate Configuration in ASP.NET Core

Download source code
5 min read

Newsletter Sponsors

Copied

The Agent That Wrote Your Code Shouldn't Be the One Reviewing It (Sponsored)

Ask a coding agent to review the change it just wrote, and it will tell you the change looks fine. Same model, same assumptions, same blind spot β€” the tests pass, the pipeline goes green, and the logic error ships anyway.

That's the gap Gitar, Sonar's AI code reviewer, was built to close. It automatically reviews every pull request with full context of your codebase and your team's conventions β€” not just the diff β€” and catches the functional and behavioral bugs that rule-based analysis can't describe.

Gitar runs next to SonarQube instead of replacing it. SonarQube checks every change the same algorithmic way across 40+ languages, against quality gates you define.

It works with GitHub, GitLab, Bitbucket, and Azure DevOps, never retains your source code, and is free for 14 days.

πŸ‘‰ Try Gitar free for 14 days

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.

Copied

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:

csharp
public 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 } }
csharp
builder.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.

Copied

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:

csharp
using 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:

csharp
builder.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.

Copied

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:

csharp
builder.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: ValidateOnStart is 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.

Copied

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:

bash
dotnet add package FluentValidation dotnet add package FluentValidation.DependencyInjectionExtensions

Write a validator for the settings class - the same AbstractValidator<T> you use everywhere else:

csharp
using 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:

csharp
using 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.
  • IValidateOptions is registered as a singleton, but FluentValidation validators are usually scoped - so we open a scope with _serviceProvider.CreateScope() and resolve IValidator<TOptions> from it. Resolving a scoped service straight into a singleton would throw.
  • The _name check returns ValidateOptionsResult.Skip for named options that do not match, which we will use shortly.

Now wrap it in an extension method, so registering it reads cleanly:

csharp
public 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:

csharp
builder.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.

Copied

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:

csharp
RuleFor(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:

csharp
builder.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.

Copied

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 AnnotationsFluentValidation
SetupNone - built inA validator plus a small bridge
Where rules liveAttributes on the settings classA separate validator class
Simple rulesGreat ([Required], [Range])Great
Cross-field / conditional rulesAwkward or impossibleEasy (When, Must)
Reuse and testingLimitedValidators 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:

csharp
var 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.

Copied

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] plus ValidateDataAnnotations cover 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.

You can download source code for this newsletter for free
Download source code

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design β€” answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
View the Playbook

Not sure where you stand? Take the free .NET Interview Run:

  • Find out your real level β€” Junior to Senior+
  • A realistic mock .NET interview β€” across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next β€” the perfect way to benchmark yourself before diving into the Playbook.

Start your free run

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.