Building Scalable APIs with NestJS
How I structure production-grade APIs with authentication, RBAC, validation, and clean module boundaries baked in from day one.
When to use synchronous HTTP between services and when to reach for a message queue — the decision framework that prevents distributed monolith antipatterns.
Building microservices by splitting a monolith and replacing in-process function calls with HTTP requests. Now every user action triggers a chain of synchronous HTTP calls across 5 services. Latency adds up. One service goes down, everything fails. You've built a distributed monolith.
The problem isn't microservices. It's treating all inter-service communication as synchronous.
Synchronous (HTTP/gRPC): Use when the caller needs the result immediately to continue.
Asynchronous (message queues): Use when the caller doesn't need the result, or the work can happen eventually.
The key question: does the user have to wait for this work to complete?
User logs in → auth service validates credentials → returns JWT. Synchronous. The user cannot proceed without the token.
@Injectable()
export class AuthService {
constructor(private readonly userService: UserService) {}
async validateUser(email: string, password: string) {
const user = await this.userService.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
throw new UnauthorizedException();
}
return this.generateToken(user);
}
}
gRPC over HTTP/2 is preferable to REST for internal sync communication: binary protocol, strongly typed contracts, bidirectional streaming, lower overhead.
User completes checkout → payment service processes payment → but email notification, inventory update, analytics event, loyalty points — none of these need to complete synchronously:
@EventPattern('order.completed')
async handleOrderCompleted(@Payload() event: OrderCompletedEvent) {
await Promise.allSettled([
this.emailService.sendConfirmation(event.userId, event.orderId),
this.inventoryService.reserveItems(event.items),
this.analyticsService.track('purchase', event),
this.loyaltyService.awardPoints(event.userId, event.total),
]);
}
With NestJS and a message broker (RabbitMQ, Kafka), each downstream service handles the event independently. One failure doesn't block the others. The checkout response returns in 50ms, not 500ms.
Distributed systems have no ACID transactions. When an order requires coordinating payment + inventory + shipping across three services, use the Saga pattern:
1. order-service: create order (status: PENDING)
2. payment-service: charge card → emit PAYMENT_SUCCEEDED or PAYMENT_FAILED
3. On PAYMENT_SUCCEEDED: inventory-service → emit ITEMS_RESERVED or OUT_OF_STOCK
4. On ITEMS_RESERVED: shipping-service → emit SHIPMENT_SCHEDULED
5. On any failure: compensating transactions (refund, release items, cancel order)
Each step emits an event. The next service listens. Failures trigger compensating actions. No distributed lock, no two-phase commit.
Message brokers deliver at-least-once. Your consumers will receive duplicate messages. Design for it:
async handlePaymentEvent(event: PaymentEvent) {
const alreadyProcessed = await this.idempotencyStore.exists(event.eventId);
if (alreadyProcessed) return;
await this.processPayment(event);
await this.idempotencyStore.set(event.eventId, { processedAt: new Date() });
}
Store processed event IDs. Check before processing. Make processing the same event twice produce the same result as processing it once.
Draw a dependency graph of your services. If every service depends synchronously on every other service, you have a distributed monolith regardless of the technology. Async events decouple services at the cost of eventual consistency. Choose based on whether consistency or availability is more important for each interaction.
There is no universal answer — only the right answer for each data flow.