DEV Community

Cover image for ASP .NET Core startup validation part 2
Karen Payne
Karen Payne

Posted on

1

ASP .NET Core startup validation part 2

Introduction

Learn how to implement ValidateOnStart with separate classes for validators invoked using language extension methods. This is a continuation of part 1 of this series.

Assumptions

The developer has a decent knowledge of C#, dependency injection, and the basics of ASP.NET Core.

Documentation

All provided code has been thoroughly documented. If something is not understood, consider using GitHub Copilot, which is free and can easily interpret code for you.

NET 9 Source code

Note
In the section Using a class project, there are two more links for source code for adapting validators in a class project, which an ASP.NET Core project uses to validate appsettings.json sections and properties.

Basic example

Given the section ConnectionStrings, which has a property MainConnection to connect to an SQL-Server database, the objective is to check if the application can create a connection to the database using EF Core or Dapper.

{
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "MainConnection": "Data Source=.\\SQLEXPRESS;Initial Catalog=AppsettingsConfigurations;Integrated Security=True;Encrypt=False"
  }
}
Enter fullscreen mode Exit fullscreen mode

Many developers will write code as shown below in an ASP.NET Core Program.cs

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddRazorPages();

        builder.Services.AddOptions<ConnectionStrings>()
            .BindConfiguration(nameof(ConnectionStrings))
            .ValidateDataAnnotations()
            .Validate(cs =>
            {
                try
                {

                    var sb = new SqlConnectionStringBuilder(cs.MainConnection)
                    {
                        ConnectTimeout = 2
                    };

                    using SqlConnection connection = new(sb.ConnectionString);

                    connection.Open();
                    connection.Close();
                    return true;

                }
                catch (Exception ex)
                {
                    return false;
                }
            }, $"Failed to open SQL connection: {nameof(ConnectionStrings.MainConnection)} ")
            .ValidateOnStart();

        var app = builder.Build();

        if (!app.Environment.IsDevelopment())
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

        app.MapStaticAssets();
        app.MapRazorPages()
           .WithStaticAssets();

        app.Run();
    }
}
Enter fullscreen mode Exit fullscreen mode

The code will work as designed, but suppose other aspects of sections and properties are needed? This is where moving validation logic into separate classes helps in two ways. The first clean-up program, cs, and the second, with separate classes, provide an opportunity to either keep the code in the project or move to a class project that is available to other projects.

The class

public class ConnectionStrings
{
    public string MainConnection { get; set; } = string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

The validator class replaces code in Program.cs, which has more assertions than the former, along with code that is easier to debug.

public class SqlConnectionValidator : IValidateOptions<ConnectionStrings>
{
    public ValidateOptionsResult Validate(string? name, ConnectionStrings options)
    {

        if (options == null || string.IsNullOrWhiteSpace(options.MainConnection))
        {
            return ValidateOptionsResult.Fail($"The '{nameof(ConnectionStrings)}' section is missing or not configured.");
        }

        if (string.IsNullOrWhiteSpace(options.MainConnection))
        {
            return ValidateOptionsResult.Fail(
                $"{nameof(ConnectionStrings.MainConnection)} string cannot be empty.");
        }

        try
        {
            /*
             * We want a short timeout for the connection attempt to avoid blocking the application
             */
            var builder = new SqlConnectionStringBuilder(options.MainConnection)
            {
                ConnectTimeout = 2 
            };

            using var connection = new SqlConnection(builder.ConnectionString);

            connection.Open();
            connection.Close();

        }
        catch (Exception ex)
        {
            return ValidateOptionsResult.Fail($"Failed to open SQL connection: {ex.Message}");
        }

        return ValidateOptionsResult.Success;
    }
}
Enter fullscreen mode Exit fullscreen mode

Multiple assertions

Using the pattern for ConnectionStrings, let's add another section and create a validator that checks if a string property has a valid GUID.

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "MainConnection": "Data Source=.\\SQLEXPRESS;Initial Catalog=AppsettingsConfigurations;Integrated Security=True;Encrypt=False"
  },
  "TenantAzureSettings": {
    "ConnectionString": "Data Source= .\\SQLEXPRESS;Initial Catalog=Reservations;Integrated Security=True;Encrypt=False",
    "TenantId": "9f6c9a6e-01cf-4e2d-8b41-34d8e3e0a43c"
  }
}
Enter fullscreen mode Exit fullscreen mode

