Discovery Package

npm downloads

The @hazeljs/discovery package provides service registration and discovery for HazelJS microservices. Register services, discover them by name with 6 load-balancing strategies, call them with smart retries, and swap backends — Memory, Redis, Consul, or Kubernetes — without changing application code.

Why Service Discovery?

In a microservices architecture, services need to know where to find each other. Hardcoding URLs breaks the moment you scale horizontally, redeploy, or change infrastructure. Service discovery solves this:

  • A service registers itself on startup (name, host, port, health check)
  • Other services discover it by name — no hardcoded URLs
  • Load balancing distributes traffic across multiple healthy instances automatically
  • Health checks remove failed instances from the pool without manual intervention

@hazeljs/discovery is the Eureka-style service registry for HazelJS — without the Java.

Why @hazeljs/discovery?

ChallengeWithout @hazeljs/discoveryWith @hazeljs/discovery
Locating servicesHardcoded env vars per serviceDiscover by name at runtime
Scaling outUpdate configs everywhereInstances register themselves
Load balancingManual round-robin or external proxy6 built-in strategies
Failed instancesManual removal or health checks per callerAuto-removed via TTL/heartbeat
Infrastructure changeRedeploy every callerSwap backend, no code change
Production backendsCustom Redis/Consul codeDrop-in backends included

Architecture

Services register with a shared backend. Clients discover instances through the same backend, with optional caching and configurable load balancing. ServiceClient handles HTTP calls with auto-discovery and smart retries:

graph TD
  A["Service A<br/>(ServiceRegistry)"] --> B["Registry Backend"]
  C["Service B<br/>(ServiceRegistry)"] --> B
  B --> D["Memory / Redis / Consul / K8s"]
  E["DiscoveryClient"] --> F["Cache<br/>(auto-refresh)"]
  F --> B
  E --> G["Load Balancer<br/>(6 strategies)"]
  G --> B
  H["ServiceClient"] --> E
  H --> I["HTTP: GET/POST/PUT/DELETE"]
  I --> A
  I --> C
  
  style A fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff
  style C fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff
  style B fill:#6366f1,stroke:#818cf8,stroke-width:2px,color:#fff
  style D fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff
  style E fill:#f59e0b,stroke:#fbbf24,stroke-width:2px,color:#fff
  style F fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff
  style G fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff
  style H fill:#ec4899,stroke:#f472b6,stroke-width:2px,color:#fff
  style I fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff

Key Components

  1. ServiceRegistry — Registers this service on startup, sends heartbeats, deregisters on shutdown
  2. Registry Backend — Pluggable storage: Memory (dev), Redis, Consul, or Kubernetes
  3. DiscoveryClient — Discovers instances by name with optional cache and auto-refresh
  4. Load Balancer — Selects one instance from available ones using the chosen strategy
  5. ServiceClient — Full HTTP client: discovers the service, picks an instance, calls it, and retries on transient errors

Advantages

1. Zero Hardcoded URLs

Services register by name and port. Callers discover them by name. Moving a service to a different host or port requires no change in callers.

2. Six Load-Balancing Strategies Out of the Box

Round Robin, Random, Least Connections, Weighted Round Robin, IP Hash (sticky sessions), and Zone Aware — pick the right one for each service. No custom proxy needed.

3. Smart Retry Logic

ServiceClient retries only on transient errors (network, 5xx, 408, 429). 4xx errors are thrown immediately — no wasted retries on bad requests.

4. Pluggable Backends — Swap Without Code Changes

Start with in-memory for development, use Redis for production, migrate to Consul or K8s when ready. The same ServiceRegistry, DiscoveryClient, and ServiceClient API works for all backends.

5. Health Checks and Auto-Cleanup

Services send periodic heartbeats. If a service goes down without deregistering, its registration expires via TTL and is removed automatically. Dead instances never receive traffic.

6. Production Safe

Uses SCAN (not KEYS) for Redis queries, MGET for efficient batch lookups, validates configs at construction time, and handles connection errors with automatic reconnection support.

Installation

npm install @hazeljs/discovery

Install only the backend you need:

npm install ioredis                   # Redis backend
npm install consul                    # Consul backend
npm install @kubernetes/client-node   # Kubernetes backend

Quick Start

1. Register a Service

Call register() on startup and deregister() on shutdown:

import { ServiceRegistry } from '@hazeljs/discovery';

