Message patterns vs event patterns
When you start building microservices in NestJS, you'll quickly face a fundamental choice: do you want your services to wait for responses, or do you want them to fire and forget? Understanding the difference between message patterns and event patterns is the key to building systems that scale gracefully instead of turning into distributed monoliths. I've learned this lesson the hard way—what looks like a quick request-reply call early on can become a bottleneck that chains your services together in ways that make deployment, testing, and scaling nightmarish.
@MessagePattern expects a reply
When you decorate a handler with @MessagePattern, you're saying: "I will receive a message, process it, and send back a response." This is synchronous request-reply semantics. The caller waits. The receiver responds. Both sides are aware of each other in that moment.
Here's a concrete example. Imagine you're building a ride-hailing platform and your booking service needs to reserve a vehicle. You send a message to the fleet service and wait for confirmation:
// fleet.service.ts (receiver)
@Controller()
export class FleetController {
@MessagePattern('reserve_vehicle')
async reserveVehicle(data: ReserveVehicleDto) {
const vehicle = await this.vehicleRepo.findAvailable(data.location);
if (!vehicle) {
throw new RpcException('No vehicles available');
}
vehicle.status = 'reserved';
await vehicle.save();
return { vehicleId: vehicle.id, licensePlate: vehicle.plate };
}
}
// booking.service.ts (sender)
export class BookingService {
constructor(private client: ClientProxy) {}
async createBooking(dto: CreateBookingDto) {
const vehicleResponse = await this.client
.send('reserve_vehicle', { location: dto.pickupLocation })
.toPromise();
const booking = new Booking();
booking.vehicleId = vehicleResponse.vehicleId;
await booking.save();
return booking;
}
}
Notice the .toPromise()? The booking service waits until the fleet service responds. If the fleet service is slow, the booking service is blocked. If the fleet service crashes, the booking service fails too. This creates tight coupling.
Message patterns are useful when you genuinely need a response—validating input, checking inventory, or computing something the caller can't do alone. But overusing them is where most people stumble.
@EventPattern never replies
@EventPattern is fire-and-forget. You emit an event, you don't wait for a response, and you don't care if the receiver crashes. The event is published; what happens next is not your concern.
Let's redesign the ride-hailing example using events:
// booking.service.ts (emitter)
export class BookingService {
constructor(private client: ClientProxy) {}
async createBooking(dto: CreateBookingDto) {
const booking = new Booking();
booking.userId = dto.userId;
booking.pickupLocation = dto.pickupLocation;
booking.status = 'pending';
await booking.save();
// Emit event, don't wait for response
this.client.emit('booking.created', {
bookingId: booking.id,
pickupLocation: booking.pickupLocation
});
return booking;
}
}
// fleet.service.ts (listener)
@Controller()
export class FleetController {
@EventPattern('booking.created')
async onBookingCreated(data: BookingCreatedEvent) {
const vehicle = await this.vehicleRepo.findAvailable(data.pickupLocation);
if (vehicle) {
vehicle.status = 'reserved';
vehicle.bookingId = data.bookingId;
await vehicle.save();
// Emit another event when done
this.client.emit('vehicle.reserved', {
bookingId: data.bookingId,
vehicleId: vehicle.id
});
}
}
}
The booking service doesn't wait. It publishes the event and continues. The fleet service listens independently. If the fleet service is temporarily unavailable, the event sits in the message broker (RabbitMQ, Kafka) and gets processed when the service recovers. No crashes propagate between services.
With events, you never use .toPromise(). You use .emit() and trust the messaging infrastructure to deliver eventually.
Events decouple services better
The real power of event patterns is decoupling. When you use message patterns heavily, every service needs to know about every other service's contract. Adding a new consumer means the producer code doesn't change—the event just flows to another listener.
Think about a real scenario from my experience building a SaaS platform. Initially, when an order was created, we had:
- Order service sends a message to payment service (message pattern)
- Payment service sends a message back to order service (message pattern)
- Order service sends a message to inventory service (message pattern)
- Order service sends a message to notification service (message pattern)
The order service became a coordinator. Every service change rippled through. Deployment order mattered. Testing required mocking four external calls.
After switching to event patterns:
- Order service emits "order.created" event
- Payment service listens and processes independently
- Inventory service listens and processes independently
- Notification service listens and processes independently
- Later, analytics service starts listening to the same event—order service code unchanged
Each service is now truly autonomous. Testing is easier: you test what happens when the event fires, without mocking dependencies. Deployment is safer: you can deploy services in any order. Scaling is cleaner: if the notification service is slow, it doesn't block the order creation flow.
Events also make your system more resilient. If a listener crashes momentarily, the message broker holds the event. When the service comes back online, it processes the backlog. With synchronous message patterns, that crash becomes a cascading failure.
Too many request-reply calls make a distributed monolith
Here's the trap: a distributed system with primarily synchronous message patterns isn't really distributed—it's a monolith spread across multiple processes. You get all the complexity of distributed systems (network failures, timeouts, partial failures) without any of the benefits (independence, resilience, scalability).
I've watched this happen in production. A platform with seven microservices where nearly every operation involved four or five synchronous hops between services. A single slow database query in one service would cause timeouts cascading backward through the entire call chain. Deploying the auth service required coordinating with five other teams. Testing an order flow required setting up and mocking all seven services.
The warning signs:
- Your services have many direct dependencies on each other
- You're constantly adding timeouts and retry logic to message patterns
- A failure in one service brings down others (even briefly)
- You need to carefully orchestrate deployment order
- Testing a single service requires running five others
- Your system slows down as you add more services instead of speeding up
The solution is preferring async event patterns. Yes, it requires a message broker (RabbitMQ, Kafka, AWS SQS). Yes, your system becomes eventually consistent instead of immediately consistent. But you gain deployment independence, resilience, and true scalability. Those tradeoffs are almost always worth it in production systems.
Common mistakes
Mistake 1: Using @MessagePattern when you really just need to notify. If you don't actually need the response, don't use message patterns. Emit an event instead. Every synchronous call is a potential bottleneck.
Mistake 2: Emitting events but not handling failures. Event listeners can fail. Make them idempotent (processing the same event twice doesn't cause problems) and add retry logic at the message broker level. Don't assume "fire and forget" means "fire and ignore."
Mistake 3: Creating long chains of synchronous calls. If service A calls B, which calls C, which calls D, you've created a house of cards. Each link is a failure point. Break long chains with events.
Mistake 4: Making events too granular. "user.logged_in", "user.password_changed", "user.email_verified"—sometimes these should be one "user.updated" event with a type field. Extreme granularity makes consumers confusing and event routing complex.
Mistake 5: Not versioning your events. Your event schema will evolve. If you don't plan for it, you'll break old listeners when you add a new field. Use versioning in your event payload.
Key takeaways
@MessagePattern(request-reply) creates synchronous dependencies. Use sparingly when you genuinely need a response.@EventPattern(fire-and-forget) decouples services. Prefer this for notifications, state changes, and side effects.- Events make systems more resilient (message brokers buffer failures), more scalable (no cascading timeouts), and easier to test and deploy.
- Too many message patterns turn your microservices into a distributed monolith with all the pain and none of the benefits.
- The tradeoff is eventual consistency and complexity in debugging asynchronous flows—well worth it in production systems.
- If you can't decide, ask: "Do I need the response right now?" If no, emit an event.






Comments
Loading comments…