Validator for TenantAzureSettings section

This validator uses the same patterns as the Validator for the ConnectionStrings section.

public class TenantAzureValidator : IValidateOptions<TenantAzureSettings>
{
    public ValidateOptionsResult Validate(string? name, TenantAzureSettings options)
    {

        if (options == null || string.IsNullOrWhiteSpace(options.ConnectionString))
        {
            return ValidateOptionsResult.Fail($"The '{nameof(TenantAzureSettings)}.{nameof(ConnectionStrings)}' section is missing or not configured.");
        }

        if (string.IsNullOrWhiteSpace(options.ConnectionString))
        {
            return ValidateOptionsResult.Fail($"{nameof(TenantAzureSettings)}.{nameof(TenantAzureSettings.ConnectionString)} is required.");
        }

        if (string.IsNullOrWhiteSpace(options.TenantId))
        {
            return ValidateOptionsResult.Fail($"{nameof(TenantAzureSettings)}.{nameof(TenantAzureSettings.TenantId)} is required.");
        }

        if (!Guid.TryParse(options.TenantId, out _))
        {
            return ValidateOptionsResult.Fail(
                $"{nameof(TenantAzureSettings)}.{nameof(TenantAzureSettings.TenantId)} must be a valid GUID.");
        }

        return ValidateOptionsResult.Success;
    }
}
Enter fullscreen mode Exit fullscreen mode

Clean implementation

The final step to keep Program.cs clean is to create language extension methods on IServiceCollection.

public static class ValidationExtensions
{
    public static IServiceCollection SqlConnectionValidation(this IServiceCollection services, IConfiguration configuration)
    {
        services.AddOptions<ConnectionStrings>()
            .Bind(configuration.GetSection(nameof(ConnectionStrings)))
            .ValidateOnStart();

        services.AddSingleton<IValidateOptions<ConnectionStrings>, SqlConnectionValidator>();

        return services;
    }

    public static IServiceCollection TenantValidation(this IServiceCollection services, IConfiguration configuration)
    {
        services.AddOptions<TenantAzureSettings>()
            .Bind(configuration.GetSection(nameof(TenantAzureSettings)))
            .ValidateOnStart();

        services.AddSingleton<IValidateOptions<TenantAzureSettings>, TenantAzureValidator>();

        return services;
    }
}
Enter fullscreen mode Exit fullscreen mode

Invoking the language extension methods keeps the code of all validators clean.

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddRazorPages();

        builder.Services
            .SqlConnectionValidation(builder.Configuration)
            .TenantValidation(builder.Configuration);

        var app = builder.Build();

        if (!app.Environment.IsDevelopment())
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

        app.MapStaticAssets();
        app.MapRazorPages()
           .WithStaticAssets();

        app.Run();
    }
}
Enter fullscreen mode Exit fullscreen mode

These extension methods are documented in the provided source code, which assists developers in understanding their responsibilities.

shows information when hovering over an extension method

Using a class project

For completeness, check out the two projects below: the class project has the classes, validators, and extension methods from the first project, and the frontend project references the backend project to validate appsettings.json.

This code set is an option when several projects use the same classes and validators which requires careful planning.

Validator class project Frontend project

Testing these validators

Make sure to fully check these validators' functions as expected by altering sections and property values in appsettings.json.

AI helpers

Karen has been writing enterprise solutions for over thirty years and welcomes using AI tools. Although all code has been fully documented, the reader may not understand parts of it. Taking advantage of GitHub Copilot can assist by highlighting code using ALT+/ followed by /explaining.

Summary

Code has been presented to separate ValidateOnStart from the traditional way of implementing ValidateOnStart to classes, which are processed using extension methods that allow reuse and separation of concerns.

Top comments (0)

👋 Kindness is contagious

Explore a trove of insights in this engaging article, celebrated within our welcoming DEV Community. Developers from every background are invited to join and enhance our shared wisdom.

A genuine "thank you" can truly uplift someone’s day. Feel free to express your gratitude in the comments below!

On DEV, our collective exchange of knowledge lightens the road ahead and strengthens our community bonds. Found something valuable here? A small thank you to the author can make a big difference.

Okay