Skip to content

@relaybus/nestjs ​

@relaybus/nestjs is the NestJS module of relaybus, a reliable, storage-agnostic event bus for NestJS and Node.js: declare handlers with @RelayBusConsumer and publish inside your database transaction, and every committed event reaches every handler at least once.

NestJS integration for relaybus: one module, one decorator, correct lifecycle.

Install ​

bash
npm install @relaybus/core @relaybus/nestjs

Peer dependencies: @nestjs/common and @nestjs/core (^11 || ^12), reflect-metadata, rxjs.

Quick start ​

ts
// app.module.ts
@Module({
  imports: [RelayBusModule.forRoot({ store: new PostgresEventStore({ pool }), source: 'orders' })],
})
export class AppModule {}

// shipping.handlers.ts - any provider of any module
@Injectable()
export class ShippingHandlers {
  @RelayBusConsumer(OrderPlaced)
  async onOrderPlaced(event: EventEnvelope<{ orderId: string }>) {
    /* ... */
  }
}

// order.service.ts
@Injectable()
export class OrderService {
  constructor(
    private readonly bus: RelayBus,
    private readonly transactions: RelayBusTransactionHost<PoolClient>,
  ) {}

  @RelayBusTransaction()
  async place(orderId: string): Promise<void> {
    await this.transactions.current().query('INSERT INTO orders (id) VALUES ($1)', [orderId]);
    await this.bus.publish(new OrderPlaced({ orderId })); // joins the transaction
  }
}

That is all: the module is global, the handler is discovered, the bus starts after bootstrap and stops on shutdown. Inject the bus anywhere with constructor(private readonly bus: RelayBus) {}; add the transaction type when you want it typed: RelayBus<PoolClient> (the generic is erased at runtime, so DI is unaffected).

Call app.enableShutdownHooks() in main.ts so SIGTERM/SIGINT close the app and let the bus drain running handlers. The whole bus.stop() is bounded by dispatcher.shutdownTimeout (default 30s): past it, running handlers are abandoned (their leases expire and another pod runs them again) and the connections relaybus opened itself are force-closed, so a hung query or broker cannot keep the pod alive until Kubernetes kills it. Keep the timeout below the pod's terminationGracePeriodSeconds, leaving room for the rest of app.close():

ts
// main.ts
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);

// app.module.ts, for a pod with terminationGracePeriodSeconds: 30
RelayBusModule.forRoot({ store, dispatcher: { shutdownTimeout: '25s' } });

RelayBusModule ​

  • forRoot(options)
  • forRootAsync({ imports?, inject?, useFactory }), or useClass / useExisting (a class with create(): RelayBusModuleOptions | Promise<RelayBusModuleOptions>, see RelayBusModuleOptionsFactory)

Both accept isGlobal next to the options. It defaults to true; with false, import the module wherever RelayBus is injected. RelayBus and RelayBusHealth are exported.

Options ​

Everything of core's RelayBusOptions passes through unchanged, except logger: the bus always logs through a Nest Logger with context RelayBus, so app.useLogger(...) controls it.

OptionDefaultNotes
storerequiredAn EventStore: @relaybus/postgres, @relaybus/mongodb, InMemoryEventStore
source'relaybus'Service name recorded on every event; set it to your app name
transactionMode'required''optional' lets publish open its own transaction
transactionResolvernoneFinds the caller's active transaction (see below)
transactionTimeout'5s'Budget of every bus.transaction / @RelayBusTransaction() (see below)
retrycore defaultDefault retry policy of every handler and route
handlerTimeoutfalseDefault handler timeout
dispatcher.enabledtruefalse on publish-only processes (API pods, scripts)
dispatcher.concurrency10Handlers running at once
dispatcher.batchSizeconcurrencyDeliveries claimed per query
dispatcher.pollInterval'1s'Wait between claims when idle
dispatcher.leaseDuration'30s'Renewed every third of it
dispatcher.shutdownTimeout'30s'Longest the whole shutdown takes; past it, handlers are abandoned
retention.completed / .dead / .interval'7d' / false / '1h'Cleanup of finished deliveries
transports, routes, subscriptionsnoneBroker fan-out and fan-in (@relaybus/kafka, @relaybus/rabbitmq)
setupStoretrueCreate tables/indexes on start
observersnoneMetrics and tracing hooks
clock, generateIdentifier, randomsystemMainly for tests
isGlobaltrueModule-level, see above

