The code lives in a public repo and is tested in CI.
Part 4 of the series about my free, MIT-licensed .NET 10 Minimal API starter. Previous parts: the base template, per-user ownership and rate limiting.
The starter had a /health endpoint that always answered ok. It told you the process was running, but not whether it could actually serve requests. This post splits it into two endpoints.
Liveness vs readiness
-
/health/live: "is the process alive?" It runs no checks. If the app answers, it is alive. An orchestrator uses this to decide whether to restart the container. -
/health/ready: "can it handle traffic?" It also checks the database. An orchestrator or load balancer uses this to decide whether to send traffic to the instance.
Keeping them separate matters. If the database goes down and your liveness probe also checks it, the orchestrator will restart a perfectly healthy API in a loop and make things worse.
1. Register the checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database", tags: ["ready"]);
-
AddHealthChecks()turns on the health check system. -
AddDbContextCheck<AppDbContext>tries to connect through yourDbContextand reportsUnhealthyif it can't. - The
readytag lets us pick this check only for the readiness endpoint.
AddDbContextCheck is not in the framework. It comes from Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore:
dotnet add StarterKit.Api package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore
2. Map the two endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // run no checks: if the app answers, it is alive
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
Predicate decides which checks each endpoint runs. They answer 200 Healthy or 503 Unhealthy, which is what Docker, Kubernetes and load balancers understand.
3. Testing it, including the failure case
[Fact]
public async Task Ready_returns_503_when_a_check_fails()
{
var client = factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
services.AddHealthChecks().AddCheck(
"always-failing",
() => HealthCheckResult.Unhealthy(),
tags: ["ready"])))
.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
Instead of shutting down a database in a test, I register an extra check that always fails and tagged ready. The test confirms a failing check turns into a 503. Two more tests check that live and ready return 200 against in-memory SQLite.
Gotchas I hit
-
Two almost identical package names.
Microsoft.Extensions.Diagnostics.HealthChecksis already part of ASP.NET Core. The one you need forAddDbContextCheckis the...HealthChecks.EntityFrameworkCoreone. I installed the wrong one first. - Version downgrade error (NU1605). The new package pulled EF Core 10.0.13 while my project referenced 10.0.12. NuGet refuses to downgrade, so the build failed. The fix is to keep all EF Core and ASP.NET Core packages on the same version.
-
Test projects don't get implicit usings.
AddHealthChecksneedsusing Microsoft.Extensions.DependencyInjection;in the test project, even though the API project doesn't need it.
Limitations
- The database check opens a connection on every probe, so don't set the probe interval too aggressively.
- The endpoints are public and only return
Healthy/Unhealthy, without details. Don't expose detailed responses without authentication. - It only checks the database. If you add Redis, queues or external APIs, each one deserves its own check.
Try it
Next up: refresh tokens. If you'd rather see something else first, open a Discussion.
Top comments (0)