I’ve been thinking a lot lately about how modern applications handle complexity at scale. Whether you’re building for millions of users or designing a system that must remain resilient under heavy load, the way services communicate can make or break your architecture. That’s why I want to walk you through building event-driven microservices using NestJS, RabbitMQ, and MongoDB—a stack that balances developer experience with production-grade reliability.
Event-driven architecture lets services react to changes instead of constantly polling each other. It’s like having a team that only speaks up when something important happens. This approach reduces coupling, improves scalability, and makes your system more fault-tolerant. Have you ever wondered how platforms like Amazon or Netflix handle thousands of transactions per second without crumbling? A big part of the answer lies in event-driven design.
Let’s start by setting up our environment. We’ll use Docker to run RabbitMQ and MongoDB locally, ensuring consistency between development and production.
# docker-compose.yml
version: '3.8'
services:
rabbitmq:
image: rabbitmq:3.12-management
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: password
mongodb:
image: mongo:7
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password
With infrastructure ready, we define events—the messages that will flow between services. Events represent something that has already happened, like an order being created or a payment processed.
// shared/events/order.events.ts
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number
) {}
}
Now, let’s build our first microservice: the order service. Using NestJS, we can quickly set up a service that listens for commands and emits events.
// order-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { OrderModule } from './order.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
OrderModule,
{
transport: Transport.RMQ,
options: {
urls: ['amqp://admin:password@localhost:5672'],
queue: 'order_queue',
queueOptions: { durable: true },
},
}
);
await app.listen();
}
bootstrap();
How do we ensure that events are handled reliably? RabbitMQ acts as a message broker, persisting messages until they’re processed. If a service goes down, messages wait in the queue, preventing data loss.
In the order service, we might have a handler that creates an order and publishes an event:
// order-service/src/order.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class OrderService {
constructor(private eventEmitter: EventEmitter2) {}
async createOrder(orderData: any) {
// Save order to MongoDB
const order = await this.orderModel.create(orderData);
// Emit event
this.eventEmitter.emit(
'order.created',
new OrderCreatedEvent(order.id, order.customerId, order.totalAmount)
);
return order;
}
}
Another service, like payments, can listen for this event and act accordingly. This separation allows each service to focus on its domain without knowing about others.
What happens when things go wrong? We implement dead letter queues for error handling. If a message fails processing repeatedly, it’s moved to a separate queue for investigation.
// payment-service/src/main.ts
options: {
urls: ['amqp://localhost:5672'],
queue: 'payment_queue',
queueOptions: {
durable: true,
arguments: {
'x-dead-letter-exchange': 'dlx.exchange',
'x-dead-letter-routing-key': 'payment.dlq'
}
}
}
Event sourcing complements this architecture by storing all state changes as a sequence of events. We can reconstruct past states or build read-optimized views using MongoDB.
// event-store.service.ts
async saveEvent(aggregateId: string, event: any) {
await this.eventModel.create({
aggregateId,
type: event.constructor.name,
data: event,
timestamp: new Date()
});
}
Testing is crucial. We can unit test event handlers and integration test the entire flow using tools like Jest and TestContainers.
// order.service.spec.ts
it('should emit OrderCreatedEvent when order is placed', async () => {
const emitSpy = jest.spyOn(eventEmitter, 'emit');
await orderService.createOrder(testOrder);
expect(emitSpy).toHaveBeenCalledWith('order.created', expect.any(OrderCreatedEvent));
});
Monitoring is the final piece. By tracking message rates, processing times, and errors, we gain visibility into the system’s health. Tools like Prometheus and Grafana can visualize this data.
As we wrap up, remember that event-driven microservices aren’t just a technical choice—they’re a way to build systems that grow with your needs. I hope this guide gives you a solid foundation to start building your own. If you found this helpful, feel free to share it with others who might benefit. I’d love to hear about your experiences or answer any questions in the comments below.