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.