const registry = new ServiceRegistry({
  name: 'user-service',          // Unique name — used by callers
  port: 3000,
  host: 'localhost',             // Omit to auto-detect
  protocol: 'http',              // 'http' | 'https' | 'grpc'
  healthCheckPath: '/health',    // Default: '/health'
  healthCheckInterval: 30000,    // Heartbeat interval in ms (default: 30000)
  metadata: { version: '2.0.0' },
  zone: 'us-east-1',            // For zone-aware load balancing
  tags: ['api', 'users'],
});

await registry.register();
console.log('user-service registered');

// Graceful shutdown
process.on('SIGTERM', async () => {
  await registry.deregister();
  process.exit(0);
});

2. Discover Services

import { DiscoveryClient } from '@hazeljs/discovery';

const client = new DiscoveryClient({
  cacheEnabled: true,
  cacheTTL: 30000,        // ms before cache expires
  refreshInterval: 15000, // auto-refresh in background
});

// All healthy instances of a service
const instances = await client.getInstances('user-service');
console.log(`${instances.length} instances available`);

// Single instance with load balancing
const instance = await client.getInstance('user-service', 'round-robin');
console.log(`Calling ${instance.host}:${instance.port}`);

// All registered service names
const services = await client.getAllServices();

// Clean up on shutdown
client.close();

3. Call Services with ServiceClient

ServiceClient combines discovery, load balancing, and retries in one HTTP client:

import { ServiceClient, DiscoveryClient } from '@hazeljs/discovery';

const discoveryClient = new DiscoveryClient({ cacheEnabled: true });

const userClient = new ServiceClient(discoveryClient, {
  serviceName: 'user-service',
  loadBalancingStrategy: 'round-robin',
  timeout: 5000,           // ms per request
  retries: 3,              // retry on transient errors
  retryDelay: 1000,        // ms between retries
});

// Fully automatic: discover → pick instance → call → retry if needed
const { data: user } = await userClient.get('/users/123');
const { data: created } = await userClient.post('/users', { name: 'Jane', email: 'jane@acme.com' });
await userClient.put('/users/123', { name: 'Jane Doe' });
await userClient.delete('/users/123');

4. With HazelJS Decorators

Register and discover services declaratively using the DI system:

import { HazelModule, Service } from '@hazeljs/core';
import { ServiceRegistryDecorator, InjectServiceClient, ServiceClient } from '@hazeljs/discovery';

// Register this module as a service on startup
@ServiceRegistryDecorator({
  name: 'order-service',
  port: 3001,
  healthCheckPath: '/health',
  zone: 'us-east-1',
})
@HazelModule({ /* ... */ })
export class AppModule {}

// Inject a ServiceClient for another service
@Service()
export class OrderService {
  constructor(
    @InjectServiceClient('user-service')
    private readonly userClient: ServiceClient,

    @InjectServiceClient('inventory-service')
    private readonly inventoryClient: ServiceClient,
  ) {}

  async createOrder(userId: string, items: string[]) {
    // Both calls use automatic discovery + load balancing + retries
    const { data: user } = await this.userClient.get(`/users/${userId}`);
    const { data: stock } = await this.inventoryClient.post('/check', { items });
    // ... create the order
    return { userId, items, user, stock };
  }
}

Load Balancing Strategies

Choose the right strategy for each caller:

Round Robin

Default. Rotates through instances in order — even traffic distribution.

const instance = await client.getInstance('user-service', 'round-robin');

Random

Random selection — simple, works well for stateless services.

const instance = await client.getInstance('user-service', 'random');

Least Connections

Routes to the instance with fewest active connections. When using ServiceClient, connection counts are tracked automatically.

const instance = await client.getInstance('user-service', 'least-connections');

Weighted Round Robin

Distribute traffic proportionally by instance weight — useful for canary deployments or instances with different hardware.

// Set weight when registering
const registry = new ServiceRegistry({
  name: 'api-service',
  port: 3000,
  metadata: { weight: 5 }, // higher = more traffic
});

const instance = await client.getInstance('api-service', 'weighted-round-robin');

IP Hash (Sticky Sessions)

Same client IP always routes to the same instance. Useful for session affinity.

const instance = await client.getInstance('api-service', 'ip-hash');

Zone Aware

Prefer instances in the same zone; fall back to other zones if none available. Reduces cross-zone latency.

const factory = client.getLoadBalancerFactory();
const strategy = factory.create('zone-aware', { zone: 'us-east-1' });
const instance = await strategy.select(instances);

