Skip to content

Health Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the health-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Two packages compose, plus ready-made indicator extensions:

  • @modularityjs/healthHealthService.check(probe?) → AggregatedHealth, the HealthIndicator interface ({ name, probe?, check() }), and HealthIndicatorsPool. No driver needed — indicators are the contributions. Each indicator declares probe: 'liveness' | 'readiness' (omitted = readiness); check('readiness') runs only that probe's indicators, in parallel, each raced against HealthConfig.indicatorTimeoutMs (default 5s). A throwing or timed-out indicator becomes { healthy: false, details: { error } } — no try/catch needed inside indicators.
  • @modularityjs/http-health — mounts /health (all indicators, diagnostics), /health/live (liveness only), /health/ready (readiness only) on the abstract HTTP contract. 200 when healthy, 503 otherwise. exposeDetails defaults to process.env.NODE_ENV !== 'production', so production bodies are just { healthy } — these routes carry no auth and probes only read the status code. Set it explicitly to true when a private probe network makes the detail worth having. The controller is @Version(null), so enabling HttpModuleConfig.versioning never moves the probe URLs out from under your deployment manifest.
  • Built-in indicators@modularityjs/redis-health (PING), @modularityjs/database-health (DatabaseConnection.ping() — a live SELECT 1), @modularityjs/search-elasticsearch-health (cluster ping). All register as readiness; wire the ones matching your drivers.

Classify probes by consequence, not by importance. Readiness failure ⇒ the orchestrator stops routing traffic to the instance until it recovers. Liveness failure ⇒ the orchestrator restarts the process. Backend dependencies (database, Redis, ES) must be readiness — classifying them as liveness turns a transient dependency blip into a fleet-wide restart storm. Reserve liveness for process-level self-checks.

typescript
import { Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import {
  HealthIndicatorsPool,
  HealthModule,
  type HealthIndicator,
  type HealthStatus,
} from '@modularityjs/health';
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpHealthModule } from '@modularityjs/http-health';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
import { RedisModule } from '@modularityjs/redis';
import { RedisHealthModule } from '@modularityjs/redis-health';

@Injectable()
class EventLoopIndicator implements HealthIndicator {
  readonly name = 'event-loop';
  readonly probe = 'liveness' as const; // restart-worthy: the process itself is wedged

  async check(): Promise<HealthStatus> {
    const start = performance.now();
    await new Promise((resolve) => setTimeout(resolve, 0));
    const lagMs = Math.round((performance.now() - start) * 100) / 100;
    return { healthy: lagMs < 1000, details: { lagMs } };
  }
}

@Module({
  name: 'app-health',
  imports: [HealthModule],
  providers: [EventLoopIndicator],
  pools: [
    {
      pool: HealthIndicatorsPool,
      key: 'event-loop',
      useClass: EventLoopIndicator,
    },
  ],
})
class AppHealthModule {}

const app = await createApp({
  di: inversify,
  modules: [
    ModularityModule,
    HttpModule.forRoot({ port: 3000 }),
    HttpFastifyModule,
    RedisModule.forRoot({ url: 'redis://localhost:6379' }),
    HealthModule,
    RedisHealthModule, // readiness: Redis PING
    HttpHealthModule, // /health, /health/live, /health/ready
    AppHealthModule, // liveness: event-loop self-check
  ],
});

Kubernetes probes map one-to-one onto the two filtered endpoints:

yaml
livenessProbe:
  httpGet: { path: /health/live, port: 3000 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /health/ready, port: 3000 }
  periodSeconds: 5
  failureThreshold: 2

Zero indicators for a probe is healthy, not an error. An app with only readiness indicators still answers /health/live with 200 { healthy: true } — the process is up and serving; that's all liveness claims until you contribute a real self-check.

Don't ping backends from your own code paths. The health:status CLI (@modularityjs/health-cli) and the HTTP endpoints share the same pool — contribute an indicator once and every surface (CLI, HTTP, dev console) sees it.