I’ve been thinking about microservices architecture recently, especially how we can build systems that handle high loads without collapsing under pressure. Last week, I hit a scaling wall with traditional REST APIs during a traffic surge - that pain point pushed me toward event-driven solutions. Let me share what I’ve learned about building resilient systems with Redis Streams and NestJS. If you’ve ever struggled with distributed systems, stick around - this might change your approach.
Setting up our project requires careful planning. We’ll create three services: orders, inventory, and notifications. Each will run independently but communicate through events. Starting is straightforward:
mkdir order-service inventory-service notification-service shared
cd order-service
npm init -y
npm install @nestjs/{common,core,microservices} ioredis
Why Redis Streams over traditional queues? Redis offers consumer groups and message persistence out-of-the-box. When an order gets created, we don’t want to lose that event during failures. Here’s how we define events:
// shared/events/order.events.ts
export class OrderCreatedEvent {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly items: { productId: string; quantity: number }[]
) {}
}
Notice how we’re using TypeScript interfaces for strict event schemas. Ever tried debugging mismatched event formats in production? I have - type safety prevents those midnight emergencies.
The real magic happens in our event bus. This Redis-powered connector handles publishing and consumption:
// shared/event-bus/redis-event-bus.ts
import Redis from 'ioredis';
@Injectable()
export class RedisEventBus {
private redis: Redis;
constructor() {
this.redis = new Redis(process.env.REDIS_URL);
}
async publish(stream: string, event: object) {
await this.redis.xadd(stream, '*', 'event', JSON.stringify(event));
}
async consumeGroup(stream: string, group: string, consumer: string) {
const messages = await this.redis.xreadgroup(
'GROUP', group, consumer,
'COUNT', '10',
'BLOCK', '2000',
'STREAMS', stream, '>'
);
return messages?.[0]?.[1] || [];
}
}
For the order service, we trigger events during critical actions. When a user completes checkout, we publish an event instead of calling inventory directly:
// order-service/src/orders/orders.controller.ts
@Post()
async createOrder(@Body() orderDto: CreateOrderDto) {
const order = await this.ordersService.create(orderDto);
await this.eventBus.publish('orders-stream', new OrderCreatedEvent(
order.id,
order.userId,
order.items
));
return order;
}
Now, how does inventory react? We set up a consumer group that processes these events:
// inventory-service/src/consumers/order.consumer.ts
@Injectable()
export class OrderConsumer {
constructor(private eventBus: RedisEventBus) {}
async start() {
await this.eventBus.createGroup('orders-stream', 'inventory-group');
setInterval(() => this.processEvents(), 5000);
}
private async processEvents() {
const events = await this.eventBus.consumeGroup(
'orders-stream',
'inventory-group',
'inventory-consumer-1'
);
for (const [id, fields] of events) {
const eventData = JSON.parse(fields[1]);
// Process inventory update
await this.eventBus.ack('orders-stream', 'inventory-group', id);
}
}
}
Error handling is crucial. Notice the explicit acknowledgment? If processing fails, Redis will redeliver the event. We implement exponential backoff for retries:
// inventory-service/src/consumers/order.consumer.ts
private async handleEvent(event: OrderCreatedEvent) {
let attempts = 0;
const maxAttempts = 5;
while (attempts < maxAttempts) {
try {
await this.inventoryService.reserveItems(event.items);
return;
} catch (error) {
attempts++;
await new Promise(res => setTimeout(res, 2 ** attempts * 1000));
}
}
// Dead-letter queue pattern here
}
For observability, we add structured logging at critical points. This helps trace events across services:
private async publish(event: BaseEvent) {
const start = Date.now();
await this.redis.xadd(/* ... */);
logger.log(`Event ${event.constructor.name} published in ${Date.now() - start}ms`);
}
Deployment uses Docker. Here’s a snippet for the order service:
# order-service/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "dist/main.js"]
When scaling, remember Redis Streams’ partitioning limitations. We might shard streams by region or product category. What happens when consumer groups can’t keep up with event volume? We add more consumers - Redis automatically load-balances.
The true test came during our load tests. With 10,000 concurrent users, the system held up because events buffered during spikes. Synchronous calls would’ve crumbled. Monitoring showed inventory updates took 47ms on average - acceptable for our use case.
Building this changed how I view distributed systems. Events create breathing room between services. If you’ve faced similar challenges, I’d love to hear your experiences. Try implementing one event-driven flow in your current project - the resilience gains might surprise you. Found this useful? Share it with your team and tag me in your implementation stories!