I’ve been thinking a lot lately about how we build systems that can grow without breaking. It started when I noticed how traditional request-response architectures often struggle under real-world complexity. They work fine until you need to handle multiple operations that depend on each other, or when you need to scale specific parts of your application independently. That’s when I turned to event-driven microservices, and I want to share what I’ve learned about building them with NestJS, Redis Streams, and MongoDB.
Why do events change everything? Because they let services communicate without being tightly coupled. When something important happens in one service, it simply publishes an event. Other services that care about that event can react accordingly, without knowing anything about the service that published it. This loose coupling is what makes microservices truly independent and scalable.
Let me show you how this works in practice. Here’s a basic event interface that forms the foundation of our communication:
interface BaseEvent {
id: string;
type: string;
timestamp: Date;
version: string;
}
Now, imagine we’re building an order processing system. When an order is created, we don’t want the order service to directly call payment and inventory services. Instead, it publishes an event:
interface OrderCreatedEvent extends BaseEvent {
type: 'ORDER_CREATED';
data: {
orderId: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
}>;
};
}
Have you ever wondered what happens if a service goes down while processing events? Redis Streams give us persistent storage and the ability to replay events, which is crucial for reliability. Here’s how we might set up a Redis client in NestJS:
import { Redis } from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
});
What makes Redis Streams particularly powerful is their ability to handle multiple consumers and track which events each consumer has processed. This means if our payment service goes offline for maintenance, it can catch up on missed events when it comes back online.
Let’s look at how we might publish an event to a Redis Stream:
async function publishEvent(streamKey: string, event: BaseEvent) {
await redis.xadd(streamKey, '*',
'type', event.type,
'data', JSON.stringify(event.data),
'timestamp', event.timestamp.toISOString()
);
}
On the consuming side, services can read from these streams and process events. The beauty here is that each service maintains its own position in the stream, so they can process events at their own pace:
async function consumeEvents(streamKey: string, consumerGroup: string) {
const events = await redis.xreadgroup(
'GROUP', consumerGroup, 'consumer-1',
'COUNT', '100',
'STREAMS', streamKey, '>'
);
return events;
}
Now, what about data persistence? MongoDB fits naturally into this architecture because of its flexible document model. Each service can have its own database, or even its own MongoDB cluster, ensuring data isolation. Here’s how we might define an order schema:
@Schema()
export class Order {
@Prop({ required: true })
customerId: string;
@Prop({ required: true })
status: string;
@Prop({ type: [OrderItemSchema] })
items: OrderItem[];
}
But here’s a question worth considering: how do we ensure that processing an event and updating our database happens atomically? We don’t want to mark an event as processed if our database update fails. This is where transaction patterns become important.
One approach I’ve found effective is to process the event first, then acknowledge it. If anything goes wrong during processing, the event remains in the stream for retry:
async function processOrderCreatedEvent(event: OrderCreatedEvent) {
try {
const session = await mongoose.startSession();
await session.withTransaction(async () => {
// Create order in database
const order = new Order({
_id: event.data.orderId,
customerId: event.data.customerId,
items: event.data.items
});
await order.save({ session });
// Update inventory through another event
await publishEvent('inventory-stream', {
type: 'RESERVE_STOCK',
data: { orderId: event.data.orderId, items: event.data.items }
});
});
// Only acknowledge event after successful processing
await redis.xack('orders-stream', 'order-group', event.id);
} catch (error) {
console.error('Failed to process event:', error);
}
}
What happens when things go wrong? Error handling in event-driven systems requires careful thought. We need retry mechanisms, dead letter queues, and monitoring to ensure we don’t lose important business events.
Testing is another area that requires different thinking. Instead of testing API endpoints, we’re testing event handlers and ensuring they produce the correct side effects:
describe('OrderCreatedEventHandler', () => {
it('should create order and publish inventory event', async () => {
const event = createTestOrderCreatedEvent();
await handler.handle(event);
const order = await Order.findById(event.data.orderId);
expect(order).toBeDefined();
// Verify inventory event was published
expect(redisPublishMock).toHaveBeenCalledWith(
'inventory-stream',
expect.objectContaining({ type: 'RESERVE_STOCK' })
);
});
});
As we move to production, monitoring becomes crucial. We need to track event throughput, processing times, and error rates. Tools like Redis Insight can help visualize stream activity, while application performance monitoring tools can track the health of our services.
But here’s the most important lesson I’ve learned: event-driven architecture isn’t just about technology choices. It’s about designing systems that reflect how business processes actually work—as sequences of related events that different parts of the organization need to know about.
The flexibility this approach provides is remarkable. Need to add a new service that reacts to order events? Just have it subscribe to the order stream. Need to replay events for debugging or recovery? Redis Streams keeps them available. Want to scale a particular service? Just add more instances to the consumer group.
What surprised me most was how this architecture naturally handles the complexity of real-world business processes. Instead of trying to coordinate everything through synchronous calls, we let events flow and services react. The system becomes more resilient, more scalable, and honestly, more fun to work with.
I’d love to hear about your experiences with event-driven architectures. What challenges have you faced? What patterns have worked well for you? Share your thoughts in the comments below, and if you found this useful, please consider sharing it with others who might benefit from this approach.