blog post image
Andrew Lock avatar

Andrew Lock

~9 min read

Adding validation to strongly typed configuration objects in .NET 6

This post is a follow up to one I wrote 4 years ago about ensuring your strongly typed configuration objects bind correctly to your configuration when your app starts up. In my previous post, built around .NET Core 2.2, I used an IStartupFilter to validate that your configuration objects have expected values early, instead of at some point later on, when your app is already running. In this post, I show the current approach to validating strongly typed options, using the built-in functionality.

I start by giving some background on the configuration system in ASP.NET Core and how to use strongly typed settings. I'll briefly touch on how to remove the dependency on IOptions in your consuming types, and then look at the problem I'm going to address—when your strongly typed settings bind incorrectly. Finally, I describe ValidateOnStart() as a solution for the issue, so you can detect any problems at app startup.

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 (just over a week at time of publish!).

Strongly typed configuration in ASP.NET Core

The configuration system in ASP.NET Core is very flexible, allowing you to load configuration from a wide range of locations: JSON files, YAML files, environment variables, Azure Key Vault, and many others. The suggested approach to consuming the final IConfiguration object in your app is to use strongly typed configuration.

Strongly typed configuration uses POCO objects to represent a subset of your configuration, instead of the raw key-value pairs stored in the IConfiguration object. For example, maybe you're integrating with Slack, and are using Webhooks to send messages to a channel. You would need the URL for the webhook, and potentially other settings like the display name your app should use when posting to the channel:

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

You can bind this strongly typed settings object to your configuration in Program.cs using the Configure<T>() extension method. When you need to use the settings object in your app, you can inject an IOptions<SlackApiSettings> into the constructor. For example, to inject the settings into a minimal API endpoint and return the object as JSON, use:

using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// bind the configuration to the SlackApi section
// i.e. SlackApi:WebhookUrl and SlackApi:DisplayName 
builder.Services.Configure<SlackApiSettings>(
    builder.Configuration.GetSection("SlackApi")); 

var app = builder.Build();

//do something with _slackApiSettings, just return it in this example
app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);

app.Run();

Behind the scenes, the ASP.NET Core configuration system creates a new instance of the SlackApiSettings class, and attempts to bind each property to the configuration values contained in the IConfiguration section. To retrieve the settings object, you access IOptions<T>.Value, as shown in the endpoint handler.

Avoiding the IOptions dependency

Some people (myself included) don't like that your classes/endpoints are now dependent on IOptions rather than on your settings object. You can avoid the IOptions<T> dependency by binding the configuration object manually as described here, instead of using the Configure<T> extension method. A simpler (in my opinion) approach is to explicitly register the SlackApiSettings object in the container, and delegate its resolution to the IOptions object. For example:

using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// Register the IOptions object
builder.Services.Configure<SlackApiSettings>(
    builder.Configuration.GetSection("SlackApi")); 

// Explicitly register the settings object by delegating to the IOptions object
builder.Services.AddSingleton(resolver => 
        resolver.GetRequiredService<IOptions<SlackApiSettings>>().Value);

var app = builder.Build();

You can now inject the "raw" settings object into your handlers, without taking a dependency on the Microsoft.Extensions.Options package. I find this preferable as the IOptions<T> interface is largely just noise in this case.

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

app.Run();

This generally works very nicely, though there are a couple of caveats to it, for example:

  • In my example above you don't get "file reloading" for configuration as I used a Singleton lifetime (you could use Scoped if you need this feature),
  • It has an extra layer of indirection by going via the IOptions registration, instead of registering the SlackApiSettings object directly in DI. Personally I like this, as it means you can use IOptions if you want, but you can avoid this direction if you prefer with the approach in this post.

I'm a big fan of strongly typed settings, and having first-class support for loading configuration from a wide range of locations is nice. But what happens if you mess up our configuration? Maybe you have a typo in your JSON file, for example?

A more common scenario that I've run into is due to the need to store secrets outside of your source code repository. In these cases, I've expected a secret configuration value to be populated in a staging/production environment, but it wasn't set up correctly, so the value remains null. Configuration errors like this are tricky, as they're only really reproducible in the environment in which they occur!

In the next section, I'll show how these sorts of errors can manifest in your application.

What happens if binding fails?

There's several things that could go wrong when binding your strongly typed settings to configuration. In this section I show a few examples of the errors by looking at the JSON output from the example endpoint handler above, which just prints out the values stored in the SlackApiSettings object.

1. Typo in the section name

When you bind your configuration, you typically provide the name of the section to bind. If you think in terms of your appsettings.json file, the section is the key name for an object. "Logging" and "SlackApi" are sections in the following .json file:

{
 "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*",
  "SlackApi": {
    "WebhookUrl": "http://example.com/test/url",
    "DisplayName": "My fancy bot",
    "ShouldNotify": true
  }
}

In order to bind SlackApiSettings to the "SlackApi" section, you would call:

builder.Services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApi")); 

