Vaaamos con el post número 23 de la serie NestJS y segundo del bloque de Escala Enterprise. Hasta ahora hemos construido una API monolítica completa con su estructura (posts 1-5), base de datos (posts 6-9), seguridad (posts 10-12), funcionalidades avanzadas (posts 13-16), eventos y tiempo real (posts 17-19), testing (posts 20-21) y GraphQL (post 22) y todo corriendo en un solo proceso.
El monolito funciona genial hasta que deja de funcionar. Cuando un módulo que procesa imágenes revienta, se lleva por delante la API de login. Cuando necesitas escalar solo los pagos, tienes que escalar todo. Cuando un despliegue rompe algo, todo está roto.
💡 Microservicios Patrón de arquitectura donde una aplicación se divide en servicios pequeños, independientes y desplegables por separado. Cada servicio tiene su propia base de datos, se comunica con los demás por red (TCP, mensajería, etc.) y puede escalar individualmente. NestJS lo soporta nativamente con @nestjs/microservices. Más info → resuelven esto porque cada dominio es un servicio independiente que se comunica con los demás por la red.
EA, amo al lío.

1. Monolito vs Microservicios: cuándo dar el salto
Monolito (posts 1-22):
┌──────────────────────────────────────┐
│ Un solo proceso │
│ ┌──────┐ ┌──────┐ ┌──────────────┐ │
│ │Users │ │Posts │ │Notifications │ │
│ └──────┘ └──────┘ └──────────────┘ │
│ ┌──────┐ ┌──────┐ ┌──────────────┐ │
│ │Auth │ │Files │ │ Payments │ │
│ └──────┘ └──────┘ └──────────────┘ │
│ Una sola BD │
└──────────────────────────────────────┘
→ Si Payments explota, todo se cae
Microservicios (este post):
┌──────────┐ ┌──────────┐ ┌──────────────────┐
│ Users │◄──►│ Posts │◄──►│ Notifications │
│ :3001 │ │ :3002 │ │ :3003 │
│ BD propia│ │BD propia │ │ BD propia │
└──────────┘ └──────────┘ └──────────────────┘
▲ ▲ ▲
│ ┌────┴────┐ │
└─────────►│ Auth │◄─────────────┘
│ :3004 │
└─────────┘
→ Si Payments explota, el resto sigue
- Tu equipo tiene +10 desarrolladores y necesitan desplegar independientemente
- Un módulo necesita escalar 10x más que los demás (procesamiento de imágenes, pagos)
- Necesitas diferentes tecnologías para diferentes dominios (Python para ML, Go para real-time)
- Los dominios están claramente separados y tienen poca dependencia entre sí
- Tu equipo tiene 2-5 personas : el overhead de microservicios os ahogará
- Estás empezando y no sabes dónde están los límites de tus dominios
- Todos los módulos comparten la misma base de datos y tienen dependencias fuertes
- No tienes infraestructura de observabilidad (logs centralizados, tracing, métricas)
ImportanteComienza siempre con un monolito modular (como hemos hecho en los posts 1-22). Cuando un módulo necesite escalar o desplegarse de forma independiente, lo extraes a un microservicio. No empieces con microservicios desde el día 1.
2. Patrones de comunicación
NestJS soporta dos patrones de comunicación entre microservicios:
| Patrón | Cómo funciona | Cuándo usarlo | Decorador |
|---|---|---|---|
| Request-Response | El cliente envía un mensaje y espera la respuesta. Es asíncrono (devuelve un
| Cuando necesitas el resultado: obtener un usuario, validar un pago, calcular un precio. | @MessagePattern() |
| Event-Based | El cliente emite un evento y no espera respuesta. Fire-and-forget. Ojo: devuelve
un | Cuando no necesitas confirmación: enviar email, log de auditoría, notificación push. | @EventPattern() |
Request-Response (@MessagePattern):
API Gateway ──send({ cmd: 'get_user' }, { id })──► Users Service
API Gateway ◄──────── { id, name, email } ─────── Users Service
(El gateway ESPERA la respuesta)
Event-Based (@EventPattern):
Users Service ──emit('user_created', payload)──► Notifications Service
(Users Service NO espera nada. Fire and forget.)
3. Instalación y primer microservicio
Vamos a crear la arquitectura más común: un API Gateway (HTTP) que se comunica con microservicios internos. Empezamos con el transporte más simple: TCP.
El microservicio (Users Service)
// apps/users-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { UsersModule } from './users.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(UsersModule, {
transport: Transport.TCP,
options: {
host: '0.0.0.0',
port: 3001,
},
});
await app.listen();
console.warn('Users microservice listening on port 3001');
}
bootstrap();
La diferencia clave es NestFactory.createMicroservice() en vez de NestFactory.create(). No expone HTTP, solo escucha mensajes TCP.
Los handlers del microservicio
// apps/users-service/src/users.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, EventPattern, Payload } from '@nestjs/microservices';
import { UsersService } from './users.service';
import { type CreateUserDto } from './dto/create-user.dto';
import { type User } from './entities/user.entity';
@Controller()
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// Request-Response: el gateway espera la respuesta
@MessagePattern({ cmd: 'get_user' })
async getUser(@Payload() data: { id: string }): Promise<User> {
return this.usersService.findOne(data.id);
}
@MessagePattern({ cmd: 'get_users' })
async getUsers(): Promise<User[]> {
return this.usersService.findAll();
}
@MessagePattern({ cmd: 'create_user' })
async createUser(@Payload() data: CreateUserDto): Promise<User> {
return this.usersService.create(data);
}
@MessagePattern({ cmd: 'validate_user' })
async validateUser(@Payload() data: { email: string; password: string }): Promise<User | null> {
return this.usersService.validateCredentials(data.email, data.password);
}
// Event-Based: no devuelve nada, fire-and-forget
@EventPattern('user_password_changed')
async handlePasswordChanged(@Payload() data: { userId: string; changedAt: string }): Promise<void> {
await this.usersService.invalidateAllSessions(data.userId);
}
}
@MessagePattern({ cmd: 'get_user' }): Request-Response. El que envía el mensaje espera la respuesta.@EventPattern('user_password_changed'): Event-Based. El que emite el evento no espera nada.@Payload(): Extrae los datos del mensaje. Similar a@Body()en controllers HTTP.
4. El API Gateway
El 💡 API Gateway Servicio que actúa como punto de entrada único para los clientes (web, mobile, etc.). Recibe las requests HTTP y las enruta al microservicio correspondiente. No tiene lógica de negocio propia : solo traduce HTTP a mensajes internos. Puede agregar auth, rate limiting y logging centralizados. Más info → es el único servicio que expone HTTP. Los clientes nunca hablan directamente con los microservicios:
Cliente (browser, mobile)
│
▼
┌─────────────────────────────┐
│ API Gateway (:3000 HTTP) │
│ - Auth, rate limiting │
│ - Traduce HTTP → mensajes │
└─────┬──────────┬────────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ Users │ │ Posts │
│ :3001 │ │ :3002 │
└──────────┘ └──────────┘
Registrar el client proxy
// apps/api-gateway/src/app.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { UsersController } from './users.controller';
// Tokens de inyección para cada microservicio
export const USERS_SERVICE = 'USERS_SERVICE';
export const POSTS_SERVICE = 'POSTS_SERVICE';
@Module({
imports: [
ClientsModule.register([
{
name: USERS_SERVICE,
transport: Transport.TCP,
options: {
host: 'users-service', // Nombre del servicio en Docker
port: 3001,
},
},
{
name: POSTS_SERVICE,
transport: Transport.TCP,
options: {
host: 'posts-service',
port: 3002,
},
},
]),
],
controllers: [UsersController],
})
export class AppModule {}
El controller del gateway
// apps/api-gateway/src/users.controller.ts
import { Controller, Get, Post, Param, Body, Inject, UseGuards } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { USERS_SERVICE } from './app.module';
import { type CreateUserDto } from './dto/create-user.dto';
import { type User } from './interfaces/user.interface';
@Controller('users')
export class UsersController {
constructor(
@Inject(USERS_SERVICE)
private readonly usersClient: ClientProxy
) {}
@Get()
@UseGuards(JwtAuthGuard)
async findAll(): Promise<User[]> {
// send() devuelve Observable : firstValueFrom lo convierte a Promise
return firstValueFrom(this.usersClient.send<User[]>({ cmd: 'get_users' }, {}));
}
@Get(':id')
@UseGuards(JwtAuthGuard)
async findOne(@Param('id') id: string): Promise<User> {
return firstValueFrom(this.usersClient.send<User>({ cmd: 'get_user' }, { id }));
}
@Post()
async create(@Body() createUserDto: CreateUserDto): Promise<User> {
return firstValueFrom(this.usersClient.send<User>({ cmd: 'create_user' }, createUserDto));
}
}
Cosas importantes aquí:
@Inject(USERS_SERVICE): Inyecta elClientProxyregistrado con ese token.client.send(pattern, data): Request-Response. Envía el mensaje y espera la respuesta. Devuelve unObservable<T>.firstValueFrom(): Convierte elObservableaPromise. Necesario porque los controllers HTTP devuelvenPromise, noObservable.client.emit(event, data): Event-Based. Emite y no espera.
5. Tipado de mensajes entre servicios
Hay un problema y es que si cambias el @MessagePattern en el microservicio pero no actualizas el gateway, la comunicación se rompe en runtime. La solución es implementar contratos tipados compartidos.
// libs/contracts/src/users.patterns.ts
// Librería compartida entre gateway y microservicios
export const UsersPatterns = {
GET_USER: { cmd: 'get_user' },
GET_USERS: { cmd: 'get_users' },
CREATE_USER: { cmd: 'create_user' },
UPDATE_USER: { cmd: 'update_user' },
DELETE_USER: { cmd: 'delete_user' },
VALIDATE_USER: { cmd: 'validate_user' },
} as const;
export const UsersEvents = {
USER_CREATED: 'user_created',
USER_UPDATED: 'user_updated',
USER_DELETED: 'user_deleted',
USER_PASSWORD_CHANGED: 'user_password_changed',
} as const;
// libs/contracts/src/users.dto.ts
// DTOs compartidos: misma validación en gateway y microservicio
import { IsEmail, IsString, MinLength, IsUUID } from 'class-validator';
export class CreateUserContract {
@IsEmail()
readonly email: string;
@IsString()
@MinLength(2)
readonly name: string;
@IsString()
@MinLength(8)
readonly password: string;
}
export class GetUserContract {
@IsUUID()
readonly id: string;
}
// libs/contracts/src/users.responses.ts
// Interfaces de respuesta: el gateway sabe exactamente qué esperar
export interface UserResponse {
readonly id: string;
readonly email: string;
readonly name: string;
readonly roles: readonly string[];
readonly createdAt: string;
}
export interface UsersListResponse {
readonly users: readonly UserResponse[];
readonly total: number;
}
Ahora el gateway y el microservicio comparten los mismos contratos:
// apps/api-gateway/src/users.controller.ts
import { UsersPatterns } from '@app/contracts';
import { type UserResponse } from '@app/contracts';
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserResponse> {
return firstValueFrom(
this.usersClient.send<UserResponse>(UsersPatterns.GET_USER, { id }),
);
}
// apps/users-service/src/users.controller.ts
import { UsersPatterns, UsersEvents } from '@app/contracts';
import { type UserResponse } from '@app/contracts';
@MessagePattern(UsersPatterns.GET_USER)
async getUser(@Payload() data: GetUserContract): Promise<UserResponse> {
return this.usersService.findOne(data.id);
}
Si alguien cambia un pattern sin actualizar ambos lados, TypeScript se queja en compilación, así no hay sorpresas en runtime.
ImportantePara que los decoradores
@IsEmail(),@IsString()y compañía validen de verdad, tienes que registrarValidationPipecomo pipe global también en el microservicio, no solo en el gateway. Si no, los decoradores están ahí de adorno y los datos entran sin filtrar:// apps/users-service/src/main.ts import { ValidationPipe } from '@nestjs/common'; const app = await NestFactory.createMicroservice(...); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
6. Transportes: TCP, Redis, RabbitMQ, NATS
TCP es genial para empezar, pero en producción necesitas un 💡 Message Broker Software intermediario que gestiona el envío y recepción de mensajes entre servicios. Garantiza la entrega, permite persistencia de mensajes, balanceo de carga entre consumidores y desacoplamiento total entre productor y consumidor. Ejemplos: Redis, RabbitMQ, NATS, Kafka. Más info → que garantice la entrega de mensajes:
| Transporte | Paquete | Ideal para | Persistencia |
|---|---|---|---|
| TCP | Incluido | Desarrollo, prototipos, comunicación directa punto a punto | ❌ No |
| Redis | ioredis | Ya tienes Redis (colas, cache). Pub/Sub simple, baja latencia | ❌ No (pub/sub) |
| RabbitMQ | amqplib | Mensajes que NO pueden perderse. Routing complejo, dead letter queues | ✅ Sí |
| NATS | nats | Ultra-rápido, cloud-native. Request-reply nativo, wildcards | ✅ Con JetStream |
| Kafka | kafkajs | Alto volumen, event sourcing, streaming, order garantizado | ✅ Sí |
Transporte Redis
Si ya tienes Redis del post 18 para las colas, reutilízalo como transporte:
// apps/users-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { UsersModule } from './users.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(UsersModule, {
transport: Transport.REDIS,
options: {
host: process.env.REDIS_HOST ?? 'redis',
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
retryAttempts: 5,
retryDelay: 3000,
},
});
await app.listen();
console.warn('Users microservice listening via Redis transport');
}
bootstrap();
// apps/api-gateway/src/app.module.ts
ClientsModule.register([
{
name: USERS_SERVICE,
transport: Transport.REDIS,
options: {
host: process.env.REDIS_HOST ?? 'redis',
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
},
},
]),
El cambio de TCP a Redis: solo cambias la configuración. Los @MessagePattern y @EventPattern no cambian. Los controllers no cambian. Los DTOs no cambian. Eso es lo bueno del sistema de transportes de NestJS.
AdvertenciaCuidado, que no todo es idéntico entre transportes. Redis usa pub/sub: si emites un evento y tienes 3 instancias del mismo servicio suscritas, todas lo reciben (fan-out). Con RabbitMQ por defecto, el mensaje se reparte entre las instancias (round-robin, una lo procesa). Además, en Redis pub/sub si el consumidor está caído cuando emites, el mensaje se pierde: no hay persistencia. Esto cambia el comportamiento de tu app, así que piensa qué semántica necesitas antes de elegir transporte.
Transporte RabbitMQ
Para mensajes que no pueden perderse (pagos, pedidos, transacciones):
// apps/payments-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { PaymentsModule } from './payments.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(PaymentsModule, {
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL ?? 'amqp://rabbitmq:5672'],
queue: 'payments_queue',
queueOptions: {
durable: true, // La cola sobrevive a reinicios de RabbitMQ
},
noAck: false, // Acknowledgement manual: el mensaje no se elimina hasta que el consumidor confirma
},
});
await app.listen();
console.warn('Payments microservice listening via RabbitMQ');
}
bootstrap();
// apps/api-gateway/src/app.module.ts
ClientsModule.register([
{
name: PAYMENTS_SERVICE,
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL ?? 'amqp://rabbitmq:5672'],
queue: 'payments_queue',
queueOptions: {
durable: true,
},
},
},
]),
Transporte NATS
El más rápido, cloud-native y con wildcards para routing avanzado:
// apps/notifications-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { NotificationsModule } from './notifications.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(NotificationsModule, {
transport: Transport.NATS,
options: {
servers: [process.env.NATS_URL ?? 'nats://nats:4222'],
},
});
await app.listen();
console.warn('Notifications microservice listening via NATS');
}
bootstrap();
NATS soporta Wildcards Wildcards Patrones con comodines para suscribirse a múltiples topics a la vez. En NATS: '*' coincide con un token (user.created, user.deleted) y '>' coincide con uno o más tokens (user.> coincide con user.created, user.role.changed, etc.). en los patterns:
// apps/notifications-service/src/audit.controller.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload, Ctx, NatsContext } from '@nestjs/microservices';
@Controller()
export class AuditController {
// Escucha TODOS los eventos de users: user.created, user.updated, user.deleted...
@EventPattern('user.*')
async handleUserEvent(@Payload() data: Record<string, unknown>, @Ctx() context: NatsContext): Promise<void> {
const subject = context.getSubject(); // 'user.created', 'user.updated', etc.
console.warn(`Audit: ${subject}`, data);
}
// Escucha TODO lo que pase en el sistema
@EventPattern('>')
async handleAllEvents(@Payload() data: Record<string, unknown>, @Ctx() context: NatsContext): Promise<void> {
console.warn(`Global audit: ${context.getSubject()}`, data);
}
}
7. Hybrid Apps: HTTP + Microservicio en el mismo proceso
A veces no necesitas servicios separados y solo quieres que tu app HTTP también escuche mensajes de microservicios. Esto es una 💡 Hybrid App Aplicación NestJS que expone tanto un servidor HTTP (para requests de clientes) como un listener de microservicios (para mensajes de otros servicios). Útil para la transición progresiva de monolito a microservicios: el servicio tiene su API HTTP y además puede recibir mensajes. Más info → :
// src/main.ts : App Híbrida
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
// 1. Crear la app HTTP normal
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);
// 2. Conectar TAMBIÉN como microservicio
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.REDIS,
options: {
host: process.env.REDIS_HOST ?? 'redis',
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
},
});
// 3. Puedes conectar múltiples transportes
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL ?? 'amqp://rabbitmq:5672'],
queue: 'my_queue',
queueOptions: { durable: true },
},
});
// 4. Iniciar TODOS los microservicios conectados
await app.startAllMicroservices();
// 5. Iniciar el servidor HTTP
await app.listen(3000);
console.warn('Hybrid app: HTTP on 3000 + Redis + RabbitMQ listeners');
}
bootstrap();
Ahora en el mismo controller puedes tener handlers HTTP y de microservicio:
// src/users/users.controller.ts
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { MessagePattern, EventPattern, Payload } from '@nestjs/microservices';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UsersService } from './users.service';
import { UsersPatterns, UsersEvents } from '@app/contracts';
import { type CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// ──── HTTP endpoints (para clientes) ────
@Get()
@UseGuards(JwtAuthGuard)
async findAll() {
return this.usersService.findAll();
}
@Get(':id')
@UseGuards(JwtAuthGuard)
async findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Post()
async create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
// ──── Microservice handlers (para otros servicios) ────
@MessagePattern(UsersPatterns.GET_USER)
async handleGetUser(@Payload() data: { id: string }) {
return this.usersService.findOne(data.id);
}
@MessagePattern(UsersPatterns.VALIDATE_USER)
async handleValidateUser(@Payload() data: { email: string; password: string }) {
return this.usersService.validateCredentials(data.email, data.password);
}
@EventPattern(UsersEvents.USER_PASSWORD_CHANGED)
async handlePasswordChanged(@Payload() data: { userId: string }): Promise<void> {
await this.usersService.invalidateAllSessions(data.userId);
}
}
Esto es perfecto para la transición progresiva: empiezas con un monolito, añades handlers de microservicio, y cuando estés listo, separas los servicios.
8. Docker Compose multi-servicio
La arquitectura completa en Docker:
# docker-compose.yml
services:
# ── API Gateway ──
api-gateway:
build:
context: .
dockerfile: apps/api-gateway/Dockerfile
ports:
- '3000:3000'
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
- RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672
- JWT_SECRET=${JWT_SECRET}
depends_on:
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
networks:
- microservices
# ── Users Service ──
users-service:
build:
context: .
dockerfile: apps/users-service/Dockerfile
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
- DB_HOST=users-db
- DB_PORT=5432
- DB_NAME=users
- DB_USER=postgres
- DB_PASSWORD=${DB_PASSWORD}
depends_on:
redis:
condition: service_healthy
users-db:
condition: service_healthy
networks:
- microservices
# ── Posts Service ──
posts-service:
build:
context: .
dockerfile: apps/posts-service/Dockerfile
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
- DB_HOST=posts-db
- DB_PORT=5432
- DB_NAME=posts
- DB_USER=postgres
- DB_PASSWORD=${DB_PASSWORD}
depends_on:
redis:
condition: service_healthy
posts-db:
condition: service_healthy
networks:
- microservices
# ── Notifications Service ──
notifications-service:
build:
context: .
dockerfile: apps/notifications-service/Dockerfile
environment:
- RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672
depends_on:
rabbitmq:
condition: service_healthy
networks:
- microservices
# ── Infrastructure ──
redis:
image: redis:7-alpine
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5
volumes:
- redis-data:/data
networks:
- microservices
rabbitmq:
image: rabbitmq:3-management-alpine
ports:
- '5672:5672'
- '15672:15672' # Management UI
environment:
- RABBITMQ_DEFAULT_USER=guest
- RABBITMQ_DEFAULT_PASS=guest
healthcheck:
test: ['CMD', 'rabbitmq-diagnostics', 'check_running']
interval: 10s
timeout: 5s
retries: 5
volumes:
- rabbitmq-data:/var/lib/rabbitmq
networks:
- microservices
users-db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=users
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 3s
retries: 5
volumes:
- users-db-data:/var/lib/postgresql/data
networks:
- microservices
posts-db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=posts
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 3s
retries: 5
volumes:
- posts-db-data:/var/lib/postgresql/data
networks:
- microservices
volumes:
redis-data:
rabbitmq-data:
users-db-data:
posts-db-data:
networks:
microservices:
driver: bridge
Cada servicio tiene su propia base de datos y NO comparten BD. Esto es fundamental porque si comparten BD, no son microservicios : es un monolito distribuido (lo peor de ambos mundos).
9. ClientProxy asíncrono con ConfigService
En producción, las URLs de los brokers vienen de variables de entorno gestionadas por ConfigService del post 4. Usa ClientsModule.registerAsync():
// apps/api-gateway/src/microservice-clients.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
export const USERS_SERVICE = 'USERS_SERVICE';
export const PAYMENTS_SERVICE = 'PAYMENTS_SERVICE';
@Module({
imports: [
ClientsModule.registerAsync([
{
name: USERS_SERVICE,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.REDIS,
options: {
host: config.getOrThrow<string>('REDIS_HOST'),
port: config.getOrThrow<number>('REDIS_PORT'),
},
}),
},
{
name: PAYMENTS_SERVICE,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.getOrThrow<string>('RABBITMQ_URL')],
queue: 'payments_queue',
queueOptions: { durable: true },
},
}),
},
]),
],
exports: [ClientsModule],
})
export class MicroserviceClientsModule {}
getOrThrow() lanza un error en el arranque si la variable no existe. Fallas rápido y claro en vez de descubrirlo cuando un mensaje no se entrega.
10. Manejo de errores entre servicios
Cuando un microservicio falla, el error tiene que llegar al gateway y luego al cliente. NestJS serializa las excepciones automáticamente con TCP, pero necesitas un poco de cuidado:
// libs/contracts/src/rpc-exception.filter.ts
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
interface RpcErrorPayload {
readonly statusCode: number;
readonly message: string;
readonly error: string;
}
@Catch(RpcException)
export class RpcExceptionFilter implements ExceptionFilter {
catch(exception: RpcException, _host: ArgumentsHost) {
const error = exception.getError() as string | RpcErrorPayload;
if (typeof error === 'string') {
return { statusCode: 500, message: error, error: 'Internal Server Error' };
}
return error;
}
}
En el microservicio, lanza RpcException en vez de HttpException:
// apps/users-service/src/users.service.ts
import { Injectable } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>
) {}
async findOne(id: string): Promise<User> {
const user = await this.usersRepository.findOne({ where: { id } });
if (!user) {
// ❌ throw new NotFoundException() : esto es HTTP, no funciona en microservicios
// ✅ throw RpcException : se serializa y viaja al gateway
throw new RpcException({
statusCode: 404,
message: `User with id ${id} not found`,
error: 'Not Found',
});
}
return user;
}
}
En el gateway, convierte el error RPC a HTTP:
// apps/api-gateway/src/filters/rpc-to-http.filter.ts
import { Catch, type ExceptionFilter, type ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { type Response } from 'express';
interface MicroserviceError {
readonly statusCode?: number;
readonly message?: string;
readonly error?: string;
}
@Catch()
export class RpcToHttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// Si ya es una HttpException, déjala pasar
if (exception instanceof HttpException) {
const status = exception.getStatus();
response.status(status).json(exception.getResponse());
return;
}
// Error del microservicio
const rpcError = exception as MicroserviceError;
const statusCode = rpcError?.statusCode ?? HttpStatus.INTERNAL_SERVER_ERROR;
const message = rpcError?.message ?? 'Internal server error';
const error = rpcError?.error ?? 'Internal Server Error';
response.status(statusCode).json({
statusCode,
message,
error,
});
}
}
// apps/api-gateway/src/main.ts
app.useGlobalFilters(new RpcToHttpExceptionFilter());
Flujo completo:
Cliente → GET /users/999 → API Gateway
→ send({ cmd: 'get_user' }, { id: '999' }) → Users Service
→ throw RpcException(404)
← { statusCode: 404, message: '...' } ←────
← HTTP 404 { statusCode: 404, message: '...' } ←──────────
11. Resiliencia: timeouts, reintentos y circuit breaker
Los microservicios se comunican por red. La red falla y necesitas resiliencia.
Timeout en el gateway
// apps/api-gateway/src/users.controller.ts
import { timeout, catchError } from 'rxjs';
import { RequestTimeoutException } from '@nestjs/common';
@Get(':id')
@UseGuards(JwtAuthGuard)
async findOne(@Param('id') id: string): Promise<UserResponse> {
return firstValueFrom(
this.usersClient.send<UserResponse>(UsersPatterns.GET_USER, { id }).pipe(
// Timeout de 5 segundos
timeout(5000),
catchError((err) => {
if (err.name === 'TimeoutError') {
throw new RequestTimeoutException(
'Users service did not respond in time',
);
}
throw err;
}),
),
);
}
Helper reutilizable para resiliencia
// apps/api-gateway/src/common/resilience.helper.ts
import { Observable, timeout, retry, catchError, throwError } from 'rxjs';
import { RequestTimeoutException, ServiceUnavailableException } from '@nestjs/common';
interface ResilienceOptions {
readonly timeoutMs?: number;
readonly retries?: number;
readonly retryDelayMs?: number;
}
const DEFAULT_OPTIONS: Required<ResilienceOptions> = {
timeoutMs: 5000,
retries: 2,
retryDelayMs: 1000,
} as const;
export function withResilience<T>(source$: Observable<T>, options: ResilienceOptions = {}): Observable<T> {
const { timeoutMs, retries, retryDelayMs } = {
...DEFAULT_OPTIONS,
...options,
};
return source$.pipe(
timeout(timeoutMs),
retry({
count: retries,
delay: retryDelayMs,
}),
catchError((err) => {
if (err.name === 'TimeoutError') {
return throwError(() => new RequestTimeoutException('Service timeout'));
}
if (err.code === 'ECONNREFUSED') {
return throwError(() => new ServiceUnavailableException('Service unavailable'));
}
return throwError(() => err);
})
);
}
Uso limpio en el controller:
// apps/api-gateway/src/users.controller.ts
import { withResilience } from './common/resilience.helper';
@Get(':id')
@UseGuards(JwtAuthGuard)
async findOne(@Param('id') id: string): Promise<UserResponse> {
return firstValueFrom(
withResilience(
this.usersClient.send<UserResponse>(UsersPatterns.GET_USER, { id }),
{ timeoutMs: 3000, retries: 2 },
),
);
}
@Get()
@UseGuards(JwtAuthGuard)
async findAll(): Promise<UsersListResponse> {
return firstValueFrom(
withResilience(
this.usersClient.send<UsersListResponse>(UsersPatterns.GET_USERS, {}),
),
);
}
12. Emitir eventos entre servicios
Combinamos los eventos del post 17 con la comunicación entre servicios:
// apps/users-service/src/users.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { firstValueFrom } from 'rxjs';
import { User } from './entities/user.entity';
import { NOTIFICATIONS_SERVICE } from './users.module';
import { UsersEvents } from '@app/contracts';
import { type CreateUserDto } from './dto/create-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
@Inject(NOTIFICATIONS_SERVICE)
private readonly notificationsClient: ClientProxy
) {}
async create(dto: CreateUserDto): Promise<User> {
const user = this.usersRepository.create(dto);
const savedUser = await this.usersRepository.save(user);
// OJO: emit() devuelve un Observable frío. Si no te suscribes
// o no haces firstValueFrom, el mensaje NO se envía.
await firstValueFrom(
this.notificationsClient.emit(UsersEvents.USER_CREATED, {
userId: savedUser.id,
email: savedUser.email,
name: savedUser.name,
createdAt: savedUser.createdAt.toISOString(),
})
);
return savedUser;
}
async delete(id: string): Promise<void> {
await this.usersRepository.softDelete(id);
await firstValueFrom(
this.notificationsClient.emit(UsersEvents.USER_DELETED, {
userId: id,
deletedAt: new Date().toISOString(),
})
);
}
}
PeligroEste es uno de los fallos más habituales cuando te estrenas con
@nestjs/microservices: piensas queemit()ya dispara el evento y te quedas tan ancho. Pues no. Tantosend()comoemit()devuelven Observables fríos: hasta que no hay una suscripción, no pasa nada. Si te suscribes con.subscribe()dispara el evento sin esperar (fire-and-forget de verdad), y si usasawait firstValueFrom(...)esperas a que el broker confirme la publicación. Si te saltas este paso, tu evento se queda en el limbo.
// apps/notifications-service/src/notifications.controller.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { NotificationsService } from './notifications.service';
import { UsersEvents } from '@app/contracts';
interface UserCreatedPayload {
readonly userId: string;
readonly email: string;
readonly name: string;
readonly createdAt: string;
}
interface UserDeletedPayload {
readonly userId: string;
readonly deletedAt: string;
}
@Controller()
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@EventPattern(UsersEvents.USER_CREATED)
async handleUserCreated(@Payload() data: UserCreatedPayload): Promise<void> {
await this.notificationsService.sendWelcomeEmail(data.email, data.name);
console.warn(`Welcome email sent to ${data.email}`);
}
@EventPattern(UsersEvents.USER_DELETED)
async handleUserDeleted(@Payload() data: UserDeletedPayload): Promise<void> {
await this.notificationsService.sendAccountDeletedEmail(data.userId);
console.warn(`Account deleted notification sent for ${data.userId}`);
}
}
La diferencia clave entre send() y emit():
// send() : Request-Response. Espera la respuesta del microservicio.
const user = await firstValueFrom(this.usersClient.send<UserResponse>(UsersPatterns.GET_USER, { id }));
// emit() : Event-Based, fire-and-forget. Pero acuérdate: es un Observable
// frío. Tienes que suscribirte o esperará al viaje de Matías.
await firstValueFrom(this.notificationsClient.emit(UsersEvents.USER_CREATED, payload));
// Alternativa sin esperar:
// this.notificationsClient.emit(UsersEvents.USER_CREATED, payload).subscribe();
13. Estructura de proyecto: Monorepo con NestJS
Para manejar múltiples servicios, NestJS tiene soporte nativo de 💡 Monorepo Un único repositorio que contiene todos los servicios (apps) y el código compartido (libs). Todos los servicios comparten las mismas dependencias, configuración de TypeScript y herramientas. NestJS CLI soporta monorepos con el comando 'nest generate app/lib'. Alternativa a tener un repositorio por servicio. Más info → :
project-root/
├── apps/
│ ├── api-gateway/ # HTTP gateway (:3000)
│ │ ├── src/
│ │ │ ├── main.ts
│ │ │ ├── app.module.ts
│ │ │ ├── users.controller.ts
│ │ │ └── posts.controller.ts
│ │ ├── Dockerfile
│ │ └── tsconfig.app.json
│ │
│ ├── users-service/ # Microservicio de usuarios
│ │ ├── src/
│ │ │ ├── main.ts
│ │ │ ├── users.module.ts
│ │ │ ├── users.controller.ts
│ │ │ ├── users.service.ts
│ │ │ └── entities/
│ │ ├── Dockerfile
│ │ └── tsconfig.app.json
│ │
│ ├── posts-service/ # Microservicio de posts
│ │ └── ...
│ │
│ └── notifications-service/ # Microservicio de notificaciones
│ └── ...
│
├── libs/
│ └── contracts/ # Código compartido
│ └── src/
│ ├── index.ts # Re-export todo
│ ├── users.patterns.ts
│ ├── users.dto.ts
│ ├── users.responses.ts
│ ├── posts.patterns.ts
│ └── posts.dto.ts
│
├── docker-compose.yml
├── nest-cli.json # Configuración del monorepo
├── tsconfig.json # Base TypeScript config
└── package.json
// nest-cli.json
{
"collection": "@nestjs/schematics",
"monorepo": true,
"root": "apps/api-gateway",
"sourceRoot": "apps/api-gateway/src",
"projects": {
"api-gateway": {
"type": "application",
"root": "apps/api-gateway",
"entryFile": "main",
"sourceRoot": "apps/api-gateway/src",
"compilerOptions": {
"tsConfigPath": "apps/api-gateway/tsconfig.app.json"
}
},
"users-service": {
"type": "application",
"root": "apps/users-service",
"entryFile": "main",
"sourceRoot": "apps/users-service/src",
"compilerOptions": {
"tsConfigPath": "apps/users-service/tsconfig.app.json"
}
},
"contracts": {
"type": "library",
"root": "libs/contracts",
"entryFile": "index",
"sourceRoot": "libs/contracts/src",
"compilerOptions": {
"tsConfigPath": "libs/contracts/tsconfig.lib.json"
}
}
}
}
14. Serialización y deserialización personalizada
Cuando los mensajes viajan por la red, se serializan a JSON. Si usas clases con métodos o tipos complejos (Date, Map, Set), necesitas serialización personalizada:
// libs/contracts/src/serialization/custom-serializer.ts
import { type Serializer } from '@nestjs/microservices';
interface SerializedMessage {
readonly pattern: unknown;
readonly data: unknown;
readonly id: string;
}
export class CustomSerializer implements Serializer {
serialize(value: unknown): SerializedMessage {
const serialized = value as SerializedMessage;
return {
...serialized,
data: JSON.parse(
JSON.stringify(serialized.data, (_key, val) => {
// Convertir Date a ISO string
if (val instanceof Date) {
return { __type: 'Date', value: val.toISOString() };
}
// Convertir Map a array de entries
if (val instanceof Map) {
return { __type: 'Map', value: [...val.entries()] };
}
return val;
})
),
};
}
}
// libs/contracts/src/serialization/custom-deserializer.ts
import { type Deserializer, type IncomingRequest } from '@nestjs/microservices';
interface TypedValue {
readonly __type: string;
readonly value: unknown;
}
function isTypedValue(val: unknown): val is TypedValue {
return typeof val === 'object' && val !== null && '__type' in val;
}
export class CustomDeserializer implements Deserializer {
deserialize(value: unknown): IncomingRequest {
const deserialized = value as IncomingRequest;
if (deserialized.data) {
deserialized.data = JSON.parse(
JSON.stringify(deserialized.data, (_key, val) => {
if (isTypedValue(val)) {
if (val.__type === 'Date') {
return new Date(val.value as string);
}
if (val.__type === 'Map') {
return new Map(val.value as Array<[unknown, unknown]>);
}
}
return val;
})
);
}
return deserialized;
}
}
Aplícalos en la configuración:
// apps/api-gateway/src/app.module.ts
import { CustomSerializer } from '@app/contracts/serialization/custom-serializer';
import { CustomDeserializer } from '@app/contracts/serialization/custom-deserializer';
ClientsModule.register([
{
name: USERS_SERVICE,
transport: Transport.REDIS,
options: {
host: 'redis',
port: 6379,
serializer: new CustomSerializer(),
deserializer: new CustomDeserializer(),
},
},
]),
15. Errores comunes
❌ Compartir la base de datos entre microservicios:
// ❌ MONOLITO DISTRIBUIDO : lo peor de ambos mundos
// users-service y posts-service leen de la misma BD
@Module({
imports: [
TypeOrmModule.forRoot({
host: 'shared-database', // La misma BD para todos
database: 'shared_db',
}),
],
})
// ✅ Cada servicio tiene su propia BD
// users-service
TypeOrmModule.forRoot({
host: 'users-db',
database: 'users',
})
// posts-service
TypeOrmModule.forRoot({
host: 'posts-db',
database: 'posts',
})
❌ Usar HttpException en microservicios:
// ❌ HttpException no se serializa bien entre servicios
throw new NotFoundException('User not found');
// ✅ RpcException se diseñó para comunicación entre servicios
throw new RpcException({
statusCode: 404,
message: 'User not found',
error: 'Not Found',
});
❌ No manejar la conexión al broker:
// ❌ No verificar que el broker está disponible
const app = await NestFactory.createMicroservice(AppModule, {
transport: Transport.RMQ,
options: {
urls: ['amqp://rabbitmq:5672'],
},
});
// ✅ Configurar reintentos y verificar la conexión
const app = await NestFactory.createMicroservice(AppModule, {
transport: Transport.RMQ,
options: {
urls: ['amqp://rabbitmq:5672'],
queue: 'my_queue',
queueOptions: { durable: true },
socketOptions: {
heartbeatIntervalInSeconds: 60,
reconnectTimeInSeconds: 5,
},
},
});
❌ Olvidar firstValueFrom en el gateway:
// ❌ send() devuelve Observable, no Promise : esto no espera
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersClient.send({ cmd: 'get_user' }, { id });
// Devuelve un Observable frío que nunca se suscribe
}
// ✅ Convertir a Promise con firstValueFrom
@Get(':id')
async findOne(@Param('id') id: string) {
return firstValueFrom(
this.usersClient.send<UserResponse>({ cmd: 'get_user' }, { id }),
);
}
❌ Llamar a emit() y pensar que ya se ha enviado:
// ❌ Observable frío sin suscripción : el mensaje NO sale del servicio
this.client.emit('user_created', payload);
// ✅ Suscríbete (fire-and-forget de verdad) o espera la confirmación
this.client.emit('user_created', payload).subscribe();
// o bien
await firstValueFrom(this.client.emit('user_created', payload));
❌ No ejecutar ValidationPipe en el microservicio:
// ❌ Tus DTOs con @IsEmail(), @IsString()... no validan nada
// si solo lo pones en el gateway.
const app = await NestFactory.createMicroservice(UsersModule, {...});
await app.listen();
// ✅ Registrar el pipe global también aquí
const app = await NestFactory.createMicroservice(UsersModule, {...});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen();
Recapitulando
Request-Response con @MessagePattern para cuando necesitas el resultado. Event-Based con @EventPattern para fire-and-forget.
TCP para desarrollo. Redis para pub/sub simple. RabbitMQ para mensajes que no pueden perderse. NATS para ultra-velocidad. Cambiar transporte = cambiar config, no código.
API Gateway como punto de entrada HTTP. Servicios internos con su propia BD. Contratos compartidos en libs/. Docker Compose para orquestar todo. Hybrid apps para transición progresiva.
En el próximo post veremos CQRS y Event Sourcing : separar las operaciones de lectura y escritura con @nestjs/cqrs, Commands, Queries, Events, Handlers y Sagas. Llevaremos la arquitectura event-driven al siguiente nivel.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿Cuál es la diferencia entre @MessagePattern y @EventPattern?
2. ¿Qué función se usa en el gateway para convertir el Observable de send() a Promise?
3. ¿Qué excepción se debe usar en un microservicio en vez de HttpException?
4. ¿Qué es una Hybrid App en NestJS?
5. ¿Qué pasa si dos microservicios comparten la misma base de datos?