Is it useful to validate the configuration of a .NET application? Let’s explore it!

On a recent TypeScript project, we added validation on application startup to ensure the required options were not missing. It was incredibly helpful in preventing us from deploying the application in an invalid state.

I wanted to explore what validation options are available with .NET applications.

The configuration data your application uses is a form of user input. And in most cases, you should not trust user input and you should perform validation before using it.

But when it comes to the configuration settings, they are often not validated. Many developers, myself included, assume the data is ‘trustworthy’ because it is something that we have control over.

However, it is more than likely that something will occur that will make the configuration data invalid. And if your application is not prepared to handle invalid configuration data, you can deploy your application in an unpredictable and potentially dangerous state.

Configuring a .NET Application

Most applications use some type of external configuration data. It is the time-honored way of changing how an application behaves without having to recompile the code. Developers of .NET applications have a wealth of options to configure the applications. The default approach is to store configuration data in a JSON file called appsettings.json. Environment variables can also be used, allowing settings to be stored outside the application code, which is useful when storing sensitive data or environment-specific data.

In this example, let’s assume the application is configured using the following appsettings.json file:

{
  "Poller": {
    "BatchSize": 0,
    "DelayDuration": "00:00:01"
  }
}

In an .NET application, you can access the configuration data directly (using the GetValue() method of the IConfiguration object). But it is often wiser to bind the values to an object:

// PollerOptions.cs
public class PollerOptions
{
    // Should be at least 10
    public int BatchSize { get; set; }
    
    // Should be at least 5 seconds
    public TimeSpan DelayDuration { get; set; }
}

// Program.cs (IHostBuilder)
services.AddOptions<PollerOptions>().Bind(hostContext.Configuration.GetSection("Poller"));

// or (WebApplicationBuilder)
builder.Services.Configure<PollerOptions>(builder.Configuration.GetSection("Poller"));

// Poller.cs
public class Poller : BackgroundService
{
    private readonly PollerOptions _pollerOptions;

    public Poller(IOptions<PollerOptions> pollerOptions)
    {
        _pollerOptions = pollerOptions.Value;
    }
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await PollForBatchToProcessAsync(_pollerOptions.BatchSize, stoppingToken);

            await Task.Delay(_pollerOptions.DelayDuration, stoppingToken);
        }
    }
}

With the current configuration settings, the Poller would be polling for a batch of size zero every second. Not ideal!

NOTE This approach uses the “Options” pattern that allows access to the values via the built-in dependency injection service container.

Refer to this article for more details of the Options Pattern:

Options pattern in ASP.NET Core

What should the system validate?

The most obvious thing to test for is missing values. But something that is out of range is also a good thing to validate. How would your application react if a polling delay is zero? What if a batch size is too small?

Adding Validation

There are several options for validating the settings. The obvious one is to write custom logic that validates the configuration data. An alternative is to use built-in features of .NET to help with the validation.

One of the built-in options is to call the Validate() method, passing in a function to validate the configuration:

services.AddOptions<PollerOptions>()
    .Bind(hostContext.Configuration.GetSection("Poller"))
    .Validate(options =>
    {
        return (options.BatchSize >= 10 && options.DelayDuration > TimeSpan.FromSeconds(5));
    }, "Invalid PollerOptions");

The drawback of this approach is the lack of a meaningful validation error message. You won’t know what is wrong with the configuration:

> dotnet run
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: Invalid PollerOptions

A better approach is to implement a class that implements the IValidateOptions interface:

internal class PollerOptionsValidation : IValidateOptions<PollerOptions>
{
    public ValidateOptionsResult Validate(string? name, PollerOptions options)
    {
        var errors = new List<string>();
        if (options.BatchSize < 10)
        {
            errors.Add($"{nameof(PollerOptions)} BatchSize of '{options.BatchSize}' must be at least 10");
        }

        if (options.DelayDuration < TimeSpan.FromSeconds(5))
        {
            errors.Add($"{nameof(PollerOptions)} DelayDuration of '{options.DelayDuration}' must be at least 5 seconds");
        }

        return errors.Count > 0
            ? ValidateOptionsResult.Fail(errors)
            : ValidateOptionsResult.Success;
    }
}

Then register it along with the options class:

services.AddOptions<PollerOptions>()
    .Bind(hostContext.Configuration.GetSection("Poller"));
services.AddSingleton<IValidateOptions<PollerOptions>, PollerOptionsValidation>();

Here is the validation error message this approach produces:

> dotnet run
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: PollerOptions BatchSize of '0' must be at least 10; PollerOptions DelayDuration of '00:00:01' must be at least 5 seconds

NOTE: Another benefit to this approach is that you can validate the options conditionally if so desired. For example, if the options had an “IsEnabled” property, you could use that value to decide whether or not to validate the remaining properties.

Validation using DataAnnotations

If you are using the Options pattern of configuration data, you can also use data annotations to validate your configuration. First, install the NuGet package:

dotnet add package Microsoft.Extensions.Options.DataAnnotations

Next, add the desired data annotations:

using System.ComponentModel.DataAnnotations;

namespace ConfigValidationDemo;

public class PollerOptions
{
    [Range(10, int.MaxValue)]
    public int BatchSize { get; set; }

    [Range(typeof(TimeSpan), "00:00:05", "23:59:59")]
    public TimeSpan DelayDuration { get; set; }
}

And last, use the provided extension method to validate the options:

services.AddOptions<PollerOptions>()
    .Bind(hostContext.Configuration.GetSection("Poller"))
    .ValidateDataAnnotations();

This is the output it produces:

> dotnet run
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'PollerOptions' members: 'BatchSize' with the error: 'The field BatchSize must be between 10 and 2147483647.'.; DataAnnotation validation failed for 'PollerOptions' members: 'DelayDuration' with the error: 'The field DelayDuration must be between 00:00:05 and 23:59:59.'.

With the ValidateDataAnnotations can also manually implement validation logic using the IValidatableObject interface, rather than using the data annotation attributes:

public class PollerOptions : IValidatableObject
{
    public int BatchSize { get; set; }

    public TimeSpan DelayDuration { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (BatchSize < 10)
        {
            yield return new ValidationResult("BatchSize must be at least 10");
        }

        if (DelayDuration < TimeSpan.FromSeconds(5))
        {
            yield return new ValidationResult("DelayDuration must be at least 5 seconds");
        }

        yield break;
    }
}
> dotnet run
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'PollerOptions' members: '' with the error: 'BatchSize must be at least 10'.; DataAnnotation validation failed for 'PollerOptions' members: '' with the error: 'DelayDuration must be at least 5 seconds'.

Startup or Runtime

As you can see above, you can validate the configuration at runtime (the first time the DI Container creates an instance of the object) or on startup. I would recommend validating the options at startup. The sooner you know something is incorrectly configured the better.

To validate the options are runtime, replace the AddOptions with AddOptionsWithValidateOnStart:

services.AddOptionsWithValidateOnStart<PollerOptions>()
    .Bind(hostContext.Configuration.GetSection("Poller"));

Summary

Validating the configuration settings of your .NET application can protect you from running your application in an invalid state. There are several built-in approaches depending on your needs. Take advantage of them!