Options Pattern Validation in ASP.NET Core With FluentValidation

Options Pattern Validation in ASP.NET Core With FluentValidation

6 min read ·

Using MassTransit with RabbitMQ or Azure Service Bus? Messages exhausting retry policies and ending up in error queues is inevitable. If you're manually "shoveling" them from Management UIs or have custom scripts to send them back to be reprocessed, there is a better way. Try the MassTransit Error Management platform today!

Webinar: Design System PoC Live Demo
Would you accept the challenge of creating a design system PoC in front of a live audience in under an hour? We did! Join us on March 26th to watch the simulation.

If you've worked with the Options Pattern in ASP.NET Core, you're likely familiar with the built-in validation using Data Annotations. While functional, Data Annotations can be limiting for complex validation scenarios.

The Options Pattern lets you use classes to obtain strongly typed configuration objects at runtime.

The problem? You can't be certain that the configuration is valid until you try to use it.

So why not validate it at application startup?

In this article, we'll explore how to integrate the more powerful FluentValidation library with ASP.NET Core's Options Pattern, to build a robust validation solution that executes at application startup.

Why FluentValidation Over Data Annotations?

Data Annotations work well for simple validations, but FluentValidation offers several advantages:

  • More expressive and flexible validation rules
  • Better support for complex conditional validations
  • Cleaner separation of concerns (validation logic separate from model)
  • Easier testing of validation rules
  • Better support for custom validation logic
  • Allows for injecting dependencies into validators

Understanding the Options Pattern Lifecycle

Before diving deep into validation, it's important to understand the lifecycle of options in ASP.NET Core:

  • Options are registered with the DI container
  • Configuration values are bound to options classes
  • Validation occurs (if configured)
  • Options are resolved when requested via IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T>

The ValidateOnStart() method forces validation to occur during application startup rather than when options are first resolved.

Common Configuration Failures Without Validation

Without validation, configuration issues can manifest in several ways:

  • Silent failures: An incorrectly configured option may result in default values being used without warning
  • Runtime exceptions: Configuration issues may only surface when the application tries to use invalid values
  • Cascading failures: One misconfigured component can cause failures in dependent systems

By validating at startup, you create a fast feedback loop that prevents these issues.

Setting Up the Foundation

First, let's add the FluentValidation package to our project:

Install-Package FluentValidation # base package
Install-Package FluentValidation.DependencyInjectionExtensions # for DI integration

For our example, we'll use a GitHubSettings class that requires validation:

public class GitHubSettings
{
    public const string ConfigurationSection = "GitHubSettings";

    public string BaseUrl { get;init; }
    public string AccessToken { get; init; }
    public string RepositoryName { get; init; }
}

Creating a FluentValidation Validator

Next, we'll create a validator for our settings class:

public class GitHubSettingsValidator : AbstractValidator<GitHubSettings>
{
    public GitHubSettingsValidator()
    {
        RuleFor(x => x.BaseUrl).NotEmpty();

        RuleFor(x => x.BaseUrl)
            .Must(baseUrl => Uri.TryCreate(baseUrl, UriKind.Absolute, out _))
            .When(x => !string.IsNullOrWhiteSpace(x.baseUrl))
            .WithMessage($"{nameof(GitHubSettings.BaseUrl)} must be a valid URL");

        RuleFor(x => x.AccessToken)
            .NotEmpty();

        RuleFor(x => x.RepositoryName)
            .NotEmpty();
    }
}

Building the FluentValidation Integration

To integrate FluentValidation with the Options Pattern, we need to create a custom IValidateOptions<T> implementation:

using FluentValidation;
using Microsoft.Extensions.Options;

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

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

    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        if (_name is not null && _name != name)
        {
            return ValidateOptionsResult.Skip;
        }

        ArgumentNullException.ThrowIfNull(options);

        using var scope = _serviceProvider.CreateScope();

        var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();

        var result = validator.Validate(options);
        if (result.IsValid)
        {
            return ValidateOptionsResult.Success;
        }

        var type = options.GetType().Name;
        var errors = new List<string>();

        foreach (var failure in result.Errors)
        {
            errors.Add($"Validation failed for {type}.{failure.PropertyName} " +
                       $"with the error: {failure.ErrorMessage}");
        }

        return ValidateOptionsResult.Fail(errors);
    }
}

A few important notes about this implementation:

  1. We create a scoped service provider to properly resolve the validator (since validators are typically registered as scoped services)
  2. We handle named options through the _name property
  3. We build informative error messages that include the property name and error message

How the FluentValidation Integration Works

When adding our custom FluentValidation integration, it's helpful to understand how it connects to ASP.NET Core's options system:

  1. The IValidateOptions<T> interface is the hook that ASP.NET Core provides for options validation
  2. Our FluentValidateOptions<T> class implements this interface to bridge to FluentValidation
  3. When ValidateOnStart() is called, ASP.NET Core resolves all IValidateOptions<T> implementations and runs them
  4. If validation fails, an OptionsValidationException is thrown, preventing the application from starting

