Failing Fast at Start-Up

Catch broken DI registrations and invalid settings when the host starts, with ValidateOnBuild, ValidateScopes and ValidateOnStart.

Syntaxbuilder.Host.UseDefaultServiceProvider(o => { o.ValidateOnBuild = true; o.ValidateScopes = true; }); builder.Services.AddOptions<PaymentGatewayOptions>().BindConfiguration("PaymentGateway") .ValidateDataAnnotations().ValidateOnStart();

A broken deploy should fail its rollout, not the first customer request at 3 a.m. Kubernetes keeps the old pods serving while a new pod fails before it becomes ready, so every problem you detect at start-up turns an outage into a stalled deploy. The host gives you three switches for that.

The three switches

SwitchRuns atCatches
ValidateOnBuildBuild()Registrations whose constructor dependencies are missing, all listed in one AggregateException
ValidateScopesBuild (with the switch above) and every resolveA singleton holding a scoped service; a scoped service resolved from the root provider
ValidateOnStart()StartAsync, before hosted services and KestrelSettings that fail options validation or cannot be bound

The two container switches are on only in Development, so in staging and production a broken graph builds without complaint and fails when the service is first resolved. Turn ValidateOnBuild on everywhere (it costs milliseconds), and either turn ValidateScopes on too or prove the graph in a test that builds with both. Defaults, limits such as factory registrations, and that test are in Captive Dependencies and Scope Validation.

Options validation is lazy until you add .ValidateOnStart(); with it, a missing or malformed setting stops StartAsync and the process exits non-zero before the port opens. Choosing between data annotations, source-generated validators and IValidateOptions<T> is covered in Validating Options.

Checking dependencies before reporting ready

ProblemCheckOn failure
Missing or malformed settingValidateOnStartCrash: the next restart will not fix it
Broken DI graphValidateOnBuildCrash
Database or broker unreachableReadiness health checkStay up, report not ready
Schema older than the code expectsHosted service in StartAsyncCrash: a deploy-order mistake

Do not crash on a transient dependency outage. If the database blips and every pod exits, all of them crash-loop together with growing back-off and come back slower than the database did. A readiness check takes the pod out of the load balancer and puts it back when the dependency recovers.

What goes wrong in production

  • Scope validation that only ran in Development, so a captive DbContext reached production and mixed data across requests.
  • A required API key missing from one environment, found by the first customer to check out.
  • A start-up connectivity check with no timeout: start-up hangs, the startup probe kills the pod, repeat.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Stop a rollout when a new PaymentClient dependency was never registered, instead of failing every checkout request on the new pods.
  • Refuse to start when PaymentGateway:BaseUrl is missing or TimeoutSeconds is 0, listing every invalid setting in one start-up error.
  • Keep pods running but unready during a database failover, rather than crash-looping every replica at once.

More examples

A web API that validates everything at start-up

<code>ValidateOnBuild</code> is on everywhere, so a missing registration stops the deploy at <code>Build()</code>. Scope validation stays in Development, so outside it a captive dependency is not caught here; pair this with the container test from Captive Dependencies and Scope Validation. <code>ValidateOnStart</code> turns an empty <code>InvoiceStorage:Container</code> into a failed start instead of a 500 on the first invoice request.

Example · csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateOnBuild = true;
    options.ValidateScopes = builder.Environment.IsDevelopment();
});

builder.Services.AddOptions<InvoiceStorageOptions>()
    .BindConfiguration("InvoiceStorage")
    .ValidateDataAnnotations()
    .ValidateOnStart();

var app = builder.Build();
app.MapGet("/invoices/{number}", (string number, IOptions<InvoiceStorageOptions> storage) =>
    Results.Ok(new { number, container = storage.Value.Container }));
app.Run();

public sealed class InvoiceStorageOptions
{
    [Required] public string Container { get; set; } = "";
    [Range(1, 3650)] public int RetentionDays { get; set; } = 2555;
}

A dependency check that reports not ready instead of crashing

Liveness runs no checks, so a database outage never restarts the pod; readiness fails and the pod leaves the load balancer until the database is back. A real service would use a database health check package that runs <code>SELECT 1</code>; the shape is the same. The 2 second timeout keeps a slow network from stalling the probe.

Example · csharp
using System.Net.Sockets;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHealthChecks()
    .AddCheck<OrdersDatabaseReachable>("orders-db", tags: ["ready"]);

var app = builder.Build();

app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") });

app.Run();

sealed class OrdersDatabaseReachable(IConfiguration configuration) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct)
    {
        var host = configuration["OrdersDb:Host"] ?? "orders-db";
        var port = configuration.GetValue("OrdersDb:Port", 5432);
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeout.CancelAfter(TimeSpan.FromSeconds(2));
        try
        {
            using var client = new TcpClient();
            await client.ConnectAsync(host, port, timeout.Token);
            return HealthCheckResult.Healthy();
        }
        catch (Exception ex) when (ex is SocketException or OperationCanceledException)
        {
            return HealthCheckResult.Unhealthy("Orders database unreachable");
        }
    }
}

Refusing to start against an old database schema

A schema that is older than the code is a deploy-order mistake, and restarting will never fix it, so this check throws from <code>StartAsync</code>: the host logs "Hosting failed to start", the pod never becomes ready and the old version keeps serving. The read has its own 10 second timeout because <code>StartupTimeout</code> is infinite by default. A database that is unreachable is a different case: let the read fail into the readiness check instead of crashing.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ISchemaVersionReader, ConfiguredSchemaVersionReader>();
builder.Services.AddHostedService<SchemaVersionCheck>();

var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id) => Results.Ok(new { id, status = "Paid" }));
app.Run();

interface ISchemaVersionReader
{
    Task<int> ReadAsync(CancellationToken ct);
}

// Stands in for SELECT MAX(version) FROM schema_migrations.
sealed class ConfiguredSchemaVersionReader(IConfiguration configuration) : ISchemaVersionReader
{
    public Task<int> ReadAsync(CancellationToken ct) =>
        Task.FromResult(configuration.GetValue("OrdersDb:SchemaVersion", 41));
}

sealed class SchemaVersionCheck(ISchemaVersionReader reader, ILogger<SchemaVersionCheck> logger) : IHostedService
{
    private const int Required = 42;   // the migration this build's queries depend on

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeout.CancelAfter(TimeSpan.FromSeconds(10));
        var current = await reader.ReadAsync(timeout.Token);
        if (current < Required)
            throw new InvalidOperationException(
                $"Orders schema is at version {current}; this build needs {Required}. Run the migrations before deploying.");
        logger.LogInformation("Orders schema at version {Version}", current);
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Discussion

  • Be the first to comment on this lesson.