@RelayBusConsumer(event, options?) ​

The default way to handle events: put @RelayBusConsumer on any method of any provider.

ts
@Injectable()
export class ShippingHandlers {
  @RelayBusConsumer(OrderPlaced)
  async shipOrder(event: EventEnvelope<{ orderId: string }>) {}

  @RelayBusConsumer(UserRegistered, { id: 'shipping.welcome-pack', retry: { attempts: 5 } })
  async sendWelcomePack(event: EventOf<typeof UserRegistered>, context: HandlerContext) {}
}
ts
@RelayBusConsumer(OrderPlaced)                                   // class
@RelayBusConsumer('order.placed.v1', { concurrency: 2 })         // type string
@RelayBusConsumer(OrderPlaced, { id: 'billing.invoice', transactional: true, timeout: '10s' })
  • No class-level setup. No catalog, interface or base class: method names are free, and one class may handle events of different catalogs (or of none).
  • Typed at compile time. With a class argument the first parameter must be the envelope of that event: @RelayBusConsumer(OrderPlaced) ship(event: EventEnvelope<{ userId: number }>) does not compile.
  • Options are core's SubscribeOptions with id optional. Methods receive (envelope, context).

Names. The relaybus decorators share the RelayBus prefix (@RelayBusConsumer, @RelayBusCatalogConsumer, @RelayBusTransaction), so they never collide with @nestjs/event-emitter's @OnEvent or @nestjs/cqrs. @Subscribe and @EventConsumer are the earlier names of @RelayBusConsumer and @RelayBusCatalogConsumer: they still work, are the same functions, are marked @deprecated (editors strike them through) and are removed in 1.0.0. @RelayBusConsumer only compiles on a method and takes an event; @RelayBusCatalogConsumer only compiles on a class and takes a catalog.

Rules, all enforced at boot (the application fails to start, no silent skips):

  • Stable ids. The default id is ClassName.methodName. Deliveries are stored under the id, so renaming the class or method orphans pending deliveries; pin { id } on handlers that matter.
  • Duplicate ids fail init with both locations named. A method with several @RelayBusConsumer decorators must give each an explicit id.
  • Singletons only. Handlers on request-scoped or transient providers (or default-scoped providers that depend on one) are rejected: deliveries run outside any request, so there is no request instance to call. This covers class, useClass and useFactory providers (a factory is checked through its class token; a request-scoped factory under a string token cannot be inspected). Providers without @RelayBusConsumer are never inspected for scope.
  • this is bound to the provider instance. Only providers are scanned, in every module. A handler reachable through several tokens (useExisting) is registered once. Declarations are stored per class and method, so decorators that wrap the method later do not drop them, and subclasses inherit the handlers of their base class.

Module contracts: RelayBusModule.forFeature(...) ​

The recommended way to use catalogs in NestJS. A feature module states which events it consumes and produces; the method-level @RelayBusConsumer handlers of its providers fulfil the contract, spread over any classes and method names.

ts
@Module({
  imports: [
    RelayBusModule.forFeature({
      consumes: [OrderEvents], // or consume(OrderEvents, { events: ['OrderPlaced'] })
      produces: [produce(BillingEvents, { events: ['InvoiceIssued'] })],
    }),
  ],
  providers: [ChargeHandlers, RefundHandlers],
})
export class BillingModule {}

When the application initialises:

  • every consumed event needs a @RelayBusConsumer method (or a @RelayBusCatalogConsumer class) among the providers of that module; consumers in other modules do not count;
  • every produced event needs a route to a transport (routes of forRoot).