But what if there's a typo in the section name in your JSON file? Instead of SlackApi, it says SlackApiSettings for example:

builder.Services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApiSettings")); 

Hitting the endpoint gives:

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

All of the keys have their default values, but there were no errors. The binding happened, it just bound to an empty configuration section. That's probably bad news, as your code is no doubt expecting webhookUrl etc to be a valid Uri!

2. Typo in a property name

In a similar vein, what happens if the section name is correct, but the property name is wrong. For example, what if WebhookUrl appears as Url in the configuration file?

{
  "SlackApi": {
    "Url": "http://example.com/test/url",
    "DisplayName": "My fancy bot",
    "ShouldNotify": true
  }
}

Looking at the output of the endpoint handler:

{"webhookUrl":null,"displayName":"My fancy bot","shouldNotify":true}

As we have the correct section name, the DisplayName and ShouldNotify properties have have bound correctly to the configuration, but WebhookUrl is null due to using the wrong name (Url). Again, there's no indication from the binder that anything went wrong here.

3. Unbindable properties

The next issue is a bit rare, but is one I've hit now and again. If you use getter-only properties on your strongly typed settings object, they won't bind correctly. For example, if we update our settings object to use readonly properties:

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

and hit the endpoint again, we're back to default values, as the binder treats those properties as unbindable:

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

4. Incompatible type values

The final error in this post is what happens when the binder tries to bind a property with an incompatible type. The configuration is all stored as strings, but the binder can convert to simple types. For example, it will bind "true" or "FALSE" to the bool ShouldNotify property, but if you try to bind something else, "THE VALUE" for example, you'll get an exception when the endpoint is hit and the binder attempts to bind the IOptions<T> object:

FormatException: THE VALUE is not a valid value for Boolean.

Getting an error isn't ideal, but the fact the binder throws an exception that clearly indicates the problem is actually a good thing! Too many times I've been in a situation trying to figure out why some API call isn't working, only to discover that my connection string or base URL is empty, due to a binding error.

For configuration errors like this, it's preferable to fail as early as possible. Compile time is best, but app startup is a good second-best. What we need is validation.

Validating IOptions values

.NET introduced validation for IOptions values back in .NET Core 2.2, with Validate<> and ValidateDataAnnotations() methods. The downside of these methods was that they didn't execute on startup, only at the point you request the IOptions instance from the container. The result felt like a partial solution to me, and led me to build a NuGet package that would do the validation on startup.

Luckily, in .NET 6, a new method was added, ValidateOnStart() which does exactly what we need—it runs the validation functions immediately when the app starts up!

If you're interested in how it does this, the trick is to use an IHostedService to do the validation for you. You can see the implementation in this PR

To use the validation features we need to do four things:

  • Switch to using services.AddOptions<T>().Bind() instead of services.Configure<T>()
  • Add validation attributes to our settings object
  • Call ValidateDataAnnotations() on the OptionsBuilder returned from AddOptions<T>()
  • Call ValidateOnStart() on the OptionsBuilder.

The extension method IServiceCollection.AddOptions<T>() is a bit like an alternative version of Configure<T>():

  • AddOptions<T>() returns an OptionsBuilder<T> instance instead of IServiceCollection.
  • You must call Bind() on the OptionsBuilder<T> instance to bind to a configuration section.

The use of the OptionsBuilder<T> instance opens up the way for adding extra features like validation.

A helper extension, BindConfiguration(), was added to OptionsBuilder to simplify the binding of configuration sections. You'll see how to use it in the next section.

Lets see it in action by adding some validation attributes to the SlackApiSettings, and configuring validation in our app:

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

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<SlackApiSettings>()
    .BindConfiguration("SlackApi") // 👈 Bind the SlackApi section
    .ValidateDataAnnotations() // 👈 Enable validation
    .ValidateOnStart(); // 👈 Validate on app start

// Explicitly register the settings object by delegating to the IOptions object
builder.Services.AddSingleton(resolver => 
        resolver.GetRequiredService<IOptions<SlackApiSettings>>().Value);

var app = builder.Build();

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

app.Run();

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

Note that for this example I used DataAnnotations, but it's totally possible to plug in other validation frameworks.

That's all the configuration required, time to put it to the test!

Testing Configuration at app startup

We can test the validation by running any of the failure examples from earlier. For example, if we introduce a typo into the WebhookUrl property, then when we start the app, and before we serve any requests, the app throws an Exception:

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, instead of only at runtime when you try and use the configuration. The app will never start up - if you're deploying to an environment with rolling deployments, for example Kubernetes, the deployment will never be healthy, which should ensure your previous healthy deployment remains active until you fix the configuration issue.

Summary

The ASP.NET Core configuration system is very flexible and allows you to use strongly typed settings. However, partly due to this flexibility, it's possible to have configuration errors that only appear in certain environments. By default, these errors will only be discovered when your code attempts to use an invalid configuration value (if at all).

In this post, I showed how you can use the ValidateOnStart() function, introduced in .NET 6, to validate 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?