I’ve been working with microservices for years, but it wasn’t until a recent production outage that I truly appreciated the power of event-driven architecture. When our synchronous API calls started failing like dominoes during peak traffic, I knew we needed a better approach. That’s when I turned to NestJS, RabbitMQ, and Redis to build truly resilient systems. Let me show you how these technologies combine to create high-performance microservices that can withstand real-world pressures.
Event-driven architecture fundamentally changes how services communicate. Instead of services calling each other directly, they emit events when something important happens. Other services listen for these events and react accordingly. This pattern prevents cascading failures - if one service goes down, others keep functioning independently. How might this have prevented my production outage? Services wouldn’t be waiting on each other, eliminating those dangerous chain reactions.
Our project centers around an e-commerce order system with four specialized services:
- Order Service: Creates and tracks orders
- Payment Service: Handles transactions
- Inventory Service: Manages product stock
- Notification Service: Alerts customers
Here’s our Docker setup for the infrastructure:
# docker-compose.yml
services:
rabbitmq:
image: rabbitmq:3-management
ports: ["5672:5672", "15672:15672"]
environment:
RABBITMQ_DEFAULT_USER: admin
RABMQ_DEFAULT_PASS: admin
redis:
image: redis:7-alpine
ports: ["6379:6379"]
postgres:
image: postgres:15
environment:
POSTGRES_DB: ecommerce
POSTGRES_USER: admin
POSTGRES_PASSWORD: admin
Shared event contracts keep our services aligned. Notice how each event clearly defines what changed:
// Shared events
export interface OrderCreatedEvent {
eventType: 'order.created';
orderId: string;
userId: string;
items: { productId: string; quantity: number }[];
}
export interface PaymentProcessedEvent {
eventType: 'payment.processed';
orderId: string;
status: 'success' | 'failed';
}
The Order Service demonstrates our core implementation. When an order is created, it publishes an event instead of calling other services directly:
// Order Service
@Injectable()
export class OrderService {
constructor(
private eventBus: ClientProxy,
private orderRepo: Repository<Order>
) {}
async createOrder(orderData: CreateOrderDto) {
const order = await this.orderRepo.save(orderData);
const event: OrderCreatedEvent = {
eventType: 'order.created',
orderId: order.id,
userId: order.userId,
items: order.items
};
this.eventBus.emit('order.created', event);
return order;
}
}
RabbitMQ handles our messaging using the fanout exchange pattern. This allows multiple services to receive the same events simultaneously. Our payment service listens for order events like this:
// Payment Service
@MessagePattern('order.created')
async handleOrderCreated(event: OrderCreatedEvent) {
const paymentResult = await this.processPayment(event);
const paymentEvent: PaymentProcessedEvent = {
eventType: 'payment.processed',
orderId: event.orderId,
status: paymentResult.success ? 'success' : 'failed'
};
this.eventBus.emit('payment.processed', paymentEvent);
}
Redis solves two critical problems: caching expensive queries and managing user sessions. Our inventory service uses Redis to cache product availability:
// Inventory Service
async getStock(productId: string) {
const cacheKey = `stock:${productId}`;
const cachedStock = await this.redisClient.get(cacheKey);
if (cachedStock) return cachedStock;
const dbStock = await this.stockRepo.findOne({ productId });
await this.redisClient.set(cacheKey, dbStock.quantity, 'EX', 60); // 60s TTL
return dbStock.quantity;
}
Error handling requires special attention in distributed systems. We implement dead-letter queues in RabbitMQ to capture failed messages:
// RabbitMQ configuration with DLQ
const options: AmqpConnectionOptions = {
urls: [amqpUrl],
queue: 'payment_queue',
queueOptions: {
deadLetterExchange: 'dlx',
deadLetterRoutingKey: 'payment.dead'
}
};
Testing event-driven systems presents unique challenges. We use the NestJS testing module to verify event emissions:
// Order Service test
it('should emit order.created event', async () => {
const eventSpy = jest.spyOn(eventBus, 'emit');
await orderService.createOrder(mockOrderData);
expect(eventSpy).toHaveBeenCalledWith('order.created', expect.any(Object));
});
For monitoring, we export metrics to Prometheus and visualize them in Grafana. This dashboard shows message throughput and error rates across services. How might this have helped me spot our production issues earlier? Real-time visibility into message backlogs would have alerted us before the system collapsed.
Deployment uses Docker with strategic scaling. We run multiple instances of stateless services like notifications while keeping stateful services like databases single-instance. Kubernetes manages this orchestration in production.
Performance tuning revealed some surprises. We initially used JSON for messages but switched to Protocol Buffers for a 40% size reduction. RabbitMQ’s publisher confirms ensured no events were lost during peak loads. Redis pipelining cut cache latency by 30%.
This architecture now processes thousands of orders per minute with 99.99% uptime. The true test came during Black Friday when traffic spiked 10x - our system didn’t flinch. What failures has your current architecture survived?
I’d love to hear about your microservices journey! If this approach resonates with you, share your thoughts below. Pass this along to any team dealing with distributed systems challenges - it might save them from their next outage. What questions do you have about implementing this in your environment?