I’ve been building microservices for years, but it wasn’t until I faced a major production outage that I truly appreciated type-safe event-driven systems. That moment when you realize a simple type mismatch can cascade through your entire architecture—it changes how you approach system design. Today, I want to share how combining NestJS, Redis Streams, and NATS creates a robust foundation for event-driven microservices that won’t keep you up at night.
Why do we need both Redis Streams and NATS? Think of Redis as your persistent event store and NATS as your high-speed messaging backbone. Redis ensures no event gets lost, while NATS handles real-time communication between services. This combination gives you both durability and performance.
Let me show you how to build this from the ground up. We’ll start with a shared types package that forms the contract between all our services. This is where type safety begins.
export abstract class BaseEvent {
id: string;
aggregateId: string;
timestamp: string;
constructor(aggregateId: string) {
this.id = crypto.randomUUID();
this.aggregateId = aggregateId;
this.timestamp = new Date().toISOString();
}
abstract getEventType(): string;
}
Have you ever wondered what happens when services evolve at different paces? That’s where versioned events become crucial. Each event carries its schema version, allowing services to handle multiple versions gracefully.
Here’s how we implement a type-safe event handler in our order service:
@Injectable()
export class OrderEventHandler {
constructor(
private readonly paymentService: PaymentService,
@Inject('REDIS_CLIENT') private redisClient: Redis
) {}
@EventPattern('order.created')
async handleOrderCreated(event: OrderCreatedEvent) {
// Validate event structure
const validatedEvent = await this.validateEvent(event);
// Persist to Redis Stream
await this.redisClient.xAdd(
'order-events',
'*',
validatedEvent
);
// Process payment
await this.paymentService.processPayment(validatedEvent);
}
}
What makes Redis Streams particularly valuable for event sourcing? Their append-only nature and consumer groups ensure exactly-once processing semantics. Here’s how we set up a consumer group:
async setupConsumerGroup(stream: string, group: string) {
try {
await this.redisClient.xGroupCreate(
stream,
group,
'0',
{ MKSTREAM: true }
);
} catch (error) {
// Group already exists - this is fine
if (!error.message.includes('BUSYGROUP')) {
throw error;
}
}
}
Now, let’s talk about NATS for inter-service communication. While Redis handles persistence, NATS excels at real-time messaging between services. The question becomes: when should you use each?
@MessagePattern('payment.processed')
async handlePaymentProcessed(data: PaymentProcessedEvent) {
// Update order status
await this.orderService.updateOrderStatus(
data.orderId,
'payment_completed'
);
// Notify inventory service via NATS
this.natsClient.emit(
'inventory.update',
{ orderId: data.orderId, items: data.items }
);
}
Error handling in distributed systems requires careful planning. What happens when a service goes down mid-processing? We implement dead letter queues and retry mechanisms:
async processWithRetry(
event: BaseEvent,
handler: Function,
maxRetries = 3
) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await handler(event);
return;
} catch (error) {
if (attempt === maxRetries) {
await this.moveToDeadLetterQueue(event, error);
}
await this.delay(Math.pow(2, attempt) * 1000);
}
}
}
Testing event-driven systems requires simulating real-world scenarios. How do you ensure your services handle events correctly under load? We create comprehensive test suites that verify both happy paths and edge cases:
describe('Order Service Events', () => {
beforeEach(async () => {
await testApp.init();
await redisClient.flushAll();
});
it('should process order creation and emit payment event', async () => {
const orderEvent = new OrderCreatedEvent(
'order-123',
'customer-456',
[{ productId: 'prod-1', quantity: 2 }]
);
await orderService.publishEvent(orderEvent);
// Verify event was processed
const paymentEvents = await natsClient.getEvents('payment.required');
expect(paymentEvents).toHaveLength(1);
});
});
Distributed tracing gives you visibility across service boundaries. By correlating events through their lifecycle, you can trace a request from order creation through payment processing to inventory updates:
@Injectable()
export class TracingService {
constructor(private readonly logger: Logger) {}
startSpan(event: BaseEvent, operation: string) {
const spanId = crypto.randomUUID();
this.logger.log({
message: `Starting ${operation}`,
spanId,
eventId: event.id,
aggregateId: event.aggregateId,
timestamp: new Date().toISOString()
});
return spanId;
}
}
Monitoring your event-driven architecture requires tracking both technical and business metrics. How many orders are processed per minute? What’s the average processing time? These metrics help you understand both system health and business performance.
The beauty of this architecture lies in its flexibility. Services can be updated independently, new services can join the ecosystem without disrupting existing ones, and you maintain a complete audit trail of all system activity. The type safety ensures that as your system evolves, breaking changes are caught at compile time rather than in production.
I’ve deployed this pattern across multiple production systems handling millions of events daily. The combination of Redis Streams for durability and NATS for performance creates a system that’s both reliable and scalable. The type safety prevents entire classes of runtime errors, while the event-driven nature makes the system resilient to individual service failures.
What challenges have you faced with microservices communication? I’d love to hear about your experiences in the comments below. If you found this guide helpful, please share it with your team and let me know what other patterns you’d like me to cover. Your feedback helps me create more relevant content for our community.