Otherwise the application fails to start with one line per gap, for example [RelayBusHandlerExplorer](assertModuleContracts) BillingModule: OrderEvents.OrderCancelled (order.cancelled.v1) has no consumer. forRoot({ catalogs }) keeps working as the application-wide check (handlers anywhere).

Stricter checking (optional): catalogs and @RelayBusCatalogConsumer(catalog, options?) ​

Method-level handlers are enough for most services. When a team wants the compiler and the boot to prove that every event of a shared contract is handled, group the events in a catalog and let a class consume it. Everything here is optional and can be added later, next to @RelayBusConsumer methods.

ts
export const OrderEvents = defineCatalog({ OrderPlaced, OrderShipped }, { name: 'OrderEvents' });

@Injectable()
@RelayBusCatalogConsumer(OrderEvents, { transactional: true })
export class ShippingHandlers implements HandlersOf<typeof OrderEvents> {
  onOrderPlaced(event: EventOf<typeof OrderPlaced>, context: HandlerContext) {}
  onOrderShipped(event: EventOf<typeof OrderShipped>) {}
}

RelayBusModule.forRoot({ store, catalogs: [OrderEvents] });

Registers every on${EventName} method the class defines, under the stable id ClassName.on${EventName}, with this bound to the provider. implements HandlersOf<...> makes the compiler reject a class that misses a handler; PartialHandlersOf<C, 'OrderShipped'> documents a deliberate subset. Options are core's SubscribeOptions without id, applied to every handler of the class.

  • catalogs: [OrderEvents] in the module options is the boot check: if no provider (via @RelayBusCatalogConsumer, @RelayBusConsumer or a configured route) handles an event, the application fails to start with the list of missing events and the methods they expect.
  • It works next to @RelayBusConsumer in the same class. A @RelayBusConsumer that would take the same default id (@RelayBusConsumer(OrderPlaced) onOrderPlaced()) fails init naming both; give it an explicit id.
  • Same rules as @RelayBusConsumer: singleton providers only, useExisting aliases register once, subclasses inherit the decoration and use their own class name in ids.
  • A class decorated with @RelayBusCatalogConsumer that defines none of the catalog's methods fails init, which catches misspelled method names.
  • Declarations are inherited from base classes; a subclass that declares the same catalog again replaces the inherited declaration, and one catalog declared twice on one class throws.

Microservices: consuming part of a shared catalog ​

ts
// shipping service
@Injectable()
@RelayBusCatalogConsumer(OrderEvents, { events: ['OrderPlaced'] })
export class ShippingHandlers {
  onOrderPlaced(event: EventOf<typeof OrderPlaced>) {}
}

RelayBusModule.forRoot({
  store,
  transports: [rabbit],
  subscriptions: [{ transport: 'rabbit', destinations: ['orders'] }],
  routes: [{ event: OrderShipped, transport: 'rabbit', destination: 'shipments' }],
  catalogs: [
    consume(OrderEvents, { events: ['OrderPlaced'] }),
    produce(OrderEvents, { events: ['OrderShipped'] }),
  ],
});
  • With events, exactly those methods are registered (other on${EventName} methods are ignored), and a class that misses one of them does not compile and fails init.
  • consume(...) fails the boot when a consumed event has no handler in the application; produce(...) fails it when a produced event has no route. See @relaybus/core, "Microservices: shared catalogs".

Lifecycle ​

  1. onModuleInit: handlers are discovered and registered on the bus.
  2. onApplicationBootstrap: bus.start() creates the schema (unless setupStore: false), connects transports and starts the dispatcher. If it rejects (database down, broker unreachable) the application does not boot.
  3. beforeApplicationShutdown: bus.stop() stops claiming, waits for running handlers, then closes transports and the store, all within shutdownTimeout (it force-closes and resolves when the deadline passes, see above). Needs app.enableShutdownHooks() for signals; app.close() runs it regardless. Nest runs onApplicationShutdown hooks (where TypeORM, Mongoose and most pools disconnect) after this, so handlers still have their connections while draining. If your own provider disconnects in onModuleDestroy (common with Prisma), move it to onApplicationShutdown.

