blog post image
Andrew Lock avatar

Andrew Lock

~7 min read

Adding validation to strongly typed configuration objects using FluentValidation

In my previous post I described how you could use DataAnnotation attributes and the new ValidateOnStart() method to validate your strongly-typed configuration on app startup.

In this post, I show how to do the same thing using the popular open-source validation library FluentValidation. There's nothing built-in to FluentValidation to enable this, but it only takes a couple of helper classes to enable it. In this post I'll show you how.

A quick reminder that my new book, ASP.NET Core in Action, Third Edition, was released last week in MEAP. Use the discount code mllock3 to get 40% off until October 13th (only a couple of days at time of publish!).

My previous post included background about strongly-typed configuration in general, and all the ways it can fail, so if it's new to you, I suggest reading that post first. In this post I'll give a quick recap on how IOptions validation works with DataAnnotation attributes, before showing how to achieve something similar with FluentValidation.

Validating IOptions values on startup

.NET introduced validation for IOptions values back in .NET Core 2.2, with Validate<> and ValidateDataAnnotations() methods, but they didn't execute on startup, only at the point you request the IOptions instance from the container. In .NET 6, a new method was added, ValidateOnStart() which runs the validation functions immediately when the app starts up!

To use the validation features you must do four things:

  • Register your IOptions<T> using services.AddOptions<T>().BindConfiguration())
  • Add validation attributes to your settings object T
  • Call ValidateDataAnnotations() on the OptionsBuilder returned from AddOptions<T>()
  • Call ValidateOnStart() on the OptionsBuilder

In the following example, I configure options validation for a SlackApiSettings object:

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<SlackApiSettings>()
    .BindConfiguration("SlackApi") // πŸ‘ˆ Bind the SlackApi section in config
    .ValidateDataAnnotations() // πŸ‘ˆ Enable validation
    .ValidateOnStart(); // πŸ‘ˆ Validate on app start

var app = builder.Build();

app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);

app.Run();

public class SlackApiSettings
{
    [Required, Url]
    public string WebhookUrl { get; set; }
    [Required]
    public string DisplayName { get; set; }
    public bool ShouldNotify { get; set; }
}

We'll now create a faulty configuration, for example removing the required DisplayName value:

{
  "SlackApi": {
    "WebhookUrl": "http://example.com/test/url",
    "DisplayName": null,
    "ShouldNotify": true
  }
}