Service Filtering

Narrow the instance pool before load balancing — filter by zone, health status, tags, or metadata:

import { ServiceStatus } from '@hazeljs/discovery';

const instances = await client.getInstances('user-service', {
  zone: 'us-east-1',
  status: ServiceStatus.UP,         // Only healthy instances
  tags: ['api', 'production'],      // Must have all specified tags
  metadata: { version: '2.0.0' },   // Match specific metadata key/value
});

Use applyServiceFilter in custom backends or utility code:

import { applyServiceFilter } from '@hazeljs/discovery';
const filtered = applyServiceFilter(allInstances, { zone: 'us-west-2' });

Registry Backends

Memory (default — development)

In-process storage. No install needed. Instances expire by TTL:

import { MemoryRegistryBackend } from '@hazeljs/discovery';

const backend = new MemoryRegistryBackend(90000); // optional expiration in ms
const registry = new ServiceRegistry(config, backend);

Redis (production)

Distributed registry across all instances using TTL-based expiration. Uses SCAN (not KEYS) for production safety and MGET for efficient batch lookups. Handles reconnection automatically.

import Redis from 'ioredis';
import { RedisRegistryBackend } from '@hazeljs/discovery';

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: 6379,
  password: process.env.REDIS_PASSWORD,
});

const backend = new RedisRegistryBackend(redis, {
  keyPrefix: 'myapp:discovery:',  // default: 'hazeljs:discovery:'
  ttl: 90,                        // seconds, default: 90
});

const registry = new ServiceRegistry(config, backend);

// Shutdown: close the Redis connection
process.on('SIGTERM', () => backend.close());

Consul

HashiCorp Consul with TTL-based health checks. TTL supports "30s", "5m", "1h" formats.

import Consul from 'consul';
import { ConsulRegistryBackend } from '@hazeljs/discovery';

const consul = new Consul({ host: 'localhost', port: 8500 });
const backend = new ConsulRegistryBackend(consul, {
  ttl: '30s',
  datacenter: 'dc1',
});

const registry = new ServiceRegistry(config, backend);
process.on('SIGTERM', () => backend.close());

Kubernetes

Read-only discovery from the Kubernetes Endpoints API. Registration, deregistration, and heartbeat are no-ops — Kubernetes manages those through Services, Endpoints, and readiness probes.

import { KubeConfig } from '@kubernetes/client-node';
import { KubernetesRegistryBackend } from '@hazeljs/discovery';

const kubeConfig = new KubeConfig();
kubeConfig.loadFromDefault(); // or loadFromCluster() inside a pod

const backend = new KubernetesRegistryBackend(kubeConfig, {
  namespace: 'default',
  labelSelector: 'app.kubernetes.io/managed-by=hazeljs',
});

// Use for discovery only — registration is a no-op in K8s
const client = new DiscoveryClient({}, backend);

Complete Example: Microservices Calling Each Other

import { HazelApp, HazelModule, Service, Controller, Get, Param } from '@hazeljs/core';
import {
  ServiceRegistry,
  DiscoveryClient,
  ServiceClient,
  ServiceRegistryDecorator,
  InjectServiceClient,
  RedisRegistryBackend,
} from '@hazeljs/discovery';
import Redis from 'ioredis';

// ─── Shared Redis backend ─────────────────────────────────────────────────────
const redis = new Redis({ host: process.env.REDIS_HOST! });
const backend = new RedisRegistryBackend(redis, { keyPrefix: 'demo:discovery:' });

// ─── Order Service: registers itself, calls user-service ─────────────────────

@ServiceRegistryDecorator({
  name: 'order-service',
  port: 3001,
  healthCheckPath: '/health',
  zone: process.env.ZONE || 'us-east-1',
  metadata: { version: '1.0.0' },
})
@HazelModule({ controllers: [OrderController] })
export class OrderModule {}

@Service()
export class OrderService {
  constructor(
    @InjectServiceClient('user-service')
    private userClient: ServiceClient,
  ) {}

  async getOrderWithUser(orderId: string) {
    // ServiceClient auto-discovers user-service, load-balances, retries
    const { data: user } = await this.userClient.get('/users/me');
    return { orderId, user, status: 'shipped' };
  }
}

@Controller('/orders')
@Service()
export class OrderController {
  constructor(private orderService: OrderService) {}

  @Get('/:id')
  async getOrder(@Param('id') id: string) {
    return this.orderService.getOrderWithUser(id);
  }
}

