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
smart_toy

AI-Assisted Content. This article was generated with AI and reviewed for accuracy based on real engineering experience. Code examples are tested and production-relevant.

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.