Transactions ​

@RelayBusTransaction() ​

Runs an async method in a relaybus transaction. Every bus.publish(...) inside it, and inside everything it awaits, joins the transaction without { transaction }; the transaction commits when the method resolves and rolls back when it throws.

ts
// Before: the transaction is threaded through by hand
async place(orderId: string): Promise<void> {
  await this.bus.transaction(async (tx) => {
    await tx.query('INSERT INTO orders (id) VALUES ($1)', [orderId]);
    await this.bus.publish(new OrderPlaced({ orderId }), { transaction: tx });
  });
}

// After
@RelayBusTransaction()
async place(orderId: string): Promise<void> {
  await this.orders.insert(orderId); // the repository reads RelayBusTransactionHost.current()
  await this.bus.publish(new OrderPlaced({ orderId }));
}
  • Self-calls are transactional. The decorator replaces the method itself (not an interceptor or proxy), so this.place() from another method of the class opens the transaction too.
  • Nested calls share one transaction. A decorated method called inside another one, inside bus.transaction, inside a transactional handler, or while transactionResolver finds a transaction (for example @nestjs-cls/transactional) joins it instead of opening a second one.
  • No injection needed. The bus is the one of the application's RelayBusModule. Calling the method before the application initialised, after it shut down, or with no RelayBusModule throws a ConfigurationError that says so.
  • Async methods only. On a method that does not return a Promise it fails to compile: '@RelayBusTransaction() needs a method that returns a Promise: make the method async'.

Isolation levels: @RelayBusTransaction({ isolationLevel }) ​

@RelayBusTransaction({ isolationLevel: 'serializable', maxAttempts: 5 }) (and bus.transaction(work, { isolationLevel, maxAttempts })) runs the method at 'read committed', 'repeatable read' or 'serializable' (PostgreSQL stores; MongoDB and the in-memory store reject a level with a ConfigurationError). At 'repeatable read' and 'serializable' a serialization failure (SQLSTATE 40001) rolls the attempt back and runs the method again in a new transaction, up to maxAttempts attempts in all (default 3); the last failure is thrown unchanged. That lets PostgreSQL detect write skew (count the active rows, check a limit, insert) without a pg_advisory_xact_lock or SELECT ... FOR UPDATE.

ts
@RelayBusTransaction({ isolationLevel: 'serializable' })
async reserve(userId: string): Promise<void> {
  if ((await this.reservations.countActive(userId)) >= LIMIT) throw new LimitReachedError(userId);
  await this.reservations.insert(userId);
  await this.bus.publish(new ReservationCreated({ userId }));
}
  • The method may run more than once. Keep HTTP calls, broker publishes and other side effects that are not database writes out of it. Events published by a failed attempt are rolled back with it, and onPublished and the dispatcher wake-up wait for the attempt that committed.
  • Joining. A method with an isolationLevel joins a running transaction only when relaybus opened it at the same level; otherwise it throws TransactionIsolationConflictError (RELAYBUS_TRANSACTION_ISOLATION_CONFLICT), because the level of a running transaction cannot be changed. A method without isolationLevel joins any transaction, as before.
  • Without isolationLevel nothing differs from @RelayBusTransaction(): same statements, no rerun.

See Serializable transactions.

Time budget: transactionTimeout ​

Every relaybus transaction (bus.transaction and @RelayBusTransaction()) is held to transactionTimeout (default '5s'). The database enforces it where it can, so a transaction waiting on something slow is ended by the server and its row locks are released:

  • PostgreSQL: SET LOCAL statement_timeout, and with the pg executor also idle_in_transaction_session_timeout and, on PostgreSQL 17+, transaction_timeout (detected per transaction). Adapters other than pg get statement_timeout only, because a server-ended session would leave their checked-out connection without an error listener.
  • MongoDB: maxCommitTimeMS on the commit.

