Articles/
IOptions FluentValidation at Startup
Move configuration validation to application startup using FluentValidation, catching errors before they reach your controllers.
ASP.NET Core's IOptions<T>
pattern handles typed configuration well, but validation only happens when the DI container resolves those options. This means your application can start successfully with invalid configuration (missing API keys, malformed connection strings, out-of-range values) and only fail when code actually tries to use those options.
The following pattern moves validation to application startup using FluentValidation and ValidateOnStart()
. If configuration is invalid, the app refuses to start. Configuration errors become immediate startup failures instead of runtime surprises.
What's in this article:
The implementation itself is straightforward - read through When Validation Actually Runs and you'll have everything you need to get started. The rest covers context I find helpful: when this pattern makes sense (and when it doesn't), how it differs from health checks, and some patterns I've developed for handling the quirks in production.
The Advanced Topics section covers manual validation timing and custom exception handling. You might not need any of that initially, but it's there when you run into those scenarios.
A Typical Example
Here's what a typical options pattern looks like:
public class EmailOptions
{
public string SmtpServer { get; set; }
public int Port { get; set; }
public string ApiKey { get; set; }
}
public class EmailService
{
private readonly EmailOptions _options;
public EmailService(IOptions<EmailOptions> options) // Validation triggers when IOptions resolves
{
_options = options.Value; // Often we'd manually validate here
}
}
Let's say EmailService
only gets used by a notification endpoint. Maybe that endpoint doesn't get hit for hours after deployment. Maybe it's rarely used. Either way, your broken configuration sits there waiting to cause problems.
Validate at Startup
Instead of waiting to discover invalid configuration at runtime, we can validate it at startup and prevent the app from starting if anything's wrong.
This is especially useful in Kubernetes or similar orchestrators. They'll automatically restart failed containers (if you have health checks setup). In fact, they won't roll out bad deployments in the first place.
The pattern combines FluentValidation with ASP.NET Core's ValidateOnStart()
. Validation runs during startup, before any HTTP requests arrive.
Implementation
Core Interfaces
First, you'll need some interfaces. These enforce a contract for all config classes:
using FluentValidation;
namespace YourNamespace.Configuration;
public interface IStandardOptions
{
static abstract string SectionName { get; }
}
public interface IStandardOptionsWithValidation<TOptions> : IStandardOptions
where TOptions : class, IStandardOptionsWithValidation<TOptions>
{
static abstract void SetupValidator(AbstractValidator<TOptions> validator);
}
C# 11's static abstract members let us enforce this at compile time. Config classes must declare their section name, and validatable config classes must provide validation rules.
Configuration Class Example
Here's what your config class looks like:
public class EmailOptions : IStandardOptionsWithValidation<EmailOptions>
{
public static string SectionName => "Email";
public string SmtpServer { get; set; } = string.Empty;
public int Port { get; set; }
public string ApiKey { get; set; } = string.Empty;
public static void SetupValidator(AbstractValidator<EmailOptions> validator)
{
validator.RuleFor(x => x.SmtpServer)
.NotEmpty()
.Must(x => Uri.CheckHostName(x) != UriHostNameType.Unknown)
.WithMessage("SmtpServer must be a valid hostname");
validator.RuleFor(x => x.Port)
.InclusiveBetween(1, 65535);
validator.RuleFor(x => x.ApiKey)
.NotEmpty()
.MinimumLength(32);
}
}Now wire it up with these extension methods:
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace YourNamespace.Configuration;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOptionsWithValidation<TOptions>(
this IServiceCollection services)
where TOptions : class, IStandardOptionsWithValidation<TOptions>
{
var sectionName = TOptions.SectionName;
// Register FluentValidation validator
services.TryAddSingleton<IValidator<TOptions>>(sp =>
new OptionsValidator<TOptions>(TOptions.SetupValidator));
// Configure standard options binding
services.AddOptions<TOptions>()
.BindConfiguration(sectionName)
.ValidateFluentValidation()
.ValidateOnStart();
return services;
}
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(
this OptionsBuilder<TOptions> builder)
where TOptions : class
{
builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
new FluentValidationOptions<TOptions>(
sp.GetService<IValidator<TOptions>>()));
return builder;
}
}
The first method does the heavy lifting. It registers the validator, binds your config, hooks up FluentValidation, and enables startup validation all in a single call.
FluentValidation Integration
This adapter translates FluentValidation results into what IOptions expects:
using FluentValidation;
using Microsoft.Extensions.Options;
namespace YourNamespace.Configuration;
public class OptionsValidator<TOptions> : AbstractValidator<TOptions>
where TOptions : class, IStandardOptionsWithValidation<TOptions>
{
public OptionsValidator(Action<AbstractValidator<TOptions>> configure)
{
configure(this);
}
}
public class FluentValidationOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IValidator<TOptions>? _validator;
public FluentValidationOptions(IValidator<TOptions>? validator)
{
_validator = validator;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (_validator is null)
{
return ValidateOptionsResult.Fail(
$"No validator registered for {typeof(TOptions).Name}");
}
ArgumentNullException.ThrowIfNull(options);
var result = _validator.Validate(options);
if (result.IsValid)
{
return ValidateOptionsResult.Success;
}
var errors = string.Join("; ", result.Errors.Select(e =>
$"{e.PropertyName}: {e.ErrorMessage}"));
return ValidateOptionsResult.Fail(
$"Validation failed for {typeof(TOptions).Name}: {errors}");
}
}
When validation fails, you get clear error messages with each property violation spelled out instead of generic "invalid configuration" exceptions.
Usage in Program.cs
Using it is simple:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptionsWithValidation<EmailOptions>();
var app = builder.Build();
// Validation happens during Host.StartAsync() when you call app.Run()
app.Run();When Validation Actually Runs
Here's something that tripped me up at first. Validation doesn't happen during builder.Build()
. It happens when you call app.Run()
.
Under the hood, app.Run()
calls Host.StartAsync()
. That's when IStartupValidator
kicks in. Check the .NET Host source if you want to see the details.
The startup sequence looks like this:
-
IHostLifetime.WaitForStartAsync- Host readiness check -
IStartupValidator.Validate()- Options validation (our code runs here) -
IHostedLifecycleService.StartingAsync- Pre-startup hooks -
IHostedService.StartAsync- Hosted services start -
IHostedLifecycleService.StartedAsync- Post-startup hooks
Validation runs early in startup. But not until the host actually starts.
Note
: .NET 9 changed how IStartupValidator
works internally. Earlier versions behave differently. Check OptionsBuilderExtensions.cs
in the dotnet/runtime repo to see how your version wires up ValidateOnStart()
.
Why This Helps
In containerized environments, bad config prevents pods from becoming ready in Kubernetes, so bad deployments don't roll out. Local development gets clearer since errors show up at startup instead of forcing you to hunt through logs hours later. Production stays safe because config drift gets caught before any requests arrive. You'll know immediately if environment variables are missing. FluentValidation gives you property-level details instead of vague "invalid configuration" messages, and because this uses IStartupValidator
, validation happens after DI builds but before hosted services start.
When You Shouldn't Use This
Strict startup validation isn't always appropriate. In multi-tenant apps, one tenant's bad config shouldn't kill the whole application. Validate per-tenant instead. If your app supports graceful degradation (say, it works fine without email), don't fail startup for missing SMTP config when sending notifications is optional. For monolithic services that handle multiple concerns (web API + background worker), valid config for one part shouldn't require valid config for both, though this usually suggests splitting services. In development environments where you want the app to run even with incomplete config, use conditional registration:
if (builder.Environment.IsProduction())
{
builder.Services.AddOptionsWithValidation<EmailOptions>();
}
else
{
builder.Services.AddOptions<EmailOptions>()
.BindConfiguration(EmailOptions.SectionName);
}Configuration vs. Dependency Health
Configuration validation and dependency health checks solve different problems.
Configuration validation asks: "Is my config well-formed?"
- Is the SMTP port between 1 and 65535?
- Is the API key at least 32 characters?
- Is the connection string format valid?
Dependency health checks ask: "Can I reach external systems?"
- Can I connect to the database?
- Is the external API responding?
- Does the SMTP server accept my credentials?
Use Health Checks for Connectivity
For checking external dependencies, use ASP.NET Core's Health Checks. Don't abuse options validation for this:
var builder = WebApplication.CreateBuilder(args);
// Validate config structure
builder.Services.AddOptionsWithValidation<EmailOptions>();
builder.Services.AddOptionsWithValidation<DatabaseOptions>();
// Check external dependencies
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddCheck<EmailServiceHealthCheck>("email")
.AddNpgSql(builder.Configuration.GetConnectionString("Default")!)
.AddUrlGroup(new Uri("https://api.external.com/health"), "external-api");
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();Here's what a health check looks like:
public class EmailServiceHealthCheck : IHealthCheck
{
private readonly IOptions<EmailOptions> _options;
public EmailServiceHealthCheck(IOptions<EmailOptions> options)
{
_options = options;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
using var client = new SmtpClient(_options.Value.SmtpServer, _options.Value.Port);
await client.ConnectAsync(cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
return HealthCheckResult.Healthy("Email service is reachable");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Email service is unreachable", ex);
}
}
}Why health checks work better:
- Separation of concerns - Config validation at startup. Health checks run continuously.
- Orchestrator integration - Kubernetes speaks health check natively.
- Rich reporting - Get detailed status info and custom metadata.
- Non-blocking - Failed health checks report degraded state. They don't prevent startup.
- Ongoing monitoring - Run on a schedule to catch issues that develop after deployment.
Use startup validation for config that must be correct, and health checks for dependencies that might be temporarily down but shouldn't block startup. Think circuit breakers and retry policies.
Advanced Topics
The basic implementation above works well for most cases. The sections below cover scenarios I've run into in production where the default behavior wasn't quite enough.
What's covered:
-
Validating Earlier
- Force validation before
app.Run()for container health checks or early config access - Exception Handling Strategies - Custom error handling when validation fails, including wrapper patterns
-
Alternative Approaches
- Different ways to integrate validation (wrapping
app.Run(), extension methods, etc.)
When this matters:
-
You need validated config values during initialization (before
app.Run()) - You want custom error messages or structured logging for validation failures
- You're running in containers and need precise control over startup failure behavior
- The default validation behavior doesn't fit your deployment model
Validating Earlier (Before Host Start)
Sometimes you want validation to happen even earlier. For instance, if your orchestrator checks health before the app runs, or if you need validated config during initialization. Here's how I've been actually using IStartupValidator
in production:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptionsWithValidation<EmailOptions>();
var app = builder.Build();
// Force validation right now
var validator = app.Services.GetService<IStartupValidator>();
if (validator is not null)
{
validator.Validate(); // Throws if config is invalid
}
app.Run();When you'd want this:
- Container health checks - Orchestrator pings before your app runs
-
Initialization dependencies - You need config before
Run()executes - Fast feedback - See errors immediately in logs
Trade-offs to consider:
Validation runs twice. Once manually, once during StartAsync()
. Usually negligible. But if validation relies on hosted services, the early check might fail incorrectly.
Exception Handling Strategies
Default behavior:
The .NET Host already catches validation exceptions during app.Run()
and logs them with ILogger
. If an IStartupValidator
throws during
Host.StartAsync()
, the host catches the exception, logs it via HostedServiceStartupFaulted
, and rethrows it to abort startup. For many applications, this default behavior is perfectly fine.
When you need custom handling:
The patterns below are only necessary if you need to:
-
Call validation before
app.Run()(for early config access) - Customize error message formatting or structure
- Implement custom exit codes for container orchestrators
- Add structured logging or observability hooks
- Handle validation failures programmatically
If the default logging and error handling work for your needs, you can skip this section entirely.
When you call validation manually (either before or during app.Run()
), you may want to customize error handling. The following subsections show increasingly sophisticated approaches:
-
The Problem
- Understanding
IStartupValidator's inconsistent exception behavior - Basic Approach - Catch both exception types manually
- Improved Approach - Normalize exceptions into a consistent list
- Recommended Approach - Create a custom exception wrapper with extension methods (most production-ready)
-
Alternative
- Wrap
app.Run()instead of manual validator calls - Anti-Pattern - What not to do (building ServiceProvider twice)
Quick recommendation: Skip to the Recommended Approach if you want the cleanest solution. The earlier sections explain why it's needed.
The Exception Inconsistency Problem
Here's something odd about IStartupValidator
: it throws different exception types depending on how many validations fail.
Look at the StartupValidator source code :
-
One validation failure
: Throws a single
OptionsValidationException -
Multiple validation failures
: Throws an
AggregateExceptioncontainingOptionsValidationExceptioninstances
This inconsistency makes proper error handling awkward. You can't just catch OptionsValidationException
because sometimes you'll get an AggregateException
instead. And neither is a specialized exception type indicating startup validation failure specifically.
Example of the problem:
If you're calling validator.Validate()
manually, this matters:
var validator = app.Services.GetService<IStartupValidator>();
if (validator is not null)
{
try
{
validator.Validate();
}
catch (OptionsValidationException ex)
{
// Only catches single failures
// Multiple failures throw AggregateException and escape this handler
Console.WriteLine($"Validation failed: {ex.Message}");
}
}Basic Approach: Handling Both Exception Types
Here's how to handle both cases properly:
var validator = app.Services.GetService<IStartupValidator>();
if (validator is not null)
{
try
{
validator.Validate();
}
catch (AggregateException aggEx) when (aggEx.InnerExceptions.All(e => e is OptionsValidationException))
{
// Multiple validation failures
foreach (var ex in aggEx.InnerExceptions.Cast<OptionsValidationException>())
{
Console.WriteLine($"[{ex.OptionsName}] {ex.Message}");
foreach (var failure in ex.Failures)
{
Console.WriteLine($" - {failure}");
}
}
throw; // Re-throw to prevent startup
}
catch (OptionsValidationException ex)
{
// Single validation failure
Console.WriteLine($"[{ex.OptionsName}] {ex.Message}");
foreach (var failure in ex.Failures)
{
Console.WriteLine($" - {failure}");
}
throw; // Re-throw to prevent startup
}
}
Note
: The order of catch blocks matters. Put AggregateException
first, otherwise C# will never reach that handler.
Improved Approach: Normalizing Exceptions
If you want cleaner error handling, normalize the exceptions yourself:
var validator = app.Services.GetService<IStartupValidator>();
if (validator is not null)
{
var failures = new List<OptionsValidationException>();
try
{
validator.Validate();
}
catch (AggregateException aggEx) when (aggEx.InnerExceptions.All(e => e is OptionsValidationException))
{
failures.AddRange(aggEx.InnerExceptions.Cast<OptionsValidationException>());
}
catch (OptionsValidationException ex)
{
failures.Add(ex);
}
if (failures.Any())
{
// Now handle all failures uniformly
Console.WriteLine($"Configuration validation failed with {failures.Count} error(s):");
foreach (var ex in failures)
{
Console.WriteLine($"\
[{ex.OptionsName}]");
foreach (var failure in ex.Failures)
{
Console.WriteLine($" - {failure}");
}
}
// Exit with error code for container orchestrators
Environment.Exit(1);
}
}This approach gives you consistent handling where all failures go into the same list, better control over error message formatting, proper exit codes that Kubernetes recognizes as failures, and a single place for error formatting logic.
Recommended Approach: Custom Exception Wrapper
Instead of handling both exception types everywhere you call validator.Validate()
, create a custom exception and extension method that normalizes the behavior:
public class StartupValidationException : Exception
{
public IReadOnlyList<OptionsValidationException> ValidationFailures { get; }
public StartupValidationException(IEnumerable<OptionsValidationException> failures)
: base($"Startup validation failed with {failures.Count()} error(s)")
{
ValidationFailures = failures.ToList();
}
public StartupValidationException(OptionsValidationException failure)
: base("Startup validation failed")
{
ValidationFailures = new[] { failure };
}
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine(Message);
foreach (var failure in ValidationFailures)
{
sb.AppendLine();
sb.AppendLine($"[{failure.OptionsName}] {failure.OptionsType.Name}");
foreach (var error in failure.Failures)
{
sb.AppendLine($" - {error}");
}
}
return sb.ToString();
}
}Now create an extension method that wraps the validation logic:
public static class StartupValidatorExtensions
{
public static void ValidateAndThrow(this IStartupValidator validator)
{
try
{
validator.Validate();
}
catch (AggregateException aggEx) when (aggEx.InnerExceptions.All(e => e is OptionsValidationException))
{
var failures = aggEx.InnerExceptions.Cast<OptionsValidationException>();
throw new StartupValidationException(failures);
}
catch (OptionsValidationException ex)
{
throw new StartupValidationException(ex);
}
}
}Now your startup code becomes much cleaner:
var app = builder.Build();
var validator = app.Services.GetService<IStartupValidator>();
if (validator is not null)
{
try
{
validator.ValidateAndThrow(); // Always throws StartupValidationException
}
catch (StartupValidationException ex)
{
// Consistent exception type regardless of failure count
Console.WriteLine(ex.ToString());
// Or handle individual failures
foreach (var failure in ex.ValidationFailures)
{
Console.WriteLine($"[{failure.OptionsName}]");
foreach (var error in failure.Failures)
{
Console.WriteLine($" - {error}");
}
}
Environment.Exit(1);
}
}
app.Run();
This pattern simplifies exception handling by always throwing StartupValidationException
instead of the inconsistent OptionsValidationException
vs AggregateException
behavior. The semantic naming makes it clear you're dealing with startup validation failures, the quirky behavior is encapsulated in one place, and you get access to all validation failures through a consistent interface with nicely formatted stack traces.
You could also add additional methods for different use cases:
public static class StartupValidatorExtensions
{
public static void ValidateAndThrow(this IStartupValidator validator)
{
// Throws StartupValidationException on failure
var failures = validator.ValidateAndCollect();
if (failures.Any())
{
throw new StartupValidationException(failures);
}
}
public static IReadOnlyList<OptionsValidationException> ValidateAndCollect(this IStartupValidator validator)
{
// Returns failures without throwing
try
{
validator.Validate();
return Array.Empty<OptionsValidationException>();
}
catch (AggregateException aggEx) when (aggEx.InnerExceptions.All(e => e is OptionsValidationException))
{
return aggEx.InnerExceptions.Cast<OptionsValidationException>().ToList();
}
catch (OptionsValidationException ex)
{
return new[] { ex };
}
}
public static bool TryValidate(this IStartupValidator validator,
out IReadOnlyList<OptionsValidationException> failures)
{
// Try pattern for conditional logic
failures = validator.ValidateAndCollect();
return failures.Count == 0;
}
}With these helpers, you can choose the pattern that fits your needs:
// Throw immediately
validator.ValidateAndThrow();
// Collect and process
var failures = validator.ValidateAndCollect();
if (failures.Any())
{
logger.LogCritical("Validation failed: {Failures}", failures);
Environment.Exit(1);
}
// Try pattern
if (!validator.TryValidate(out var failures))
{
// Handle failures
}Alternative: Wrapping app.Run()
If you prefer to let the framework handle validation naturally during app.Run()
instead of calling the validator manually, you can wrap app.Run()
to catch and normalize the exceptions:
var app = builder.Build();
try
{
app.Run();
}
catch (AggregateException aggEx) when (aggEx.InnerExceptions.All(e => e is OptionsValidationException))
{
var failures = aggEx.InnerExceptions.Cast<OptionsValidationException>();
var validationException = new StartupValidationException(failures);
Console.WriteLine(validationException.ToString());
Environment.Exit(1);
}
catch (OptionsValidationException ex)
{
var validationException = new StartupValidationException(ex);
Console.WriteLine(validationException.ToString());
Environment.Exit(1);
}This approach has a few advantages:
-
Less invasive
- Doesn't require manually retrieving and calling
IStartupValidator - Framework-aligned - Lets ASP.NET Core handle validation at the natural point in startup
- Consistent timing - Validation runs exactly when Microsoft designed it to run
- Simpler code - No need to worry about validation running twice
You could also create an extension method for this pattern:
public static class WebApplicationExtensions
{
public static void RunWithValidationHandling(this WebApplication app)
{
try
{
app.Run();
}
catch (AggregateException aggEx) when (aggEx.InnerExceptions.All(e => e is OptionsValidationException))
{
var failures = aggEx.InnerExceptions.Cast<OptionsValidationException>();
HandleStartupValidationFailure(new StartupValidationException(failures));
}
catch (OptionsValidationException ex)
{
HandleStartupValidationFailure(new StartupValidationException(ex));
}
}
private static void HandleStartupValidationFailure(StartupValidationException exception)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine("═══════════════════════════════════════════════");
Console.Error.WriteLine(" STARTUP CONFIGURATION VALIDATION FAILED");
Console.Error.WriteLine("═══════════════════════════════════════════════");
Console.ResetColor();
Console.Error.WriteLine();
Console.Error.WriteLine(exception.ToString());
Console.Error.WriteLine();
Console.Error.WriteLine("Application cannot start with invalid configuration.");
Environment.Exit(1);
}
}Now your startup code becomes:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptionsWithValidation<EmailOptions>();
builder.Services.AddOptionsWithValidation<DatabaseOptions>();
var app = builder.Build();
app.MapHealthChecks("/health");
app.RunWithValidationHandling(); // Handles startup validation exceptions gracefullyWhen to use which approach:
- Wrap app.Run() if you want validation to happen at the standard point and don't need access to validated config before starting the host
- Manual validator call if you need validated config values during initialization, want faster feedback in container health checks, or need to perform logic based on validation results before starting
Both approaches work equally well. The wrap-app.Run()
pattern is simpler and less invasive, while the manual validation pattern gives you more control over timing and error handling.
Anti-Pattern: Building ServiceProvider Twice
Some folks try building a temporary ServiceProvider
before calling builder.Build()
. Don't do this:
// ⚠️ DON'T DO THIS
var tempSp = builder.Services.BuildServiceProvider();
var validator = tempSp.GetService<IStartupValidator>();
validator?.Validate();
var app = builder.Build(); // Builds service provider again
app.Run();Why this breaks things:
- Double initialization - Singletons get created twice
- Wasted resources - Building service providers is expensive
- Inconsistent behavior - Services might act differently between providers
-
Framework warnings - ASP.NET Core explicitly discourages calling
BuildServiceProvider()directly
Better approach
: Validate manually after app.Build()
if you need earlier validation.
Wrapping Up
Combining FluentValidation with ValidateOnStart()
moves config validation from runtime surprises to startup failures. This is especially useful in containerized environments where fast failures enable automated recovery, but it works just as well in traditional deployments where clear early errors beat mysterious runtime failures.
That said, strict validation isn't universal. Think through whether it makes sense for your specific application architecture and deployment patterns.