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>
usingservices.AddOptions<T>().BindConfiguration())
- Add validation attributes to your settings object
T
- Call
ValidateDataAnnotations()
on theOptionsBuilder
returned fromAddOptions<T>()
- Call
ValidateOnStart()
on theOptionsBuilder
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 byIOptionsMonitor
, which is registered as a singleton. Therefore ourFluentValidationOptions
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 anIValidator<T>
into theFluentValidationOptions
constructor, and instead have to create anIServiceScope
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.