I’ve been thinking about microservices a lot lately. In my work, I’ve seen too many teams build distributed systems that become fragile webs of synchronous calls. Services get tangled together, failures cascade, and scaling becomes a nightmare. That’s why I’m passionate about event-driven architecture – it offers a cleaner, more resilient way to build systems that can actually handle real-world complexity.
What if your services could communicate without knowing about each other? That’s the power of events.
Let me show you how to build this with NestJS, RabbitMQ, and TypeScript. We’ll create a system where services publish events when something important happens, and other services react to those events autonomously.
First, we need our messaging backbone. RabbitMQ provides the reliable message broker we need:
// docker-compose.yml for RabbitMQ setup
services:
rabbitmq:
image: rabbitmq:3.12-management
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: password
With our infrastructure ready, let’s define our event structure. Strong typing is crucial here – it prevents entire classes of errors in distributed systems:
// shared/types/events.ts
export interface DomainEvent {
id: string;
type: string;
timestamp: Date;
aggregateId: string;
data: unknown;
correlationId: string;
}
export class OrderCreatedEvent implements DomainEvent {
constructor(
public readonly id: string,
public readonly type: string,
public readonly timestamp: Date,
public readonly aggregateId: string,
public readonly data: OrderData,
public readonly correlationId: string
) {}
}
Now, how do we actually get these events moving between services? The event bus acts as our communication layer:
// shared/event-bus.service.ts
@Injectable()
export class EventBusService {
private channel: Channel;
async publish(event: DomainEvent): Promise<void> {
await this.channel.assertExchange('domain_events', 'topic');
this.channel.publish(
'domain_events',
event.type,
Buffer.from(JSON.stringify(event))
);
}
}
In your order service, publishing an event becomes straightforward:
// order.service.ts
@Injectable()
export class OrderService {
constructor(private readonly eventBus: EventBusService) {}
async createOrder(orderData: CreateOrderDto): Promise<Order> {
const order = await this.ordersRepository.create(orderData);
const event = new OrderCreatedEvent(
uuidv4(),
'order.created',
new Date(),
order.id,
orderData,
uuidv4() // correlation ID
);
await this.eventBus.publish(event);
return order;
}
}
But what happens when things go wrong? Error handling in distributed systems requires careful thought:
// payment.service.ts - handling failed payments
async handlePaymentFailedEvent(event: PaymentFailedEvent): Promise<void> {
try {
await this.ordersService.cancelOrder(event.data.orderId);
await this.inventoryService.releaseStock(event.data.orderId);
} catch (error) {
// Dead letter queue pattern
await this.eventBus.publishToDlq(event, error);
this.logger.error('Failed to process payment failure', error);
}
}
Have you considered how you’ll track requests across service boundaries? Distributed tracing becomes essential:
// correlation.middleware.ts
@Injectable()
export class CorrelationMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const correlationId = req.headers['x-correlation-id'] || uuidv4();
// Store in async local storage for context propagation
RequestContext.setCorrelationId(correlationId);
next();
}
}
Testing event-driven systems requires a different approach. Instead of mocking HTTP calls, we need to verify events:
// order.service.spec.ts
it('should publish order created event', async () => {
const publishSpy = jest.spyOn(eventBus, 'publish');
await orderService.createOrder(testOrderData);
expect(publishSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'order.created',
aggregateId: expect.any(String)
})
);
});
Monitoring becomes crucial when you can’t simply trace a single request through your system. We need to track event flows, processing times, and error rates across all services.
What patterns have you found most effective for monitoring distributed systems?
Building event-driven microservices requires shifting your mindset from request-response to event-based thinking. Services become more autonomous, the system becomes more resilient, and scaling becomes more granular.
The beauty of this approach is that new services can join the ecosystem without disrupting existing ones. They simply start listening for relevant events and contribute to the system’s capabilities.
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 helpful, please like and share with others who might benefit from this approach.
Remember: the goal isn’t just to build microservices, but to build systems that can evolve and scale with your needs. Event-driven architecture, when implemented well, gives you that flexibility.