I’ve been thinking about system resilience and scalability lately. How do we handle high-load scenarios where services need to communicate without tight coupling? That’s when event-driven architecture caught my attention. But I wanted more than just loose coupling - I wanted type safety throughout the entire event flow. This led me to explore TypeScript, NestJS, and RabbitMQ together. Let me show you what I’ve built.
First, we need a solid foundation. Our e-commerce system will handle orders, payments, inventory, and notifications as separate services. Why keep them separate? Because when payment processing slows down during Black Friday sales, I don’t want it dragging down the entire system.
nest new event-driven-ecommerce
cd event-driven-ecommerce
npm install @nestjs/microservices amqplib amqp-connection-manager
The real magic starts with RabbitMQ configuration. Notice how we define everything upfront - exchanges, queues, and dead letter handling. This prevents configuration drift across environments:
// rabbitmq.config.ts
export const getRabbitMQConfig = (configService: ConfigService) => ({
url: configService.get('RABBITMQ_URL'),
exchanges: {
orders: 'orders.exchange',
payments: 'payments.exchange',
inventory: 'inventory.exchange'
},
queues: {
orderProcessing: 'order.processing.queue',
deadLetter: 'dead.letter.queue'
}
});
Now, how do we ensure messages survive service restarts? Our connection manager handles reconnections automatically:
// rabbitmq.service.ts
private async connect(): Promise<void> {
this.connection = connect([this.config.url], {
reconnectTimeInSeconds: 5
});
this.connection.on('disconnect', (err) => {
this.logger.error('RabbitMQ connection lost', err);
});
}
The heart of type safety lies in our event schemas. Ever been frustrated by events changing without notice? We solve this with validation decorators:
// order-created.event.ts
export class OrderCreatedEvent {
@IsUUID()
orderId: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItem)
items: OrderItem[];
}
Publishing events becomes bulletproof with our wrapper service. Notice how we validate before publishing:
// event-publisher.service.ts
async publish<T extends object>(
exchange: string,
routingKey: string,
event: T
): Promise<void> {
await validateOrReject(event);
this.rabbitService.publish(exchange, routingKey, event);
}
On the consumer side, how do we handle unexpected failures? Our dead letter setup catches problematic messages:
// rabbitmq.service.ts
private async createQueues(channel: amqp.Channel) {
await channel.assertQueue(this.config.queues.deadLetter, {
durable: true,
messageTtl: 86400000 // 24 hours
});
await channel.assertQueue(this.config.queues.orderProcessing, {
durable: true,
deadLetterExchange: 'dlx.exchange'
});
}
Here’s a question: What happens when inventory service is temporarily unavailable? We implement retry logic with exponential backoff:
// order.handler.ts
@RabbitSubscribe({
exchange: 'orders.exchange',
routingKey: 'order.created',
queue: 'order.processing.queue'
})
async handleOrderCreated(event: OrderCreatedEvent) {
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.inventoryService.reserveItems(event);
return;
} catch (error) {
if (attempt === maxRetries) throw error;
await new Promise(resolve =>
setTimeout(resolve, 1000 * Math.pow(2, attempt))
}
}
}
Testing is crucial. How do we verify events without RabbitMQ in unit tests? We use a mock publisher:
// order.service.spec.ts
beforeEach(() => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrderService,
{ provide: EventPublisher, useClass: MockEventPublisher }
]
}).compile();
});
class MockEventPublisher {
publishedEvents = [];
publish(exchange, routingKey, event) {
this.publishedEvents.push({ exchange, routingKey, event });
}
}
In production, monitoring is key. I add this to every handler:
private logEvent(
event: any,
routingKey: string,
direction: 'RECEIVED' | 'PUBLISHED'
) {
const { constructor: { name } } = Object.getPrototypeOf(event);
this.logger.log(`${direction} ${name} via ${routingKey}`);
}
What about performance? We tune channel prefetching:
private async setupChannel(channel: amqp.Channel) {
// Only 10 unacknowledged messages per consumer
await channel.prefetch(10);
}
Common pitfall: Forgetting to serialize complex objects. Solution:
// Send Date as ISO string
publish('orders.exchange', 'order.created', {
...event,
createdAt: event.createdAt.toISOString()
});
Now I’m curious - how would you extend this pattern? We’ve covered the essentials: type safety, resilience, and observability. The complete codebase lives on my GitHub repository. If this approach resonates with you, share your thoughts in the comments. Have you implemented something similar? What challenges did you face? Like this article if it helped clarify event-driven patterns, and share it with your team if you’re considering this architecture.