Health Probes Without an Endpoint Class
FastEndpoints.HealthChecks is the strangest package in the repository - it never references FastEndpoints. One extension method, an IStartupFilter, an always-healthy self check, and two probes Kubernetes understands. Part 6 of FastEndpoints — The Missing Chapters.
Auditing the FastEndpoints repository for this series, one package produced a genuine double-take. FastEndpoints.HealthChecks lives in Src/HealthChecks, ships under the FastEndpoints name and namespace - and its csproj contains no reference to FastEndpoints. Not to the library, not to Core, not to Attributes. It is pure ASP.NET Core plumbing that happens to live in this repository, and that turns out to be the most instructive thing about it.
It is also, at roughly four files, the smallest shippable package in the repo - which makes it a nice palate cleanser between the statics deep-dives and the integration heavyweights coming next. Small does not mean trivial: the package encodes three operational decisions that teams routinely get wrong by hand.
What it gives you
One call in Program.cs:
var bld = WebApplication.CreateBuilder(args);
bld.Services
.AddFastEndpoints()
.AddServiceHealthChecks(configureChecks: hc =>
{
hc.AddNpgSql(bld.Configuration.GetConnectionString("db")!, name: "postgres");
hc.AddRedis("localhost:6379", name: "redis");
});
var app = bld.Build();
app.UseFastEndpoints();
app.Run();
Notice what is missing: there is no app.MapHealthChecks(...), no UseHealthChecks(...), nothing on the pipeline side at all. Yet the app now answers on two routes:
/health/live- the liveness probe. Returns 200 if the process can serve a request. Period./health/ready- the readiness probe. Runs every registered check (the Postgres and Redis ones above) and aggregates.
Both paths, and whether each probe exists at all, come from ServiceHealthChecksOptions (EnableLive/LivePath, EnableReady/ReadyPath, UseJsonResponse), configurable through the first optional lambda.
Decision one: liveness must not check dependencies
The implementation of the two probes differs by exactly one predicate, and it is the whole ballgame. Reading ServiceHealthChecksStartupFilter.cs: the liveness endpoint is wired with Predicate = _ => false - no health check ever runs for it - while readiness gets Predicate = _ => true, all of them.
That asymmetry is Kubernetes doctrine that many hand-rolled setups violate. Liveness answers “should the orchestrator restart this container?” If your liveness probe checks the database and the database has a bad five minutes, Kubernetes restarts every pod in the fleet - turning a dependency brownout into a full-service restart storm. The only correct liveness check is “the process responds.” Readiness answers a different question - “should this pod receive traffic?” - where checking dependencies is exactly right: a pod that can't reach Postgres should leave the load-balancer rotation and come back when the connection does.
Encoding that split in the package means nobody on the team has to re-derive it during an incident. The .NET Aspire and Kubernetes deployment guides both push this pattern; here it is the default and you would have to work to break it.
Decision two: readiness that cannot 404
AddServiceHealthChecks always registers one check before yours, from the source (HealthChecksExtensions.cs):
// a base "self" check so readiness will still work (it will just be Healthy)
hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy());
Without it, an app that registers no custom checks would have a readiness endpoint backed by an empty check collection - technically fine in ASP.NET Core, but the "self" check guarantees the JSON body always has at least one entry and the semantics are unambiguous: no dependencies registered means ready. It also gives probes something to display the moment you adopt the package, before you have written a single real check. Defensive defaults, the house style.
Decision three: wiring by IStartupFilter
How do two routes appear with no Map call? The package registers an IStartupFilter:
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IStartupFilter, ServiceHealthChecksStartupFilter>());
IStartupFilter is the ASP.NET Core hook that lets a service registration inject middleware into the pipeline - the filter's Configure wraps the app's pipeline builder, calls UseHealthChecks(...) for each enabled probe, then hands off to the rest of the chain. It is the same mechanism hosting uses for its own diagnostics, and it is criminally underused in application code. Two properties make it right here: the probes land at the front of the pipeline (before your auth, before FastEndpoints - probes shouldn't need a JWT), and TryAddEnumerable makes a double AddServiceHealthChecks() call harmless rather than double-registering routes.
The trade is discoverability - nothing in Program.cs says these routes exist, which is why this article does.
The JSON body, AOT-proofed
With UseJsonResponse (the default), both probes reply with a structured body instead of ASP.NET's plain-text Healthy:
{
"status": "Degraded",
"totalDurationMs": 312.4,
"checks": [
{ "name": "self", "status": "Healthy", "durationMs": 0.1 },
{ "name": "postgres", "status": "Healthy", "durationMs": 41.7 },
{ "name": "redis", "status": "Unhealthy", "durationMs": 270.2,
"exception": "Connection refused" }
]
}
Note the exception field carries Exception.Message only - useful in a dashboard, worth remembering if your probes are internet-reachable (they should not be; keep them on the cluster network or behind a port filter).
The serialization detail is the package's quiet flex: the response types go through a source-generated JsonSerializerContext with camelCase policy, not reflection-based JsonSerializer. That plus the absent FastEndpoints dependency is why the csproj can honestly declare IsAotCompatible - this package works in a Native AOT deployment without any of the generator machinery the main library needs.
The lesson in the missing dependency
Why does a FastEndpoints package not use FastEndpoints? Because an endpoint class would be the wrong tool. Probes need no model binding, no validation, no versioning, no OpenAPI entry (you want them out of the spec), and they must run before the rest of the pipeline. The maintainers reached past their own abstraction to the platform primitive - UseHealthChecks plus IStartupFilter - and shipped that.
That is a design smell test worth stealing: when a framework's own authors bypass the framework for a feature, the feature genuinely didn't fit, and forcing it would have cost something real. The package earns its place in the FastEndpoints install line anyway, because AddServiceHealthChecks() next to AddFastEndpoints() reads as one coherent bootstrap - even though, underneath, they never touch.
Next week the opposite kind of integration - a package that reaches deep into FastEndpoints internals and rides a seam we met in Part 2: the OData bridge.