Skip to content

Microservices ​

@hazejs/microservices lets hazejs services talk to each other with message patterns, in the style of NestJS microservices. A service declares handlers for patterns such as orders.find; other services call them with send() (request / reply) or emit() (events, fire-and-forget). The same handlers work over Redis, NATS, gRPC or an in-memory transport for tests.

Paths

Examples on this page use the API template layout (app/..., config/...). In a full-stack project these live under server/.

Installation ​

bash
pnpm add @hazejs/microservices
# then the client library of the transport you use
pnpm add ioredis                              # redis
pnpm add @nats-io/transport-node              # nats
pnpm add @grpc/grpc-js @grpc/proto-loader     # grpc

The transport libraries are optional peer dependencies and are loaded only when a transport of that driver is used. The memory transport needs nothing.

Register the provider:

ts
// app/provider/MicroserviceProvider.ts
import { microservicesProvider } from '@hazejs/microservices'

// Handlers in app/message are registered automatically
export default microservicesProvider()

microservicesProvider({ handlers }) registers additional handlers that are not in app/message, for example handlers from a shared package or defined in a test.

Configuration ​

ts
// config/microservices.ts
import { env } from '@hazejs/core'
import { defineMicroservicesConfig } from '@hazejs/microservices'

export default defineMicroservicesConfig(() => ({
  default: env('MICROSERVICE_TRANSPORT', 'nats'),
  // This service's name: the queue group on NATS and memory
  name: 'orders',
  timeout: 5000,
  transports: {
    nats: { driver: 'nats', servers: env('NATS_URL', 'nats://127.0.0.1:4222') },
    redis: { driver: 'redis', connection: 'default' }, // a connection from config/redis.ts
    grpc: { driver: 'grpc', url: '0.0.0.0:50051', protoPath: 'proto/orders.proto', package: 'orders' },
    memory: { driver: 'memory' },
  },
}))
OptionDescription
defaultTransport used by Microservice.send() / emit() and client() without a name. Defaults to the first one
nameService name, used as the queue group. Instances with the same name share the work; different services each receive events. Without it, every instance receives every message
timeoutDefault send() timeout in milliseconds. Default 5000
listenTransports the listener serves. Default: only default
autoListenStart listening when the app boots (monoliths, tests). Default false
transportsNamed transports, see Transports

Message handlers ​

