Skip to content

Health

Contract

@modularityjs/health defines the HealthIndicator interface, HealthService, and pool-based indicator discovery.

typescript
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>;
}
typescript
interface HealthStatus {
  healthy: boolean;
  details?: Record<string, unknown>;
}
typescript
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

typescript
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:

typescript
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.

typescript
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.

typescript
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.

typescript
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):

RouteRunsStatus
/health/liveliveness indicators only200, or 503 when unhealthy
/health/readyreadiness indicators only200, or 503 when unhealthy
/healthall indicators (diagnostics)200, or 503 when unhealthy
typescript
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

typescript
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:

typescript
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)