A transaction that still runs past the budget is rolled back before it commits. Either way the caller gets a TransactionTimeoutError (RELAYBUS_TRANSACTION_TIMEOUT, with the database error as cause) whose message points at the usual cause: a slow call to another service inside the transaction. Move HTTP calls before the transaction or after it commits (the @relaybus/eslint-plugin rule no-external-call-in-transaction catches them) instead of raising the budget.

Repositories: RelayBusTransactionHost<T> ​

Inject RelayBusTransactionHost typed with your store's transaction handle. current() returns the active transaction and throws TransactionRequiredError outside one; find() returns undefined instead. Outside Nest, bus.currentTransaction() does the same as find().

Store / executorT of RelayBusTransactionHost<T>Write with
PostgresEventStore with pgPoolClienttx.query(sql, values)
createKyselyExecutor(db)Transaction<DB>tx.insertInto('orders')...execute()
createTypeOrmExecutor(dataSource)EntityManagertx.getRepository(Order).save(order)
createPrismaExecutor(prisma)Prisma.TransactionClienttx.order.create({ data })
MongoEventStoreClientSessioncollection.insertOne(doc, { session: tx })

Without a decorator or bus.transaction, publish needs a transaction: pass { transaction }, configure transactionResolver, or set transactionMode: 'optional' (event stays durable but is no longer atomic with your writes).

Transactional handlers ​

ts
@RelayBusConsumer(PaymentCaptured, { id: 'ledger.record-capture', transactional: true })
async record(event: EventEnvelope<{ paymentId: string }>, context: HandlerContext<PoolClient>) {
  // Writes through context.transaction commit atomically with the delivery completion:
  // effectively-once processing. Keep these handlers short.
  await context.transaction!.query('INSERT INTO ledger ...', [event.payload.paymentId]);
  await this.bus.publish(new LedgerUpdated({ ... }), { transaction: context.transaction! });
}

Non-transactional handlers are at-least-once: make them idempotent (deduplicate on event.id).

ORM integration ​

Prefer transactionResolver, so services call bus.publish(event) inside whatever transaction the ORM already opened. The resolver is called synchronously and must return undefined when there is no active transaction.

ts
// @nestjs-cls/transactional (not a dependency of this package)
RelayBusModule.forRootAsync({
  inject: [TransactionHost],
  useFactory: (txHost: TransactionHost) => ({
    store,
    transactionResolver: () => (txHost.isTransactionActive() ? txHost.tx : undefined),
  }),
});
  • Prisma / raw pg: @relaybus/postgres store; Prisma's interactive transaction client is adapted by the store package, see its README.
  • TypeORM: @relaybus/postgres over the same DataSource connection; resolve the active EntityManager's query runner in transactionResolver.
  • MongoDB: @relaybus/mongodb store; the transaction is the ClientSession (needs a replica set).

Health ​

RelayBusHealth (exported by the module) has no @nestjs/terminus dependency.

ts
const report = await health.check();
// { status: 'up' | 'down', running, ready, handlers, statistics?, lagMilliseconds?, error? }

down when the bus is not running, a transport is not ready (ready is bus.isReady(): for example a dead Kafka consumer or a disconnected RabbitMQ channel) or the store cannot be queried. lagMilliseconds is how long the oldest due delivery has waited (measured with the clock from the module options, the system clock by default), a good alert signal. With terminus 11:

ts
@Injectable()
export class RelayBusIndicator {
  constructor(
    private readonly indicators: HealthIndicatorService,
    private readonly relayBus: RelayBusHealth,
  ) {}

  async isHealthy(key = 'relaybus') {
    const indicator = this.indicators.check(key);
    const { status, ...details } = await this.relayBus.check();
    return status === 'up' ? indicator.up(details) : indicator.down(details);
  }
}

Startup failure and restarts ​