Each file in app/message/*.ts default-exports a handler, or an array of handlers, created with defineMessageHandler(pattern, options?, handle):

ts
// app/message/orders.ts
import { NotFoundException } from '@hazejs/core'
import { defineMessageHandler } from '@hazejs/microservices'
import * as v from 'valibot'
import { Order } from '~/app/model/Order'

export default [
  // Request / reply: the return value is sent back
  defineMessageHandler('orders.find', { schema: v.object({ id: v.number() }) }, async ({ id }) => {
    const order = await Order.find(id)
    if (!order)
      throw new NotFoundException(`Order ${id} not found`)
    return order
  }),

  // Event: the return value is ignored
  defineMessageHandler('orders.created', async (order: { id: number }, ctx) => {
    ctx.logger.info(`order ${order.id} created`)
  }),
]

A handler can serve both send() and emit(); ctx.kind tells you which ('request' or 'event').

OptionDescription
schemaStandard Schema (valibot, zod, arktype …) for the payload. A failure is returned to the caller as a 422 with issues
transportOnly handle the pattern on these transports ('grpc' or ['nats', 'redis']). Default: every listened transport

The context passed to the handler:

MemberDescription
ctx.pattern / ctx.kind / ctx.idThe message
ctx.transportThe transport name it arrived on
ctx.app / ctx.loggerThe application and a logger tagged with the pattern
ctx.container / ctx.make(key)A child container for this message, disposed afterwards

inject() works in the synchronous part of the handler, as in a constructor; use ctx.make() after an await.

Calling other services ​

ts
// app/controller/CheckoutController.ts
import type { HttpContext } from '@hazejs/core'
import { Microservice } from '@hazejs/microservices'

export class CheckoutController {
  async show(ctx: HttpContext) {
    const orders = Microservice.client('nats')
    const order = await orders.send<Order>('orders.find', { id: Number(ctx.param('id')) }, { timeout: 3000 })
    await orders.emit('orders.viewed', { id: order.id })
    return order
  }
}
  • Microservice.client(name?) returns a client for a configured transport. Microservice.send() / Microservice.emit() use the default transport.
  • send(pattern, data, { timeout }) resolves with the reply. timeout: 0 waits forever.
  • emit(pattern, data) resolves once the event is published, without waiting for handlers.
  • When the services share code, pass the handler definition instead of a string; payload and reply types are inferred: client.send(FindOrder, { id: 1 }).

Clients connect on first use and disconnect when the application shuts down. You can also resolve MicroserviceManager from the container (app.make(MicroserviceManager).client('nats')).

Errors ​

SituationError on the callerStatus
Handler threw an HttpException below 500 (NotFoundException, BusinessException …)RemoteError with the remote status, code and dataas remote
Payload failed the handler's schemaRemoteError with issues422
Handler threw anything elseRemoteError; the message is hidden from HTTP clients unless app.debug is on502
No reply within timeoutMicroserviceTimeoutError504
Nobody listens (NATS no responders, memory, gRPC server down, unmapped gRPC pattern)MicroserviceUnavailableError503
Listener has no handler for the patternRemoteError from NoHandlerError404

All of them extend HttpException, so an error that escapes a controller renders with a sensible status. RemoteError exposes pattern, remote (the serialized remote error) and issues. Errors thrown by event handlers are logged on the receiving side and never reach the emitter.

Payloads and replies are serialized as JSON (gRPC: protobuf), so dates arrive as strings and class instances as plain objects.

Running the listener ​

Handlers only receive messages while a listener runs. In development:

bash
npx haze microservice:listen                  # transports from `listen` (or `default`)
npx haze microservice:listen --transport nats,grpc

The command runs the app in the dev server's module graph without opening the HTTP port. Each message uses the latest code, so edits to a handler apply without a restart; adding a new pattern needs a restart.

In production, call listenMicroservices(app) from a worker entry, or set autoListen: true to listen inside the web process:

ts
// worker entry
import { listenMicroservices } from '@hazejs/microservices'

const listener = await listenMicroservices(app, { transports: ['nats'] })
process.once('SIGTERM', async () => {
  await listener.close() // stop consuming, let running handlers finish
  await app.dispose()
})

Transports ​

NATS ​

Uses the official @nats-io/transport-node client (the deprecated nats v2 package works too if it is installed instead).

OptionDescription
serversServer URL(s). Default nats://127.0.0.1:4222
prefixSubject prefix. By default the pattern is the subject (orders.created)
queueQueue group. Default: the config's name
optionsPassed to connect() (user, pass, token, tls …)

send() is a NATS request / reply and fails immediately with 503 when nobody subscribes. Listeners subscribe with the queue group, so each request and each event is handled by one instance per service, while every service gets its own copy of an event.

Redis ​

Uses ioredis.

OptionDescription
connectionA Redis connection from config/redis.ts. Default: the default connection (REDIS_URL)
urlInline address; takes precedence over connection. Standalone (outside an app), the default is REDIS_URL or redis://127.0.0.1:6379
prefixKey and channel prefix. Default haze:ms:
concurrencyRequests a listener processes at once. Default 10
optionsPassed to ioredis
  • send() pushes the request onto a list (<prefix>req:<pattern>). Listeners pop with BRPOP, so every request is handled by exactly one instance. Replies come back over pub/sub on a channel owned by the caller.
  • emit() publishes on <prefix>evt:<pattern>. Redis pub/sub has no queue groups: every listening instance receives every event. Make event handlers idempotent, or use NATS when each service must handle an event once.
  • Redis cannot tell that nobody is listening, so send() waits until the timeout. Expired requests are skipped when a listener picks them up later.
  • Connections: inside an application, requests and replies are published on the connection's shared command client; each transport opens one dedicated subscriber for replies, and a listener opens two more (BRPOP and event subscriptions). createTransport() / redisTransport() used standalone own all of their connections and close them in close(). The connection's keyPrefix applies to the request lists but not to the pub/sub channels.

gRPC ​

Node only, using @grpc/grpc-js and @grpc/proto-loader. Unary methods in your .proto file are mapped to message patterns, and the same handlers serve them.

proto
// proto/orders.proto
syntax = "proto3";
package orders;

service OrderService {
  rpc FindOne (OrderById) returns (Order);
}
message OrderById { int32 id = 1; }
message Order { int32 id = 1; string item = 2; }
ts
// config/microservices.ts (excerpt)
transports: {
  grpc: {
    driver: 'grpc',
    url: '0.0.0.0:50051',          // the listener binds here; clients connect here
    protoPath: 'proto/orders.proto',
    package: 'orders',
    methods: { 'OrderService.FindOne': 'orders.find' }, // otherwise the pattern is 'OrderService.FindOne'
  },
},
OptionDescription
urlAddress the listener binds to and clients connect to
protoPathOne or more .proto files
packageOnly use services from this proto package
methodsMethod → pattern. Unlisted methods use Service.Method
loader@grpc/proto-loader options. Defaults: keepCase, longs: String, enums: String, defaults, oneofs
credentials{ server, client } TLS credentials. Default: insecure
channelOptionsPassed to the gRPC server and client

Handler errors map to gRPC status codes (404 → NOT_FOUND, 422 → INVALID_ARGUMENT, 401 → UNAUTHENTICATED …) and the full error travels in the x-haze-error-bin metadata, so hazejs clients get the same RemoteError as on the other transports.

Limits: unary calls only (streaming methods answer UNIMPLEMENTED); payloads are encoded with the proto definition, so fields it does not declare are dropped; emit() is a unary call whose result is ignored, so the handler sees ctx.kind === 'request'; not available on Bun, Deno or edge runtimes.

Memory ​

In-process, for tests or for splitting a monolith into modules before deploying them separately. Clients and listeners in the same process share a bus (channel, default default). It behaves like a network transport: payloads go through JSON, send() fails with 503 when nobody listens, and queue groups balance work the way NATS does.

Custom transports ​

{ driver: 'custom', create: () => transport } plugs in your own implementation of the Transport interface (send, emit, listen, close). createTransport(config) and createClient(transport) also work outside a hazejs app.

Testing ​

Use the memory transport and listen in the test:

ts
// tests/orders.test.ts
import { listenMicroservices, MicroserviceManager } from '@hazejs/microservices'
import { useTestApp } from '@hazejs/testing'
import { expect, it } from 'vitest'

// .env.test: MICROSERVICE_TRANSPORT=memory (read by `default` in config/microservices.ts)
const t = useTestApp()

it('finds an order', async () => {
  await listenMicroservices(t.app)
  const order = await t.make(MicroserviceManager).client().send('orders.find', { id: 1 })
  expect(order).toMatchObject({ id: 1 })
})

Limitations ​

  • There is no message persistence or retry. Use queues for work that must survive restarts.
  • Patterns are subscribed when the listener starts; new patterns need a restart.
  • Redis events reach every instance (see above); the memory transport only works within one process.

Released under the MIT License.