Validate strongly typed options when using config sections

Validate strongly typed options when using config sections. Validate strongly typed options when using config sections.

I like to validate my application configuration upon startup. Especially when doing local development, I want to know which application settings are missing. I also like to know where I should add them. This blog shows how to implement validation of your configuration classes using data annotations.

Use data annotations

The configuration classes were completely rewritten when .NET Core was introduced. We came a long way since the old .config files.

I love how we can now use the options pattern to bind our settings classes. Strongly typed instances are injected as IOptions<T> or IOptionsSnapShot<T> into our services.

Let's define a settings class and use data annotations to make the fields required:

using System.ComponentModel.DataAnnotations;

public class KafkaOptions
{
    [Required]
    public string BootstrapServers { get; set; }

    [Required]
    public string GroupId { get; set; }
}

In ASP.NET we already use data annotation to validate our models. Some better known attributes are: Required, MinLength, MaxLength, Range, StringLength, EmailAddress, Url and RegularExpression.

What does .NET Core provide?

Microsoft introduced a validation method that can validate the options (since .NET Core 2.2). You can use it with data annotations and it can look something like this:

services.AddOptions();
services
    .AddOptions<KafkaOptions>()
    .Bind(Configuration.GetSection("Kafka"))
    .Validate(option =>

        // return a bool result:
        Validator.TryValidateObject(
            option, 
            new ValidationContext(option),
            new List<ValidationResult>(), 
            validateAllProperties: true),

        // generic error message:
        "Kafka section invalid"
    );

This is what happens when you try to read the Value of the IOptions<T> object:

An error is throw inside the code that tries to use the provided option. It is validated when the valid is accessed.

.NET throws an error upon usage and the error message does not provide many clues on what to do or where to look.

If you, like me, are looking for eager validation on startup, you must wait until the next version:

Eager validation (fail fast at startup) is under consideration for a future release.

Source: Options pattern in ASP.NET Core - Options Validation

So, we need to code something our selves.

PostConfigure to the rescue!

Let's use PostConfigure to validate the created option and throw an error if something is invalid:

services.AddOptions();
services
    .AddOptions<KafkaOptions>()
    .Bind(Configuration.GetSection("Kafka"))
    .PostConfigure(x =>
    {
        var validationResults = new List<ValidationResult>();
        var context = new ValidationContext(x);
        var valid = Validator.TryValidateObject(x, context, validationResults);
        if (valid) return;

        var msg = String.Join("\n", validationResults.Select(r => r.ErrorMessage));
        throw new Exception($"Invalid configuration of section 'Kafka':\n{msg}");
    });

Now, when we run the code, it breaks in (and during) Startup.cs. This is eager validation, and it provides us with an exception that helps to solve the configuration-error.

The application now breaks in the right place and with a useful error message.

Extension methods

Your startup can get quite verbose if you have to configure many options in your application. That's why I've created the following extension methods to help set up the configuration and validation of options:

public static class OptionExtensions
{
    private static void ValidateByDataAnnotation(object instance, string sectionName)
    {
        var validationResults = new List<ValidationResult>();
        var context = new ValidationContext(instance);
        var valid = Validator.TryValidateObject(instance, context, validationResults);
        if (valid) return;

        var msg = string.Join("\n", validationResults.Select(r => r.ErrorMessage));
        throw new Exception($"Invalid configuration of section '{sectionName}':\n{msg}");
    }

    public static OptionsBuilder<TOptions> ValidateByDataAnnotation<TOptions>(
        this OptionsBuilder<TOptions> builder,
        string sectionName) 
        where TOptions : class
    {
        return builder.PostConfigure(x => ValidateByDataAnnotation(x, sectionName));
    }

    public static IServiceCollection ConfigureAndValidate<TOptions>(
        this IServiceCollection services,
        string sectionName,
        IConfiguration configuration)
        where TOptions : class
    {
        var section = configuration.GetSection(sectionName);

        services
            .AddOptions<TOptions>()
            .Bind(section)
            .ValidateByDataAnnotation(sectionName);

        return services;
    }
}

Coming up with a good name for an extension method is usually the hardest part. Normally, I set up options using services.Configure<KafkaOptions>(Configuration.GetSection("Kafka")), so ConfigureAndValidate<KafkaOptions>("Kafka", Configuration) looks good to me.

Example application

Let's look at an example application. I like to use the appsettings.json to define the configuration of my services. This is the Program.cs:

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

public class Program
{
    public static void Main(string[] args)
    {
        new WebHostBuilder()
            .UseKestrel(options => options.AddServerHeader = false)
            .ConfigureAppConfiguration((builderContext, config) =>
            {
                var env = builderContext.HostingEnvironment.EnvironmentName;

                config.AddJsonFile("appsettings.json", false, true);
                config.AddJsonFile($"appsettings.{env}.json", true);
                config.AddEnvironmentVariables();
            })
            .UseStartup<Startup>()
            .Build()
            .Run();
    }
}

This setup allows us to use environment-specific configuration files. Next, we'll set up the loading of the configuration information into an object in the startup.cs:

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddOptions();
        services.ConfigureAndValidate<KafkaOptions>("Kafka", Configuration);
        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseMvc();
    }
}

Note: the configuration is injected in the startup class.

Final thoughts

Because we've used config.AddEnvironmentVariables() in our program, the setting values could also come from the environment. Technically, we can add a Kafka__GroupId to satisfy the requirement.

Configuration can come from a myriad of places, not just from the config files. Pointing your fellow developers into the right direction can save a lot of time.

  1. DisqusTech says:

    Great code!

    I am listing what I ~think are the needed nuget packages. (This was a 45 minute exercise trying to find them).

    2.1

    3.1’ish

    Note, you should look nuget for the latest 2.x or 3.x versions.

    Remember, 2.1 has LTS (long term support) and so does 3.1. 2.2 does not.

expand_less