Skip to main content
SEMastery
ASP.NETintermediate

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.

12 min readUpdated November 20, 2025

A guard at the front gate

Think about a wedding hall on a busy Saturday. At the front gate stands one guard with a checklist. Before anyone walks in, the guard checks a few things. Does this guest have an invitation card? Is the card for today, not last week? Is the name written clearly? If something is wrong, the guard stops the guest right there at the gate. It would be a disaster to let everyone in first and only discover the problem when food runs out in the middle of the function.

Your ASP.NET Core app has its own front gate. When it starts, it reads settings from appsettings.json: a database link, an email server, a timeout, a payment key. If one of those is missing or silly (a negative timeout, a blank key), it is far better to stop at the gate than to let a real user hit the broken part hours later.

The options pattern is how ASP.NET Core reads those settings into clean C# classes. FluentValidation is a friendly library that lets you write the guard's checklist in plain, readable rules. In this article you will learn how to join the two, so a bad setting stops your app at the gate with a clear message.

Let us build it step by step.

A quick refresher on the options pattern

The options pattern binds a section of your configuration to a strongly typed class. Instead of reading settings by string keys all over your code, you bind once and inject the class.

Here is a small settings class for an email feature.

public sealed 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; }
}

And the matching appsettings.json section.

{
  "Email": {
    "Host": "smtp.mailserver.com",
    "Port": 587,
    "FromAddress": "[email protected]",
    "TimeoutSeconds": 30
  }
}

You register it in Program.cs and then inject IOptions<EmailOptions> wherever you need it. That part is normal options pattern work. The new part is adding a checklist.

How configuration flows into a typed options class your code can use.

Why FluentValidation here

ASP.NET Core already ships with ValidateDataAnnotations(). You put [Required] or [Range] on the properties and it checks them. That is perfect for simple rules.

But settings get tricky. Sometimes a rule depends on two fields. Sometimes a rule only applies when a flag is on. Sometimes you want one set of rules reused in many classes. FluentValidation handles all of that with rules that read almost like English.

NeedData annotationsFluentValidation
Single field requiredEasy with [Required]Easy with NotEmpty()
Compare two fieldsHard, needs custom codeEasy, built in
Conditional ruleAwkwardEasy with When()
Reuse rules across classesLimitedStrong, validators are classes
Inject other servicesNot supportedSupported via constructor
Rules live separatelyNo, mixed with the modelYes, in their own class

The key idea: your EmailOptions class stays clean. The rules live in a separate EmailOptionsValidator class. This is the separation of concerns principle, and it makes both pieces easier to read and test.

Step 1: install FluentValidation

Add the package to your web project.

// In a terminal, from the project folder:
// dotnet add package FluentValidation

FluentValidation is open source and free under the Apache 2.0 license. That is worth saying clearly, because some popular .NET libraries (such as MediatR and MassTransit) have moved their newer versions to a commercial license. FluentValidation has not. You can use it without paying.

Step 2: write the validator

A FluentValidation validator is just a class that inherits from AbstractValidator<T>. Inside the constructor, you list your rules.

using FluentValidation;
 
public sealed class EmailOptionsValidator : AbstractValidator<EmailOptions>
{
    public EmailOptionsValidator()
    {
        RuleFor(x => x.Host)
            .NotEmpty()
            .WithMessage("Email Host must be set.");
 
        RuleFor(x => x.Port)
            .InclusiveBetween(1, 65535)
            .WithMessage("Email Port must be a valid TCP port.");
 
        RuleFor(x => x.FromAddress)
            .NotEmpty()
            .EmailAddress()
            .WithMessage("FromAddress must be a real email address.");
 
        RuleFor(x => x.TimeoutSeconds)
            .GreaterThan(0)
            .LessThanOrEqualTo(300)
            .WithMessage("TimeoutSeconds must be between 1 and 300.");
    }
}

Read those rules out loud. "Host must not be empty." "Port must be between 1 and 65535." They are easy for a new team member to follow, and easy to change later.

Step 3: bridge FluentValidation to IValidateOptions

ASP.NET Core does not know about FluentValidation by itself. The framework only knows about one interface: IValidateOptions<T>. So we write a tiny bridge class. Its job is simple. When the framework asks "are these options valid?", our bridge runs the FluentValidation validator and translates the answer into the format the framework expects.

using FluentValidation;
using Microsoft.Extensions.Options;
 
