Discovery Package
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?
| Challenge | Without @hazeljs/discovery | With @hazeljs/discovery |
|---|---|---|
| Locating services | Hardcoded env vars per service | Discover by name at runtime |
| Scaling out | Update configs everywhere | Instances register themselves |
| Load balancing | Manual round-robin or external proxy | 6 built-in strategies |
| Failed instances | Manual removal or health checks per caller | Auto-removed via TTL/heartbeat |
| Infrastructure change | Redeploy every caller | Swap backend, no code change |
| Production backends | Custom Redis/Consul code | Drop-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
- ServiceRegistry — Registers this service on startup, sends heartbeats, deregisters on shutdown
- Registry Backend — Pluggable storage: Memory (dev), Redis, Consul, or Kubernetes
- DiscoveryClient — Discovers instances by name with optional cache and auto-refresh
- Load Balancer — Selects one instance from available ones using the chosen strategy
- 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:
| Error | Retried? | Reason |
|---|---|---|
ECONNREFUSED, network timeout | Yes | Service may be restarting |
| 502 Bad Gateway | Yes | Upstream may be temporarily down |
| 503 Service Unavailable | Yes | Service temporarily overloaded |
| 504 Gateway Timeout | Yes | Transient timeout |
| 408 Request Timeout | Yes | Transient timeout |
| 429 Too Many Requests | Yes | Rate limit — retry after delay |
| 400 Bad Request | No | Invalid input — retry won't help |
| 401 Unauthorized | No | Auth issue — retry won't help |
| 403 Forbidden | No | Permission issue — retry won't help |
| 404 Not Found | No | Resource doesn't exist |
| Other 4xx | No | Client 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.