Health
Contract
@modularityjs/health defines the HealthIndicator interface, HealthService, and pool-based indicator discovery.
type HealthProbe = 'liveness' | 'readiness';
interface HealthIndicator {
readonly name: string;
/** Defaults to 'readiness' when omitted — dependency checks are the norm. */
readonly probe?: HealthProbe;
check(): Promise<HealthStatus>;
}interface HealthStatus {
healthy: boolean;
details?: Record<string, unknown>;
}interface AggregatedHealth {
healthy: boolean;
indicators: Record<string, HealthStatus>;
}Liveness vs Readiness
Every indicator carries a probe classification, and HealthService.check(probe?) filters by it:
readiness(the default) — "can this instance serve traffic right now?" Backend dependency checks (database, Redis, Elasticsearch) belong here: when a dependency blips, the orchestrator sheds traffic from the instance until it recovers.liveness— "is this process itself broken beyond recovery?" Reserve it for process-level self-checks (event-loop stalls, wedged internal state). Never classify a backend dependency as liveness — a transient database outage would then restart every pod instead of just unrouting them, turning a blip into a restart storm.
check() with no argument runs all indicators (the full diagnostic view); check('readiness') / check('liveness') run only that probe's indicators. Zero matching indicators yields { healthy: true, indicators: {} }.
Setup
import { HealthModule } from '@modularityjs/health';
const modules = [HealthModule];No driver is required — HealthModule declares the HealthIndicatorsPool contract and provides HealthService. Indicators are contributed by other modules.
Defining Indicators
Implement the HealthIndicator interface and register via HealthIndicatorsPool:
import { Inject, Injectable } from '@modularityjs/di';
import { HealthIndicatorsPool, HealthModule } from '@modularityjs/health';
import type { HealthIndicator } from '@modularityjs/health';
import { Module } from '@modularityjs/modularity';
import { PaymentGatewayClient } from './payment-gateway.client.js';
@Injectable()
class PaymentGatewayIndicator implements HealthIndicator {
readonly name = 'payment-gateway';
// omitted probe = 'readiness' — a dependency check, sheds traffic when down
constructor(
@Inject(PaymentGatewayClient)
private readonly gateway: PaymentGatewayClient,
) {}
async check() {
const start = performance.now();
await this.gateway.ping();
const responseTimeMs = Math.round((performance.now() - start) * 100) / 100;
return { healthy: true, details: { responseTimeMs } };
}
}
@Module({
name: 'payment-gateway-health',
imports: [HealthModule],
providers: [PaymentGatewayIndicator],
pools: [
{
pool: HealthIndicatorsPool,
key: 'payment-gateway',
useClass: PaymentGatewayIndicator,
},
],
})
class PaymentGatewayHealthModule {}A process-level self-check declares readonly probe = 'liveness' as const instead — it then answers /health/live and a failure restarts the pod rather than unrouting it.
The HealthService catches exceptions thrown by indicators and marks them as unhealthy automatically — indicators don't need their own try/catch. Each indicator is also raced against HealthConfig.indicatorTimeoutMs (default 5000); a slow indicator fails with Health check timed out rather than blocking the aggregate.
Built-in Indicators
All three ship as readiness indicators reporting { healthy: true, details: { responseTimeMs } } on success; a failed ping propagates and HealthService converts it into { healthy: false, details: { error } }.
Redis (@modularityjs/redis-health)
PING round-trip against the shared client. Requires @modularityjs/redis.
import { HealthModule } from '@modularityjs/health';
import { RedisHealthModule } from '@modularityjs/redis-health';
import { RedisModule } from '@modularityjs/redis';
const modules = [
RedisModule.forRoot({ url: 'redis://localhost:6379' }),
HealthModule,
RedisHealthModule,
];Database (@modularityjs/database-health)
Probes DatabaseConnection.ping() — a live SELECT 1 round-trip through the active driver (TypeORM or Prisma), not just an "is initialized" flag. Registers as indicator database. Requires @modularityjs/database plus a driver.
import { DatabaseHealthModule } from '@modularityjs/database-health';
import { HealthModule } from '@modularityjs/health';
const modules = [
// ...DatabaseModule + a database driver...
HealthModule,
DatabaseHealthModule,
];Elasticsearch (@modularityjs/search-elasticsearch-health)
Cluster ping via ElasticsearchSearchService.ping(). Registers as indicator elasticsearch. This extension depends on the search-elasticsearch driver (not the search contract) — ping() is driver-specific, so only apps running the ES driver wire it.
import { HealthModule } from '@modularityjs/health';
import { SearchElasticsearchHealthModule } from '@modularityjs/search-elasticsearch-health';
const modules = [
// ...SearchModule + SearchElasticsearchModule.forRoot({ node: '...' })...
HealthModule,
SearchElasticsearchHealthModule,
];HTTP Endpoints (@modularityjs/http-health)
Mounts three probe-ready routes on the HTTP contract (any HTTP driver):
| Route | Runs | Status |
|---|---|---|
/health/live | liveness indicators only | 200, or 503 when unhealthy |
/health/ready | readiness indicators only | 200, or 503 when unhealthy |
/health | all indicators (diagnostics) | 200, or 503 when unhealthy |
import { HttpHealthModule } from '@modularityjs/http-health';
const modules = [
// ...HttpModule.forRoot({ port: 3000 }), HttpFastifyModule, HealthModule, indicator modules...
HttpHealthModule,
// or, when the endpoints are externally reachable:
// HttpHealthModule.forRoot({ exposeDetails: false }),
];The body is the full AggregatedHealth outside production, and just { healthy: boolean } in it: exposeDetails defaults to process.env.NODE_ENV !== 'production'. These routes carry no authentication and probes only read the status code, so indicator details (backend names, response times, error messages) stay off the wire where it matters. Set exposeDetails: true explicitly if only a private probe network can reach the port. The controller is also @Version(null), so turning on URI versioning never relocates the probe URLs your deployment manifest hardcodes. See the Health wiring recipe for the Kubernetes probe configuration.
Programmatic Access
import { Inject, Injectable } from '@modularityjs/di';
import { HealthService } from '@modularityjs/health';
@Injectable()
class StatusController {
constructor(@Inject(HealthService) private readonly health: HealthService) {}
async getHealth() {
const result = await this.health.check();
// { healthy: true, indicators: { database: { healthy: true, details: { responseTimeMs: 2.31 } }, redis: { healthy: true, details: { responseTimeMs: 0.85 } } } }
return result;
}
}CLI
@modularityjs/health-cli adds the health:status command:
import { HealthModule } from '@modularityjs/health';
import { HealthCliModule } from '@modularityjs/health-cli';
const modules = [HealthModule, HealthCliModule];$ myapp health:status
HEALTHY
├── ✓ database (responseTimeMs: 2.31)
└── ✓ redis (responseTimeMs: 0.85)