The bus starts during application bootstrap. If the start fails (store setup, transport connect), application boot fails with that error. A bus whose start failed is stopped for good and cannot be started again: restart the process rather than retrying in place.

Testing ​

Use @nestjs/testing with InMemoryEventStore and turn the dispatcher off so tests control time:

ts
const moduleRef = await Test.createTestingModule({
  imports: [
    RelayBusModule.forRoot({ store: new InMemoryEventStore(), dispatcher: { enabled: false } }),
    AppFeatureModule,
  ],
}).compile();
await moduleRef.init();

await moduleRef.get(OrderService).place('o-1');
await moduleRef.get(RelayBus).drain(); // runs every due delivery, including handlers' follow-up events
// assert on side effects
await moduleRef.close();
  • bus.runOnce() runs a single batch; bus.drain() loops until nothing is due.
  • Retries and delays are scheduled in the future: pass clock: new FakeClock() from @relaybus/testing, clock.advance(5_000), then drain() again.
  • bus.listDeliveries({ status: 'dead' }) shows handlers that exhausted their retries.

Troubleshooting ​

  • Nest can't resolve dependencies of ... (?) when injecting RelayBus: the module is not global (isGlobal: false) and not imported by the consuming module.
  • Handler never runs: the provider is not listed in any module providers, the process runs with dispatcher.enabled: false, or the event type differs (check bus.handlers()).
  • uses @RelayBusConsumer but its provider is request-scoped or transient: make the provider a singleton; inject request data through the event payload instead.
  • Vitest/esbuild: esbuild does not emit decorator metadata; use explicit @Inject(Token) in test classes.
  • App hangs on shutdown: a handler ignores context.signal; it is waited for until dispatcher.shutdownTimeout.

When to use @relaybus/nestjs, and when not to ​

Use it when your service is a NestJS 11 or 12 application and you want decorator-based handlers, lifecycle integration and a health check. It needs @relaybus/core and a store; for other frameworks use @relaybus/core directly.

Documentation: https://berkcansavur.github.io/relaybus/packages/nestjs

relaybus vs alternatives ​

What it isSurvives a restartWritten in your database transactionChoose it when
relaybusEvent bus built on the transactional outbox: PostgreSQL and MongoDB stores, Kafka and RabbitMQ transports, per-handler deliveriesYes, events live in your databaseYesEvents must never be lost or phantom, and handlers need retries, dead letters and ordering keys
@nestjs/event-emitterIn-process event emitter built on eventemitter2No persistence is documentedNoSimple in-process notifications where losing an event on restart is acceptable
@nestjs/cqrsCommandBus, QueryBus, EventBus and sagas; the docs say sagas and event handlers run in memoryNo built-in persistenceNoYou want CQRS structure; it can sit on top of an outbox
@nestjs/outboxTransactional outbox module from the NestJS organisation: PostgreSQL, MySQL and in-memory stores, relay with leases and retries, dead-letter table, inboxYes with the database storesYesYou need MySQL, or a NestJS-organisation module; compare features with relaybus before choosing
BullMQJob queue on Redis with retries, delayed jobs and flows; at-least-once in the worst caseYes, in RedisNo, jobs are stored in Redis and cannot join a SQL or MongoDB transactionYour stack is Redis-based and background jobs matter more than event atomicity
pg-bossJob queue on PostgreSQL using SKIP LOCKED, with retries, dead letter queues, scheduling and pub/sub; jobs can be created in your own transactionYes, in PostgreSQLYes, through its database adaptersYou want a PostgreSQL job queue with scheduling; relaybus is an event bus with per-handler deliveries and transports
Kafka client directlyA distributed log; the idempotent producer and transactions give exactly-once within KafkaYes, in KafkaNo, producing to Kafka is a second writeYou consume and produce only inside Kafka; add an outbox when a database write must be announced

This table summarises the vendors' own documentation as of October 2026; check their docs for current details. Full comparison and sources: https://berkcansavur.github.io/relaybus/comparison.

Released under the MIT License.