// ─── Startup ──────────────────────────────────────────────────────────────────

const app = new HazelApp({ module: OrderModule });
await app.listen(3001);
console.log('order-service running on :3001');

Smart Retry Logic

ServiceClient retries only on errors that are transient by nature. Client errors are never retried:

ErrorRetried?Reason
ECONNREFUSED, network timeoutYesService may be restarting
502 Bad GatewayYesUpstream may be temporarily down
503 Service UnavailableYesService temporarily overloaded
504 Gateway TimeoutYesTransient timeout
408 Request TimeoutYesTransient timeout
429 Too Many RequestsYesRate limit — retry after delay
400 Bad RequestNoInvalid input — retry won't help
401 UnauthorizedNoAuth issue — retry won't help
403 ForbiddenNoPermission issue — retry won't help
404 Not FoundNoResource doesn't exist
Other 4xxNoClient error — retry won't help

Custom Logging

The default logger writes to console with a [discovery] prefix. Plug in Winston, Pino, or any other logger:

import { DiscoveryLogger } from '@hazeljs/discovery';

DiscoveryLogger.setLogger({
  debug: (msg, ...args) => logger.debug(msg, ...args),
  info: (msg, ...args) => logger.info(msg, ...args),
  warn: (msg, ...args) => logger.warn(msg, ...args),
  error: (msg, ...args) => logger.error(msg, ...args),
});

// Reset to default console logger
DiscoveryLogger.resetLogger();

Config Validation

All configuration is validated at construction time. Invalid configs throw ConfigValidationError immediately with a clear message — no silent failures at runtime:

import { ServiceRegistry, ConfigValidationError } from '@hazeljs/discovery';

try {
  const registry = new ServiceRegistry({ name: '', port: -1 });
} catch (err) {
  if (err instanceof ConfigValidationError) {
    console.error(err.message);
    // => 'ServiceRegistryConfig: "name" is required and must be a non-empty string'
  }
}

API Reference

ServiceRegistry

class ServiceRegistry {
  constructor(config: ServiceRegistryConfig, backend?: RegistryBackend);
  register(): Promise<void>;      // Register and start heartbeat
  deregister(): Promise<void>;    // Remove registration and stop heartbeat
  getInstance(): ServiceInstance | null;
  getBackend(): RegistryBackend;
}

DiscoveryClient

class DiscoveryClient {
  constructor(config?: DiscoveryClientConfig, backend?: RegistryBackend);
  getInstances(name: string, filter?: ServiceFilter): Promise<ServiceInstance[]>;
  getInstance(name: string, strategy?: string, filter?: ServiceFilter): Promise<ServiceInstance | null>;
  getAllServices(): Promise<string[]>;
  clearCache(serviceName?: string): void;
  getLoadBalancerFactory(): LoadBalancerFactory;
  close(): void;
}

ServiceClient

class ServiceClient {
  constructor(discoveryClient: DiscoveryClient, config: ServiceClientConfig);
  get<T>(path: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
  post<T>(path: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
  put<T>(path: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
  delete<T>(path: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
  patch<T>(path: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
}

Configuration Types

interface ServiceRegistryConfig {
  name: string;                   // Required: unique service name
  port: number;                   // Required: listening port
  host?: string;                  // Auto-detected if omitted
  protocol?: 'http' | 'https' | 'grpc';  // default: 'http'
  healthCheckPath?: string;       // default: '/health'
  healthCheckInterval?: number;   // ms, default: 30000
  metadata?: Record<string, unknown>;
  zone?: string;                  // For zone-aware load balancing
  tags?: string[];
}

interface DiscoveryClientConfig {
  cacheEnabled?: boolean;         // default: false
  cacheTTL?: number;              // ms, default: 30000
  refreshInterval?: number;       // Background refresh, ms
}

interface ServiceClientConfig {
  serviceName: string;
  loadBalancingStrategy?: string; // default: 'round-robin'
  filter?: ServiceFilter;
  timeout?: number;               // ms, default: 5000
  retries?: number;               // default: 3
  retryDelay?: number;            // ms, default: 1000
}

Related

  • Gateway Package — API gateway with versioning, canary deployments, and circuit breaking, built on top of discovery
  • gRPC Package — High-performance RPC between services; supports protocol: 'grpc' in service registration
  • Resilience Package — Circuit breaker, retry, rate limiter, and timeout decorators

For the complete API reference and working examples, see the Discovery package on GitHub.