Homechevron_rightBlogchevron_rightBackend
Backendschedule8 min read20 April 2025

Building Scalable Microservices with Node.js and NestJS

A deep dive into designing production-ready microservices using NestJS — covering service communication, message queues, and fault tolerance patterns.

Node.jsNestJSMicroservicesKafkaDocker

Introduction

Microservices architecture has become the standard for teams that need to scale independently, deploy frequently, and isolate failure domains. But moving from a monolith to microservices is rarely a straight line — it introduces distributed systems complexity that demands careful design.

In this post I'll walk through the patterns I use when building microservices with NestJS, based on production work at SunCulture Kenya.


Why NestJS for Microservices?

NestJS ships with first-class microservice transport support out of the box — TCP, Redis, NATS, Kafka, RabbitMQ, and gRPC are all supported with a consistent @MessagePattern() / @EventPattern() API. This means you can switch transports without rewriting business logic.

// notification.controller.ts
@Controller()
export class NotificationController {
  constructor(private readonly notifService: NotificationService) {}

  @MessagePattern({ cmd: 'send_notification' })
  async handleSend(@Payload() dto: SendNotificationDto) {
    return this.notifService.send(dto);
  }

  @EventPattern('user.created')
  async onUserCreated(@Payload() event: UserCreatedEvent) {
    await this.notifService.sendWelcome(event.userId);
  }
}

Service Communication Patterns

1. Synchronous — Request/Response

Use for operations where the caller needs an immediate result (e.g. user lookup before payment authorisation).

// Caller service
const result = await this.client.send({ cmd: 'get_user' }, { userId }).toPromise();

Keep timeouts explicit — never let a slow downstream service block your entire request chain.

2. Asynchronous — Event-Driven via Kafka

Use for anything that doesn't need an immediate response — sending emails, updating read models, triggering workflows.

// Producer
this.client.emit('order.placed', { orderId, customerId, amount });

// Consumer in another service
@EventPattern('order.placed')
async onOrderPlaced(@Payload() data: OrderPlacedEvent) {
  await this.emailService.sendOrderConfirmation(data);
}

Fault Tolerance

Circuit Breaker

Wrap outbound HTTP calls in a circuit breaker to prevent cascading failures:

import CircuitBreaker from 'opossum';

const breaker = new CircuitBreaker(callExternalService, {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 10000,
});

breaker.fallback(() => ({ status: 'degraded', data: null }));

Retry with Exponential Backoff

For transient errors on Kafka consumers, configure retry topics rather than blocking the main partition:

KafkaModule.register({
  config: { retry: { retries: 5, initialRetryTime: 300, multiplier: 2 } },
})

Health Checks

Every service should expose a /health endpoint that checks its own database, cache, and downstream dependencies. NestJS Terminus makes this trivial:

@Get('/health')
@HealthCheck()
check() {
  return this.health.check([
    () => this.db.pingCheck('postgres'),
    () => this.redis.checkHealth('redis'),
  ]);
}

Key Takeaways

  • Design for failure from day one — every remote call can fail
  • Prefer events for anything that doesn't need an immediate response
  • Expose health checks on every service
  • Use distributed tracing (OpenTelemetry + Datadog) so you can follow a request across 8 services without going insane
  • Keep service boundaries aligned with business domains, not technical layers

The hardest part of microservices isn't the code — it's the operational discipline of maintaining independent deployability.