When building applications with NestJS, sooner or later you'll need some form of asynchronous communication.
Maybe you want to send an email after a user registers, process a payment in the background, or notify another microservice when something changes.
You could call another service directly, but as your application grows, this creates dependencies between components. It also becomes harder to introduce queues, handle failures, or switch between messaging providers.
I've been working on an open-source library called @nestjstools/messaging to make this easier.
You can think of it as an alternative approach to messaging in NestJS, although it's not a direct replacement for @nestjs/microservices. Rather than focusing primarily on microservice communication, it provides a message bus abstraction with support for messages, multiple transports, and custom channels.
The idea is simple. You shouldn't have to write the same messaging infrastructure code every time you start a new project. Configure your message buses, choose a transport, and focus on your application logic.
In this article, I'll show you how to build a simple message-driven application using separate command and event buses. We'll start with in-memory messaging, then look at RabbitMQ, custom transports, and running consumers as background workers.
1. Create a NestJS project
Let's start with a new NestJS application.
npm install -g @nestjs/cli
nest new messaging-demo
cd messaging-demo
Install the library:
npm install @nestjstools/messaging
For now, that's everything we need. We won't be running RabbitMQ or any other external service yet.
2. Define a command and an event
Let's use a common example.
A user registers in our application, and we want to send them a welcome email.
There are two different things happening here.
First, we want to create a user. That's a command because we're asking the application to perform an action.
Once the user has been created, we want to notify other parts of the system. That's an event because we're describing something that already happened.
Let's create both messages.
src/messages/create-user.command.ts
export class CreateUser {
constructor(
public readonly email: string,
) {}
}
And our event:
src/messages/user-created.event.ts
export class UserCreated {
constructor(
public readonly userId: string,
public readonly email: string,
) {}
}
Both are regular TypeScript classes. We don't need to extend any framework-specific classes or implement additional interfaces.
The difference is their purpose.
CreateUser tells the system what we want to do, while UserCreated tells other parts of the application what has happened.
3. Configure the command and event buses
Now let's register the messaging module.
Instead of using one bus for everything, we'll create two separate buses.
-
command.busfor handling application commands. -
event.busfor publishing events.
Update src/app.module.ts:
import { Module } from '@nestjs/common';
import {
MessagingModule,
InMemoryChannelConfig,
} from '@nestjstools/messaging';
import { AppController } from './app.controller';
import { CreateUserHandler } from './handlers/create-user.handler';
import { SendWelcomeEmailHandler } from './handlers/send-welcome-email.handler';
@Module({
imports: [
MessagingModule.forRoot({
buses: [
{
name: 'command.bus',
channels: ['commands'],
},
{
name: 'event.bus',
channels: ['events'],
},
],
channels: [
new InMemoryChannelConfig({
name: 'commands',
}),
new InMemoryChannelConfig({
name: 'events',
}),
],
debug: true,
}),
],
controllers: [AppController],
providers: [
CreateUserHandler,
SendWelcomeEmailHandler,
],
})
export class AppModule {}
We're using two in-memory channels, one for each bus.
This gives us a useful separation between commands and events without introducing an external message broker.
The setup comes down to a few configuration options.
A bus is responsible for dispatching messages. A channel determines how those messages are delivered, and handlers contain the actual business logic.
Later, we can configure different transports for each bus.
For example, we could keep commands in memory while publishing events through RabbitMQ.
4. Create a command handler
Now we need something that handles the CreateUser command.
Create src/handlers/create-user.handler.ts:
import { Injectable } from '@nestjs/common';
import {
IMessageBus,
IMessageHandler,
MessageBus,
MessageHandler,
RoutingMessage,
} from '@nestjstools/messaging';
import { randomUUID } from 'node:crypto';
import { CreateUser } from '../messages/create-user.command';
import { UserCreated } from '../messages/user-created.event';
@Injectable()
@MessageHandler('user.create')
export class CreateUserHandler
implements IMessageHandler<CreateUser>
{
constructor(
@MessageBus('event.bus')
private readonly eventBus: IMessageBus,
) {}
async handle(command: CreateUser): Promise<void> {
const userId = randomUUID();
// In a real application, we'd persist the user here.
console.log(`Creating user ${userId}`);
await this.eventBus.dispatch(
new RoutingMessage(
new UserCreated(userId, command.email),
'user.created',
),
);
}
}
This handler is responsible for creating the user.
For simplicity, we're generating an ID and logging the operation. In a real application, you'd inject a repository and save the user to your database.
Notice that we're injecting event.bus into the command handler.
Once the user is successfully created, the handler dispatches a UserCreated event.
That means our command handler doesn't need to know which services are interested in the new user.
It simply publishes an event.
This is one of the things I like about working with message buses. Each part of the application has a clear responsibility.
One important detail: in a real application, publish UserCreated only after the user has been successfully persisted. If you need reliable publication alongside database writes, consider the transactional outbox pattern.
5. Create an event handler
Now let's handle the UserCreated event.
Create src/handlers/send-welcome-email.handler.ts:
import { Injectable } from '@nestjs/common';
import {
IMessageHandler,
MessageHandler,
} from '@nestjstools/messaging';
import { UserCreated } from '../messages/user-created.event';
@Injectable()
@MessageHandler('user.created')
export class SendWelcomeEmailHandler
implements IMessageHandler<UserCreated>
{
async handle(event: UserCreated): Promise<void> {
console.log(
`Sending welcome email to ${event.email}`,
);
}
}
The @MessageHandler('user.created') decorator tells the library which routing key this handler listens to.
Whenever a matching event is received, the library routes it to this handler.
In a real application, we'd inject an email service here.
The important part is that our CreateUserHandler doesn't know anything about SendWelcomeEmailHandler.
We could introduce additional handlers for analytics, notifications, or user profile initialization without modifying the command handler.
6. Dispatch the command from a controller
Now we can connect everything to an HTTP endpoint.
Update src/app.controller.ts:
import { Body, Controller, Post } from '@nestjs/common';
import {
IMessageBus,
MessageBus,
RoutingMessage,
} from '@nestjstools/messaging';
import { CreateUser } from './messages/create-user.command';
@Controller('users')
export class AppController {
constructor(
@MessageBus('command.bus')
private readonly commandBus: IMessageBus,
) {}
@Post()
async createUser(
@Body('email') email: string,
): Promise<string> {
await this.commandBus.dispatch(
new RoutingMessage(
new CreateUser(email),
'user.create',
),
);
return 'Command dispatched!';
}
}
Our controller doesn't create the user directly anymore.
It dispatches a command to command.bus.
The command handler performs the action and publishes an event through event.bus.
That event is then handled by our welcome email handler.
Let's test it.
Start the application:
npm run start:dev
Send a request:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"email":"john@example.com"}'
You should see output similar to:
Creating user 6efb...
Sending welcome email to john@example.com
We now have a working message flow with separate command and event buses.
This example uses in-memory channels, so everything runs within the same application process. It doesn't provide a distributed queue, and the dispatch call may wait for handlers to finish.
It's a simple way to understand the architecture before introducing a message broker.
7. How the message flow works
Here's what happens when someone sends a request to our application:
HTTP POST /users
|
v
Controller
|
| CreateUser
v
command.bus
|
v
CreateUserHandler
|
| Save user
|
| UserCreated
v
event.bus
|
v
SendWelcomeEmailHandler
|
v
Send Email
The controller is responsible for accepting the request and dispatching a command.
The command handler is responsible for executing the business operation.
The event handler reacts to something that has already happened.
This makes it easier to extend the application without introducing direct dependencies between services.
It's also a natural fit for applications using CQRS or event-driven architecture, although you don't need to implement full CQRS to benefit from separating commands and events.
8. Moving to RabbitMQ
So far, we've been using in-memory channels.
That's useful for getting started, but eventually, you might want your events to be processed by another microservice or a background worker.
This is where a message broker becomes useful.
Install the RabbitMQ extension:
npm install @nestjstools/messaging-rabbitmq-extension
For local development, start RabbitMQ using Docker:
docker run -d \
--name messaging-rabbitmq \
-p 5672:5672 \
-p 15672:15672 \
rabbitmq:management
The RabbitMQ management UI is available at http://localhost:15672.
You can use guest / guest to sign in locally.
Now we can keep the command bus in memory and configure RabbitMQ for the event bus.
The command still runs inside our application, but the UserCreated event can be delivered through RabbitMQ.
Here's an example of the messaging configuration:
import {
MessagingModule,
InMemoryChannelConfig,
} from '@nestjstools/messaging';
import {
MessagingRabbitmqExtensionModule,
RmqChannelConfig,
ExchangeType,
} from '@nestjstools/messaging-rabbitmq-extension';
// Inside your module:
imports: [
MessagingRabbitmqExtensionModule,
MessagingModule.forRoot({
buses: [
{
name: 'command.bus',
channels: ['commands'],
},
{
name: 'event.bus',
channels: ['rabbitmq-events'],
},
],
channels: [
new InMemoryChannelConfig({
name: 'commands',
}),
new RmqChannelConfig({
name: 'rabbitmq-events',
connectionUri:
'amqp://guest:guest@localhost:5672/',
exchangeName: 'users.events',
exchangeType: ExchangeType.TOPIC,
bindingKeys: ['user.#'],
queue: 'users.events.queue',
autoCreate: true,
enableConsumer: true,
}),
],
}),
],
In this example, RabbitMQ is configured for both publishing and consuming events.
In a distributed deployment, we would configure the publisher and consumer applications separately.
What matters is that our application logic doesn't change.
The controller still dispatches the command:
await this.commandBus.dispatch(
new RoutingMessage(
new CreateUser(email),
'user.create',
),
);
The command handler still publishes the event:
await this.eventBus.dispatch(
new RoutingMessage(
new UserCreated(userId, command.email),
'user.created',
),
);
And the event handler still handles user.created.
We only changed the messaging configuration.
This is where the library becomes particularly useful. You can introduce different messaging technologies without rewriting the business logic that dispatches and handles messages.
9. What about other messaging providers?
RabbitMQ isn't always the right choice.
Depending on your infrastructure, you might already be using Redis, NATS, AWS, or Azure.
The library provides extensions for several messaging technologies, including:
- RabbitMQ
- Redis
- NATS
- Amazon SQS
- Google Pub/Sub
- Azure Service Bus
- MQTT
The message bus API remains the same regardless of which transport you're using.
await this.eventBus.dispatch(
new RoutingMessage(payload, routingKey),
);
Of course, every broker works differently. Delivery guarantees, message ordering, acknowledgment, and retry behavior still depend on the chosen transport and its configuration.
The goal isn't to hide these differences completely. It's to avoid spreading broker-specific code across your application services.
You can also build your own transport
One thing worth mentioning is that you're not limited to the existing extensions.
The library is designed to be extensible, so you can implement your own channel when you need to integrate with a transport or messaging system that isn't supported out of the box.
For example, maybe your company uses an internal messaging platform, or you need to communicate through a custom protocol.
You can create a channel implementation that integrates with that system while keeping the same message bus and handler abstractions in your application.
Your services can continue dispatching messages through IMessageBus, without needing to know the details of your custom transport.
I think this is particularly useful when working on larger systems where infrastructure requirements can vary between projects.
10. Running consumers as background workers
There's another architectural decision worth considering once your application starts processing more messages.
Do you really want your HTTP server to handle background jobs as well?
For smaller applications, running both in the same process might be perfectly fine. But as the system grows, you may want to separate the API from message processing.
With the command and event buses we created earlier, we already have a useful starting point.
Our HTTP application can accept requests, process commands, and publish events.
A separate worker can consume those events and execute their handlers.
HTTP Server
|
| CreateUser
v
command.bus
|
v
CreateUserHandler
|
| UserCreated
v
event.bus
|
v
RabbitMQ
|
v
Background Worker
|
v
SendWelcomeEmailHandler
With this approach, the HTTP server focuses on serving requests, while the worker handles background processing.
Both applications can share the same message contracts and use the same messaging library.
For RabbitMQ, the channel configuration includes an enableConsumer option that controls whether an application instance should consume messages.
You can configure an API instance to publish events without running the consumer and a worker instance to process incoming messages.
This also gives you more flexibility when scaling.
For example, your API might need several instances to handle incoming traffic, while background processing might require a different number of workers.
The number of consumers and how messages are distributed between them still depend on the broker and queue configuration.
I've described this approach in more detail here:
Consumer as the Background Process
11. A few other useful features
So far, we've covered commands, events, basic message handling, and transports.
The library also provides several additional features that become useful as your application grows.
You can add middleware for common functionality such as logging, validation, or message transformation.
There are also message normalization mechanisms, lifecycle hooks, and centralized exception handling.
You can define multiple buses with different configurations, which is useful when commands and events have different processing requirements.
For example, commands might be handled in memory while events are published to RabbitMQ for consumption by other applications.
You don't need to introduce all these features at once. You can start with a simple configuration and extend it when your requirements change.
12. Things to consider in production
Introducing a message broker doesn't automatically make a system reliable.
When working with distributed messaging, there are a few things I always try to keep in mind.
Consumers should be idempotent where duplicate message delivery is possible. Retries and dead-letter queues need to be configured properly, and logs should make it possible to trace messages across services.
It's also worth thinking about what happens when a database transaction succeeds but publishing an event fails. In those cases, patterns like the transactional outbox can help.
These aren't problems specific to NestJS or this library. They're things you'll need to handle with most asynchronous messaging solutions.
Final thoughts
The example we built is intentionally simple, but it shows the approach I wanted to achieve with @nestjstools/messaging.
We started with two separate message buses. One handles commands, and the other publishes events.
Our controller dispatches a CreateUser command, the command handler creates the user, and the UserCreated event is processed independently by an event handler.
We can start with in-memory channels and later introduce RabbitMQ or another transport without rewriting that application flow.
Most of the initial work comes down to configuration. You choose a transport, register your handlers, and start dispatching messages.
If your requirements change, you can switch transports, implement your own channel, or move consumers into separate background workers.
I've been developing this library to make message-driven communication in NestJS easier to work with, particularly in applications using microservices or event-driven architectures.
The project is open source, and feedback is always welcome.
You can find it here:
- npm package
- GitHub repository
- Documentation
- RabbitMQ example
- Running consumers as background processes
If you find it useful, a GitHub star is always appreciated.
Top comments (0)