I’ve spent years building monolithic applications that struggled under load, and I kept hitting walls with scalability and maintenance. That frustration led me to explore event-driven microservices, and the combination of NestJS, Redis Streams, and PostgreSQL has completely transformed how I approach system design. Let me walk you through building something that not only scales but remains resilient under pressure.
Have you ever wondered how large systems process thousands of orders without breaking a sweat? The secret often lies in event-driven architecture. Instead of services calling each other directly, they emit events about what happened. Other services listen and react independently. This creates systems that can handle unexpected loads and failures gracefully.
Let’s start with Redis Streams as our message broker. Why Redis? It’s fast, persistent, and supports consumer groups out of the box. Here’s how I set up the basic event bus:
// Simple event publisher
async publishOrderEvent(event: OrderEvent) {
const serialized = JSON.stringify({
id: uuid(),
timestamp: new Date(),
type: event.type,
data: event.data
});
await this.redis.xadd('orders', '*', 'event', serialized);
}
Now, what happens when multiple services need to process the same event? Redis consumer groups solve this beautifully. Each service gets its own copy of the event stream without interfering with others.
Building the order service in NestJS feels natural with its modular structure. I create an OrdersController that accepts HTTP requests and emits events rather than calling other services directly:
// In OrdersController
@Post()
async createOrder(@Body() orderData: CreateOrderDto) {
const order = await this.ordersService.create(orderData);
await this.eventBus.publish('ORDER_CREATED', {
orderId: order.id,
userId: order.userId,
items: order.items,
totalAmount: order.totalAmount
});
return order;
}
The payment service subscribes to ORDER_CREATED events. Notice how it doesn’t know anything about the order service—it just reacts to events:
// Payment service event handler
@OnEvent('ORDER_CREATED')
async processPayment(event: OrderCreatedEvent) {
const payment = await this.paymentService.charge(
event.data.userId,
event.data.totalAmount
);
await this.eventBus.publish('PAYMENT_PROCESSED', {
orderId: event.data.orderId,
paymentId: payment.id,
status: payment.status
});
}
But what happens when payments fail? We need to handle errors without losing events. I implement retry logic with exponential backoff:
async handlePaymentEvent(event: PaymentEvent, attempt = 1) {
try {
await this.processPayment(event.data);
} catch (error) {
if (attempt < 3) {
setTimeout(() => {
this.handlePaymentEvent(event, attempt + 1);
}, Math.pow(2, attempt) * 1000);
} else {
await this.deadLetterQueue.add(event);
}
}
}
PostgreSQL serves as our event store, capturing every state change. This pattern, called event sourcing, gives us complete audit trails and the ability to rebuild state at any point:
-- Event store table structure
CREATE TABLE event_store (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100),
aggregate_id UUID,
event_type VARCHAR(100),
event_data JSONB,
timestamp TIMESTAMP DEFAULT NOW()
);
How do we monitor such a distributed system? I instrument everything with OpenTelemetry, adding trace IDs to events so I can follow a request across service boundaries. When an order gets stuck, I can see exactly where it failed.
Deployment requires careful planning. I use Docker Compose for development and Kubernetes for production, ensuring each service can scale independently based on its event load. The order service might need more instances during peak shopping hours, while the notification service hums along steadily.
Testing event-driven systems feels different too. I focus on contract testing—verifying that events contain the right data without testing implementation details:
// Contract test example
it('should emit ORDER_CREATED with correct structure', async () => {
const order = await createTestOrder();
expect(orderEvents[0]).toMatchObject({
type: 'ORDER_CREATED',
data: {
orderId: expect.any(String),
totalAmount: expect.any(Number)
}
});
});
The biggest lesson I’ve learned? Start simple. Don’t over-engineer your event schema. Make sure your team understands the consistency trade-offs. Event-driven systems offer eventual consistency, which means data might not be immediately synchronized across all services.
I’ve deployed this architecture for e-commerce platforms handling millions of events daily. The separation of concerns makes development faster and incidents less catastrophic. When the payment service has issues, orders still get created—they just wait for payment processing to resume.
What challenges have you faced with microservices? I’d love to hear your experiences in the comments. If this approach resonates with you, please share this article with your team—it might spark the conversation that transforms your next project.