🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

Microservicios: patrones y transporte en NestJS

Serie NestJS #23 : @nestjs/microservices, patterns request-response y event-based, transportes TCP, Redis, RabbitMQ y NATS, hybrid apps y docker-compose multi-servicio

Escrito por domin el 21 de abril de 2026

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.

resuelven esto porque cada dominio es un servicio independiente que se comunica con los demás por la red.

EA, amo al lío.

Diagrama de microservicios comunicándose entre sí con diferentes transportes en NestJS.

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
Microservicios cuando...
  • 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í
Monolito cuando...
  • 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)
Importante

Comienza 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ónCómo funcionaCuándo usarloDecorador
Request-Response

El cliente envía un mensaje y espera la respuesta. Es asíncrono (devuelve un Observable), pero simula un flujo tipo petición-respuesta de toda la vida.

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 Observable frío, hay que suscribirse para que se emita de verdad.

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

Instalación de @nestjs/microservices 0 / 1
$
Pulsa para ejecutar el siguiente comando

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);
    }
}

4. El API Gateway

El 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í:


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.

Importante

Para que los decoradores @IsEmail(), @IsString() y compañía validen de verdad, tienes que registrar ValidationPipe como 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 que garantice la entrega de mensajes:

TransportePaqueteIdeal paraPersistencia
TCPIncluidoDesarrollo, prototipos, comunicación directa punto a punto❌ No
RedisioredisYa tienes Redis (colas, cache). Pub/Sub simple, baja latencia❌ No (pub/sub)
RabbitMQamqplibMensajes que NO pueden perderse. Routing complejo, dead letter queues✅ Sí
NATSnatsUltra-rápido, cloud-native. Request-reply nativo, wildcards✅ Con JetStream
KafkakafkajsAlto volumen, event sourcing, streaming, order garantizado✅ Sí

Transporte Redis

Si ya tienes Redis del post 18 para las colas, reutilízalo como transporte:

Instalación del transporte Redis 0 / 1
$
Pulsa para ejecutar el siguiente comando
// 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.

Advertencia

Cuidado, 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):

Instalación del transporte RabbitMQ 0 / 1
$
Pulsa para ejecutar el siguiente comando
// 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:

Instalación del transporte NATS 0 / 1
$
Pulsa para ejecutar el siguiente comando
// 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 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 :

// 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
Levantar la arquitectura de microservicios 0 / 2
$
Pulsa para ejecutar el siguiente comando

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(),
            })
        );
    }
}
Peligro

Este es uno de los fallos más habituales cuando te estrenas con @nestjs/microservices: piensas que emit() ya dispara el evento y te quedas tan ancho. Pues no. Tanto send() como emit() 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 usas await 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 :

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"
            }
        }
    }
}
Comandos del monorepo NestJS 0 / 4
$
Pulsa para ejecutar el siguiente comando

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

📨 Patrones

Request-Response con @MessagePattern para cuando necesitas el resultado. Event-Based con @EventPattern para fire-and-forget.

🔌 Transportes

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.

🏗️ Arquitectura

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?