If you run the app, you now get an exception when the app starts:

Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: 
  DataAnnotation validation failed for 'SlackApiSettings' members: 
    'DisplayName' with the error: 'The DisplayName field is required.'.
   at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
   at Microsoft.Extensions.Options.OptionsMonitor`1.<>c__DisplayClass10_0.<Get>b__0()

Now if there's a configuration exception, you'll know about it as soon as possible, on app startup instead of only at runtime when you try to use the configuration.

Validating IOptions using FluentValidation

DataAnnotation attributes are a very demo-able validation framework, as they are built into .NET, but they often fall down for more complex scenarios. A popular open-source library alternative is FluentValidation.

This post isn't going to be a primer on FluentValidation, so I'm just going to focus on the minimum required to implement startup validation.

1. Creating the test project

We'll start by creating a simple minimal API app for testing, and add the FluentValidation package:

dotnet new web
dotnet add package FluentValidation

We'll then replace Program.cs with a simple API that uses a strongly typed configuration object (SlackApiSettings) and "echoes" its value in an API:

using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<SlackApiSettings>()
    .BindConfiguration("SlackApi") // Bind to the SlackApi section in configuration

var app = builder.Build();

app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value); // echo the SlackApiSettings object

app.Run();

public class SlackApiSettings
{
    public string? WebhookUrl { get; set; }
    public string? DisplayName { get; set; }
    public bool ShouldNotify { get; set; }
}

In this simple app, we bind the SlackApiSettings object to the SlackApi section in configuration. The single API then echoes the bound values as JSON.

{"webhookUrl":null,"displayName":null,"shouldNotify":false}

It's now time to add some validators.

2. Adding a FluentValidation validator

You can read the documentation for details on how to create validators and rules with FluentValidation. In the example below, I create a validator that derives from AbstractValidator<T>, and has the same validation rules as the DataAnnotation version from the start of this post.

public class SlackApiSettingsValidator : AbstractValidator<SlackApiSettings>
{
    public SlackApiSettingsValidator()
    {
        RuleFor(x => x.DisplayName)
            .NotEmpty(); // not nul
        RuleFor(x => x.WebhookUrl)
            .NotEmpty()
            // .MustAsync((_, _) => Task.FromResult(true)) πŸ‘ˆ can't use async validators
            .Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _))
            .When(x => !string.IsNullOrEmpty(x.WebhookUrl));
    }
}

There's an important point to note here: you can't use any async validation. This probably won't be a big problem for validating IOptions values, but it's something to bear in mind. This is do to the fact that the IValidateOptions<T> interface we're going to use later is synchronous only.

3. Creating a ValidateFluentValidation extension method

This next step is the crucial one; we need to add the FluentValidation-equivalent of the ValidateDataAnnotations() extension method, which I called ValidateFluentValidation(). This extension itself is relatively simple, and follows the design of the DataAnnotation version:

public static class OptionsBuilderFluentValidationExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(
      this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
    {
        optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
            provider => new FluentValidationOptions<TOptions>(
              optionsBuilder.Name, provider));
        return optionsBuilder;
    }
}

This extension method on OptionsBuilder<T> adds a new service to the DI container, FluentValidationOptions<T>, and registers it as an IValidateOptions<T>. FluentValidationOptions<T> is where all the magic happens so we'll look at it now: There's a fair amount of code here, so I've commented it extensively:

public class FluentValidationOptions<TOptions> 
    : IValidateOptions<TOptions> where TOptions : class
{
    private readonly IServiceProvider _serviceProvider;
    private readonly string? _name;
    public FluentValidationOptions(string? name, IServiceProvider serviceProvider)
    {
        // we need the service provider to create a scope later
        _serviceProvider = serviceProvider; 
        _name = name; // Handle named options
    }

    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        // Null name is used to configure all named options.
        if (_name != null && _name != name)
        {
            // Ignored if not validating this instance.
            return ValidateOptionsResult.Skip;
        }

        // Ensure options are provided to validate against
        ArgumentNullException.ThrowIfNull(options);
        
        // Validators are typically registered as scoped,
        // so we need to create a scope to be safe, as this
        // method is be called from the root scope
        using IServiceScope scope = _serviceProvider.CreateScope();

        // retrieve an instance of the validator
        var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();

        // Run the validation
        ValidationResult results = validator.Validate(options);
        if (results.IsValid)
        {
            // All good!
            return ValidateOptionsResult.Success;
        }

        // Validation failed, so build the error message
        string typeName = options.GetType().Name;
        var errors = new List<string>();
        foreach (var result in results.Errors)
        {
            errors.Add($"Fluent validation failed for '{typeName}.{result.PropertyName}' with the error: '{result.ErrorMessage}'.");
        }

        return ValidateOptionsResult.Fail(errors);
    }
}

The above code is made more complex by two features of the IValidateOptions interface:

  • IOptions<T> supports named options. Named options don't tend to be used often; they're most commonly used in authentication, for example. You can read more about them in my post here.
  • IValidateOptions is executed by IOptionsMonitor, which is registered as a singleton. Therefore our FluentValidationOptions object must also be registered as a singleton. However, it's common for FluentValidation validators to be registered as scoped. That mismatch means that we can't inject an IValidator<T> into the FluentValidationOptions constructor, and instead have to create an IServiceScope first.

Apart from the two caveats above, the code is pretty simple. Run validator.Validate(), and return an appropriate response.

Note: This code requires that you have registered an IValidator<T> for the type in DI

We now have all the pieces we need to configure validation on startup.

Putting it all together

If we combine all the above steps, and register our validator in DI, then the final app looks something like this:

using FluentValidation;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// πŸ‘‡ Register the validator
builder.Services.AddScoped<IValidator<SlackApiSettings>, SlackApiSettingsValidator>();

builder.Services.AddOptions<SlackApiSettings>()
    .BindConfiguration("SlackApi") // πŸ‘ˆ Bind the SlackApi section in config
    .ValidateFluentValidation() // πŸ‘ˆ Enable validation
    .ValidateOnStart(); // πŸ‘ˆ Validate on app start

var app = builder.Build();

app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);

app.Run();

public class SlackApiSettings
{
    public string? WebhookUrl { get; set; }
    public string? DisplayName { get; set; }
    public bool ShouldNotify { get; set; }
}

public class SlackApiSettingsValidator : AbstractValidator<SlackApiSettings>
{
    public SlackApiSettingsValidator()
    {
        RuleFor(x => x.DisplayName).NotEmpty();
        RuleFor(x => x.WebhookUrl)
            .NotEmpty()
            .Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _))
            .When(x => !string.IsNullOrEmpty(x.WebhookUrl));
    }
}

public static class OptionsBuilderFluentValidationExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
    {
        optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
            provider => new FluentValidationOptions<TOptions>(optionsBuilder.Name, provider));
        return optionsBuilder;
    }
}

public class FluentValidationOptions<TOptions> : IValidateOptions<TOptions> where TOptions : class
{
    private readonly IServiceProvider _serviceProvider;
    private readonly string? _name;

    public FluentValidationOptions(string? name, IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _name = name;
    }

    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        // Null name is used to configure all named options.
        if (_name != null && _name != name)
        {
            // Ignored if not validating this instance.
            return ValidateOptionsResult.Skip;
        }

        // Ensure options are provided to validate against
        ArgumentNullException.ThrowIfNull(options);
        
        // Validators are registered as scoped, so need to create a scope,
        // as we will be called from the root scope
        using var scope = _serviceProvider.CreateScope();
        var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();
        var results = validator.Validate(options);
        if (results.IsValid)
        {
            return ValidateOptionsResult.Success;
        }

        string typeName = options.GetType().Name;
        var errors = new List<string>();
        foreach (var result in results.Errors)
        {
            errors.Add($"Fluent validation failed for '{typeName}.{result.PropertyName}' with the error: '{result.ErrorMessage}'.");
        }

        return ValidateOptionsResult.Fail(errors);
    }
}

Finally, if you run the application with the faulty configuration, you'll get an exception on startup, exactly as we want:

Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: Fluent validation failed for 'SlackApiSettings.DisplayName' with the error: ''Display Name' must not be empty.'.
   at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)

Bundling it all in an extension

Currently, you have to remember to add a validator for your settings, enable validation for your option object, and enable validation on start up. If you prefer, instead, you can add an extension method to do all that for you:

public static class FluentValidationOptionsExtensions
{
    public static OptionsBuilder<TOptions> AddWithValidation<TOptions, TValidator>(
        this IServiceCollection services,
        string configurationSection)
    where TOptions : class
    where TValidator : class, IValidator<TOptions>
    {
        // Add the validator
        services.AddScoped<IValidator<TOptions>, TValidator>();

        return services.AddOptions<TOptions>()
            .BindConfiguration(configurationSection)
            .ValidateFluentValidation()
            .ValidateOnStart();
    }
}

Then your application setup becomes as simple as:

using FluentValidation;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// πŸ‘‡ Register the validator + options
builder.Services.AddWithValidation<SlackApiSettings, SlackApiSettingsValidator>("SlackApi")

var app = builder.Build();

app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);

app.Run();

And that's it, I hope you find this useful if you're using FluentValidation with ASP.NET Core!

Summary

In this post I showed how you can use FluentValidation to validate your strong-typed IOptions<> types in ASP.NET Core. I created a FluentValidation version of ValidateDataAnnotations() as an extension method called ValidateFluentValidation(). When combined with ValidateOnStart() (and a registered IValidator<T>), you get validation of your settings when your app starts up. This ensures you learn about configuration errors as soon as possible, instead of at runtime.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?