public sealed class FluentValidateOptions<TOptions>
    : IValidateOptions<TOptions> where TOptions : class
{
    private readonly string? _name;
    private readonly IServiceProvider _serviceProvider;
 
    public FluentValidateOptions(IServiceProvider serviceProvider, string? name)
    {
        _serviceProvider = serviceProvider;
        _name = name;
    }
 
    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        // If a name is set, only validate the matching named options.
        if (_name is not null && _name != name)
        {
            return ValidateOptionsResult.Skip;
        }
 
        ArgumentNullException.ThrowIfNull(options);
 
        // Resolve the validator from a fresh scope so scoped services work.
        using var scope = _serviceProvider.CreateScope();
        var validator = scope.ServiceProvider
            .GetRequiredService<IValidator<TOptions>>();
 
        var result = validator.Validate(options);
        if (result.IsValid)
        {
            return ValidateOptionsResult.Success;
        }
 
        var errors = result.Errors
            .Select(e => $"Options validation failed for " +
                         $"'{e.PropertyName}': {e.ErrorMessage}");
 
        return ValidateOptionsResult.Fail(errors);
    }
}

Two small details matter here. We create a scope so the validator can use scoped services if it needs them. And we return ValidateOptionsResult.Skip when the name does not match, which keeps named options working correctly.

The bridge translates a FluentValidation result into the result ASP.NET Core understands.

Step 4: a friendly extension method

We do not want to wire up that bridge by hand every time. Let us add a small helper so registration reads nicely, just like the built-in ValidateDataAnnotations().

using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
 
public static class OptionsBuilderFluentValidationExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(
        this OptionsBuilder<TOptions> builder) where TOptions : class
    {
        builder.Services.AddSingleton<IValidateOptions<TOptions>>(
            provider => new FluentValidateOptions<TOptions>(
                provider, builder.Name));
 
        return builder;
    }
}

Now the hard work is hidden. Calling .ValidateFluentValidation() is all a teammate needs to remember.

Step 5: register everything in Program.cs

Here is where it all comes together. We register the validators from the assembly, then bind and validate the options.

using FluentValidation;
 
var builder = WebApplication.CreateBuilder(args);
 
// Register all validators found in this assembly.
builder.Services.AddValidatorsFromAssemblyContaining<EmailOptionsValidator>();
 
// Bind, validate with FluentValidation, and check at startup.
builder.Services
    .AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
    .ValidateFluentValidation()
    .ValidateOnStart();
 
var app = builder.Build();
 
app.MapGet("/", (IOptions<EmailOptions> options) =>
{
    var email = options.Value; // Safe: already validated.
    return Results.Ok(new { email.Host, email.Port });
});
 
app.Run();

The single most important line is .ValidateOnStart(). We will look at why next.

Why ValidateOnStart matters so much

By default, options are validated lazily. That means the check only runs the first time something asks for the value. If a setting is wrong, your app starts up looking healthy, and the error hides until a user happens to hit the code path that needs that setting. That could be hours after you deployed.

ValidateOnStart() moves the check to startup. A bad setting now crashes the app the moment it boots, with a clear message. A crash during deployment is loud and obvious. A crash during a customer's payment at 9pm is a bad night for everyone.

Lazy validation timeline

Deploy app
App looks healthy
User hits feature
Error surfaces late

Steps

1

Deploy app

Bad setting present

2

App looks healthy

No check yet

3

User hits feature

Options first read

4

Error surfaces late

Hours later, in prod

Without ValidateOnStart, the error hides until a request triggers it.

Startup validation timeline

Deploy app
Startup runs checks
Validation fails
App refuses to start

Steps

1

Deploy app

Bad setting present

2

Startup runs checks

All validators run

3

Validation fails

Clear message printed

4

App refuses to start

Caught at deploy time

With ValidateOnStart, the same bad setting stops the app at boot.

The difference is night and day. The second path catches the bug while you are watching the deployment logs, not when a real user is stuck.

Cross-field rules: where FluentValidation really helps

Now let us show something data annotations struggle with. Imagine a payment options class. If retries are turned on, then the retry count must be at least one. If retries are off, the count does not matter.

public sealed class PaymentOptions
{
    public const string SectionName = "Payment";
 
    public string ApiKey { get; set; } = string.Empty;
    public bool EnableRetries { get; set; }
    public int RetryCount { get; set; }
    public int TimeoutSeconds { get; set; }
}
 