Creating Extension Methods for Easy Integration

Now, let's create a few extension methods to make our validation easier to use:

public static class OptionsBuilderExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(
        this OptionsBuilder<TOptions> builder)
        where TOptions : class
    {
        builder.Services.AddSingleton<IValidateOptions<TOptions>>(
            serviceProvider => new FluentValidateOptions<TOptions>(
                serviceProvider,
                builder.Name));

        return builder;
    }
}

This extension method allows us to call .ValidateFluentValidation() when configuring options, similar to the built-in .ValidateDataAnnotations() method.

For even more convenience, we can create another extension method to simplify the entire configuration process:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddOptionsWithFluentValidation<TOptions>(
        this IServiceCollection services,
        string configurationSection)
        where TOptions : class
    {
        services.AddOptions<TOptions>()
            .BindConfiguration(configurationSection)
            .ValidateFluentValidation() // Configure FluentValidation validation
            .ValidateOnStart(); // Validate options on application start

        return services;
    }
}

Registering and Using the Validation

There are a few ways to use our FluentValidation integration:

Option 1: Standard Registration with Manual Validator Registration

// Register the validator
builder.Services.AddScoped<IValidator<GitHubSettings>, GitHubSettingsValidator>();

// Configure options with validation
builder.Services.AddOptions<GitHubSettings>()
    .BindConfiguration(GitHubSettings.ConfigurationSection)
    .ValidateFluentValidation() // Configure FluentValidation validation
    .ValidateOnStart();

Option 2: Using the Convenience Extension Method

// Register the validator
builder.Services.AddScoped<IValidator<GitHubSettings>, GitHubSettingsValidator>();

// Use the convenience extension
builder.Services.AddOptionsWithFluentValidation<GitHubSettings>(GitHubSettings.ConfigurationSection);

Option 3: Automatic Validator Registration

If you have many validators and want to register them all at once, you can use FluentValidation's assembly scanning:

// Register all validators from assembly
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

// Use the convenience extension
builder.Services.AddOptionsWithFluentValidation<GitHubSettings>(GitHubSettings.ConfigurationSection);

What Happens at Runtime?

With .ValidateOnStart(), the application will throw an exception during startup if any validation rules fail. For example, if your appsettings.json is missing the required AccessToken, you'll see something like:

Microsoft.Extensions.Options.OptionsValidationException:
    Validation failed for GitHubSettings.AccessToken with the error: 'Access Token' must not be empty.

This prevents your application from even starting with invalid configuration, ensuring issues are caught as early as possible.

Working with Different Configuration Sources

ASP.NET Core's configuration system supports multiple sources. When using the Options Pattern with FluentValidation, remember that validation works regardless of the source:

  • Environment variables
  • Azure Key Vault
  • User secrets
  • JSON files
  • In-memory configuration

This is particularly useful for containerized applications where configuration comes from environment variables or mounted secrets.

Testing Your Validators

One benefit of using FluentValidation is that validators are easy to test:

// Uses helper methods from FluentValidation.TestHelper
[Fact]
public void GitHubSettings_WithMissingAccessToken_ShouldHaveValidationError()
{
    // Arrange
    var validator = new GitHubSettingsValidator();
    var settings = new GitHubSettings { RepositoryName = "test-repo" };

    // Act
    TestValidationResult<CreateEntryDto>? result = await validator.TestValidate(dto);

    // Assert
    result.ShouldNotHaveAnyValidationErrors();
}

Summary

By combining FluentValidation with the Options Pattern and ValidateOnStart(), we create a powerful validation system that ensures our application has correct configuration at startup.

This approach:

  1. Provides more expressive validation rules than Data Annotations
  2. Separates validation logic from configuration models
  3. Catches configuration errors at application startup
  4. Supports complex validation scenarios
  5. Is easily testable

This pattern is particularly valuable in microservice architectures or containerized applications where configuration errors should be detected immediately rather than at runtime.

Remember to register your validators appropriately and use .ValidateOnStart() to ensure validation happens during application startup.

That's all for today.

See you next Saturday.


Whenever you're ready, there are 4 ways I can help you:

  1. (NEW) Pragmatic REST APIs: You will learn how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  2. Pragmatic Clean Architecture: Join 3,900+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 1,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Patreon Community: Join a community of 1,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

Become a Better .NET Software Engineer

Join 64,000+ engineers who are improving their skills every Saturday morning.

Hi, I'm Milan

Every Saturday morning I send one .NET & software architecture tip in my newsletter.

More than 64,000+ engineers already read it. I would love to have you with us.