I’ve been thinking a lot about building systems that can grow without breaking. You know that moment when your application starts getting real traffic, and suddenly those synchronous API calls between services become bottlenecks? That’s exactly why I started exploring event-driven architectures with NestJS. Today, I want to share how you can build something that not only scales but also maintains type safety across your entire system.
What if your services could communicate without knowing about each other’s existence? That’s the power of events.
Let me show you how we set up our shared types first. This is crucial for maintaining consistency across services:
// shared/types/src/events.ts
export enum EventType {
ORDER_CREATED = 'order.created',
ORDER_CANCELLED = 'order.cancelled',
INVENTORY_RESERVED = 'inventory.reserved'
}
export class OrderCreatedEvent extends BaseEvent {
@IsString()
customerId: string;
@ValidateNested({ each: true })
@Type(() => OrderItem)
items: OrderItem[];
constructor(orderId: string, customerId: string, items: OrderItem[]) {
super(EventType.ORDER_CREATED, orderId);
this.customerId = customerId;
this.items = items;
}
}
Notice how we’re using class-validator decorators? This ensures that every event passing through our system meets the expected contract. Have you ever dealt with schema drift between services? This approach eliminates that problem entirely.
Now, let’s look at how we set up our RabbitMQ connection in NestJS:
// order-service/src/messaging/rabbitmq.service.ts
@Injectable()
export class RabbitMQService {
private channel: amqp.Channel;
constructor(@Inject('RABBITMQ_CONFIG') private config: RabbitMQConfig) {}
async connect(): Promise<void> {
const connection = await amqp.connect(this.config.uri);
this.channel = await connection.createChannel();
await this.channel.assertExchange('orders', 'topic', {
durable: true
});
}
async publishEvent(event: BaseEvent): Promise<void> {
const message = Buffer.from(JSON.stringify(event));
this.channel.publish('orders', event.type, message, {
persistent: true
});
}
}
The beauty here is in the durability settings. Even if RabbitMQ restarts, our messages and exchanges survive. But what happens when things go wrong? That’s where dead letter exchanges come into play.
Here’s how we handle failed messages:
// inventory-service/src/messaging/error-handler.ts
async setupErrorHandling(): Promise<void> {
await this.channel.assertExchange('dlx', 'topic', { durable: true });
await this.channel.assertQueue('inventory.dead.letter', {
durable: true,
arguments: {
'x-dead-letter-exchange': 'orders',
'x-message-ttl': 60000
}
});
}
This configuration automatically retries failed messages after a minute. How many times have you seen temporary failures bring down entire systems?
Now let’s look at the order service implementation:
// order-service/src/orders/orders.service.ts
@Injectable()
export class OrdersService {
constructor(
private readonly rabbitMQService: RabbitMQService,
private readonly prisma: PrismaService
) {}
async createOrder(createOrderDto: CreateOrderDto): Promise<Order> {
return await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
customerId: createOrderDto.customerId,
items: {
create: createOrderDto.items
}
},
include: { items: true }
});
const event = new OrderCreatedEvent(
order.id,
order.customerId,
order.items
);
await this.rabbitMQService.publishEvent(event);
return order;
});
}
}
The transaction ensures that we only publish the event if the database commit succeeds. This prevents phantom events from circulating in your system.
But how do we ensure the inventory service can handle these events? Let’s look at the consumer side:
// inventory-service/src/consumers/order-consumer.service.ts
@Injectable()
export class OrderConsumerService {
constructor(
private readonly inventoryService: InventoryService,
private readonly rabbitMQService: RabbitMQService
) {}
async setupConsumer(): Promise<void> {
const queue = await this.rabbitMQService.channel.assertQueue('', {
exclusive: true
});
await this.rabbitMQService.channel.bindQueue(
queue.queue,
'orders',
EventType.ORDER_CREATED
);
this.rabbitMQService.channel.consume(queue.queue, async (msg) => {
if (msg) {
try {
const event = plainToInstance(
OrderCreatedEvent,
JSON.parse(msg.content.toString())
);
await validate(event);
await this.inventoryService.reserveStock(event);
this.rabbitMQService.channel.ack(msg);
} catch (error) {
this.rabbitMQService.channel.nack(msg, false, false);
}
}
});
}
}
The plainToInstance transformation is key here. It converts our raw message back into a typed class instance, complete with validation. Can you see how this prevents entire classes of errors?
Now, let’s talk about monitoring. How do you know what’s happening across your services?
// shared/src/monitoring/metrics.service.ts
@Injectable()
export class MetricsService {
private counter: prometheus.Counter;
constructor() {
this.counter = new prometheus.Counter({
name: 'events_processed_total',
help: 'Total number of events processed',
labelNames: ['service', 'event_type', 'status']
});
}
trackEvent(service: string, eventType: string, status: 'success' | 'error'): void {
this.counter.inc({
service,
event_type: eventType,
status
});
}
}
This simple counter gives you visibility into your event flow. Combine this with Grafana, and you have a complete picture of your system’s health.
The real magic happens when you deploy everything with Docker:
# docker/order-service/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist/ ./dist/
CMD ["node", "dist/main"]
And the docker-compose ties it all together:
# docker-compose.yml
version: '3.8'
services:
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
order-service:
build: ./services/order-service
depends_on:
- rabbitmq
environment:
- RABBITMQ_URI=amqp://rabbitmq:5672
inventory-service:
build: ./services/inventory-service
depends_on:
- rabbitmq
- postgres
environment:
- RABBITMQ_URI=amqp://rabbitmq:5672
This setup gives you independent scaling. Need to handle more orders? Just add more order service instances. Inventory checks taking too long? Scale out your inventory service.
The best part? Everything remains type-safe from database to message queue. Your IDE can help you navigate the entire system, and refactoring becomes much less scary.
I’d love to hear about your experiences with event-driven architectures. What challenges have you faced? Share your thoughts in the comments below, and if you found this useful, please like and share with your team!