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.
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"
}
}
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();
}
}
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;
}
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;
}
}
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"
}
}
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;
}
}
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;
}
}
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();
}
}
These extension methods are documented in the provided source code, which assists developers in understanding their responsibilities.
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)