public sealed class PaymentOptionsValidator
    : AbstractValidator<PaymentOptions>
{
    public PaymentOptionsValidator()
    {
        RuleFor(x => x.ApiKey)
            .NotEmpty()
            .MinimumLength(20)
            .WithMessage("Payment ApiKey looks too short.");
 
        // Conditional rule: only check RetryCount when retries are on.
        RuleFor(x => x.RetryCount)
            .GreaterThan(0)
            .When(x => x.EnableRetries)
            .WithMessage("RetryCount must be at least 1 when retries are on.");
 
        RuleFor(x => x.TimeoutSeconds)
            .InclusiveBetween(1, 120);
    }
}

The When() clause is the star. It says "only apply this rule under these conditions." Writing that with attributes alone is painful. With FluentValidation it is one readable line.

Validators can use other services

Because validators are normal classes resolved from dependency injection, they can take other services in the constructor. For example, a validator might check a list of allowed regions that comes from another service.

Validation styleGood forResolved from DI?
ValidateDataAnnotations()Simple, single-field rulesNo
Inline Validate(o => ...)One quick ruleNo
IValidateOptions<T> by handCustom logic, full controlYes
FluentValidation bridgeRich rules plus reuse plus DIYes

This table is a handy guide. Reach for FluentValidation when rules grow up. Stay with data annotations when a single [Required] says everything.

The full picture

Here is how all the pieces connect, from your config file to a running, validated app.

End to end: config binds, the bridge runs FluentValidation, and ValidateOnStart gates the boot.

A simple mental model for the lifecycle

It helps to picture the app boot as a small state machine. The app stays in "checking" until every validator has spoken. Only then does it move to "running."

The app refuses to reach the running state until all option checks pass.

Testing your validators

One more gift from this design: the validator is a plain class, so you can test it without starting the whole app. This makes your checklist trustworthy.

using FluentValidation.TestHelper;
using Xunit;
 
public class PaymentOptionsValidatorTests
{
    private readonly PaymentOptionsValidator _validator = new();
 
    [Fact]
    public void RetryCount_Required_When_Retries_Enabled()
    {
        var options = new PaymentOptions
        {
            ApiKey = "a-very-long-secret-key-here",
            EnableRetries = true,
            RetryCount = 0,        // Wrong: retries on but count is zero.
            TimeoutSeconds = 30
        };
 
        var result = _validator.TestValidate(options);
 
        result.ShouldHaveValidationErrorFor(x => x.RetryCount);
    }
 
    [Fact]
    public void Valid_Options_Pass()
    {
        var options = new PaymentOptions
        {
            ApiKey = "a-very-long-secret-key-here",
            EnableRetries = false,
            RetryCount = 0,        // Fine: retries are off.
            TimeoutSeconds = 30
        };
 
        var result = _validator.TestValidate(options);
 
        result.ShouldNotHaveAnyValidationErrors();
    }
}

Because the rules live in their own testable class, you can prove your checklist works before you ever deploy. That is the real payoff of separation of concerns.

Common mistakes to avoid

A few small traps catch people. Watch out for these.

  • Forgetting .ValidateOnStart(). Without it, validation is lazy and your bug hides until runtime. Always add it.
  • Forgetting to register the validators with AddValidatorsFromAssemblyContaining. The bridge will then fail to resolve the validator.
  • Reading settings through IOptionsSnapshot inside the bridge during startup. Keep the bridge simple and let it work on the options object it is handed.
  • Putting secrets like real API keys in appsettings.json committed to git. Use user secrets in development and a secret store in production. Validation checks the shape of a setting, not whether it should be public.

Quick recap

  • The options pattern binds settings into a typed class. Validation is the guard at the gate that checks those settings.
  • ASP.NET Core only understands IValidateOptions<T>. We write a small bridge class that runs a FluentValidation validator and returns the framework's result.
  • A short extension method, ValidateFluentValidation(), makes registration read cleanly.
  • Always call ValidateOnStart(). It turns a hidden production bug into a loud, obvious failure at boot time.
  • FluentValidation shines for cross-field rules, conditional rules with When(), reuse, and rules that need dependency injection.
  • Validators are plain classes, so they are easy to unit test on their own.
  • FluentValidation is free and open source (Apache 2.0). Unlike newer MediatR and MassTransit, it has not moved to a commercial license.

References and further reading

Related Posts