Skip to content

🎯 Meta: aprender el framework backend más estructurado del ecosistema JavaScript. NestJS te obliga a organizar el código con módulos, inyección de dependencias y patrones. Es "el Laravel/Spring de Node": ideal para aplicaciones grandes y equipos.

Versiones: Node.js 24 LTS · NestJS 11 · TypeScript 7 · Prisma / TypeORM.

📘 ¿No dominas TypeScript? Lee primero el Apéndice A. Y actualiza a TypeScript 7 (compilador nativo en Go, GA jul-2026): compila 8-12× más rápido sin cambiar tu código.


6.1 · Primero: entender Node y TypeScript

Node.js es JavaScript ejecutándose fuera del navegador (en el servidor). Su modelo es asíncrono y de un solo hilo con event loop: no se bloquea esperando I/O, atiende miles de conexiones. Por eso brilla en APIs y tiempo real.

TypeScript es JavaScript + tipos. Detecta errores antes de ejecutar y hace el código mantenible. En backend serio, TypeScript es el estándar de facto en 2026.

🧠 Node "crudo" vs framework: podrías hacer una API con el módulo http de Node, pero tendrías que resolver rutas, validación, DI, etc. tú mismo (como Flask, pero peor). NestJS te da una arquitectura completa y opinada. Es el otro extremo de Elysia (cap. 07), que es minimalista.


6.2 · Instalación

bash
node --version          # 24.x (LTS)
npm i -g @nestjs/cli

nest new tienda-api     # elige npm/pnpm cuando pregunte
cd tienda-api
npm run start:dev       # arranca con recarga en http://localhost:3000

6.3 · Los 3 bloques de NestJS

Todo en NestJS gira en torno a tres piezas. Entiéndelas y entiendes el framework entero:

┌──────────────────────────────────────────────────────────────┐
│  CONTROLLER  →  recibe la petición HTTP, extrae datos,         │
│                 llama al service y devuelve la respuesta.       │
│                 (NO lleva lógica de negocio)                    │
├──────────────────────────────────────────────────────────────┤
│  SERVICE     →  la lógica de negocio: valida reglas, habla      │
│                 con la BD. Es "inyectable" (@Injectable).       │
├──────────────────────────────────────────────────────────────┤
│  MODULE      →  agrupa controllers + services relacionados y    │
│                 declara qué expone e importa. Organiza la app.  │
└──────────────────────────────────────────────────────────────┘

Genera un CRUD completo con un comando (crea las 3 piezas + DTOs + tests):

bash
nest g resource productos       # elige "REST API" y "yes" a los endpoints CRUD

6.4 · Controller

typescript
// src/productos/productos.controller.ts
import {
  Controller, Get, Post, Put, Delete, Body, Param, ParseIntPipe, HttpCode,
} from '@nestjs/common';
import { ProductosService } from './productos.service';
import { CrearProductoDto } from './dto/crear-producto.dto';

@Controller('productos')                     // prefijo de ruta: /productos
export class ProductosController {
  // Inyección de dependencias: Nest crea y pasa el service solo
  constructor(private readonly productos: ProductosService) {}

  @Get()
  listar() {
    return this.productos.listar();
  }

  @Get(':id')
  ver(@Param('id', ParseIntPipe) id: number) {   // ParseIntPipe valida que sea número
    return this.productos.buscar(id);
  }

  @Post()
  crear(@Body() dto: CrearProductoDto) {          // @Body inyecta y valida el cuerpo
    return this.productos.crear(dto);
  }

  @Put(':id')
  editar(@Param('id', ParseIntPipe) id: number, @Body() dto: CrearProductoDto) {
    return this.productos.editar(id, dto);
  }

  @Delete(':id')
  @HttpCode(204)
  borrar(@Param('id', ParseIntPipe) id: number) {
    return this.productos.borrar(id);
  }
}

🧠 Decoradores (@Controller, @Get, @Body…): son "etiquetas" que añaden comportamiento. @Get(':id') dice "esta función maneja GET /productos/:id". Es el mismo concepto que @app.route de Flask o #[Route] de otros, pero muy pulido.


6.5 · DTOs y validación

Un DTO (Data Transfer Object) define y valida la forma de los datos que entran. Se usa class-validator:

bash
npm i class-validator class-transformer
typescript
// src/productos/dto/crear-producto.dto.ts
import { IsString, IsNumber, IsInt, Min, MaxLength, IsOptional } from 'class-validator';

export class CrearProductoDto {
  @IsString()
  @MaxLength(255)
  nombre: string;

  @IsNumber()
  @Min(0)
  precio: number;

  @IsInt()
  @Min(0)
  @IsOptional()
  stock?: number;
}

Activa la validación global en main.ts:

typescript
// src/main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,               // elimina campos no declarados en el DTO
    forbidNonWhitelisted: true,    // rechaza si mandan campos de más
    transform: true,               // convierte tipos automáticamente
  }));
  await app.listen(3000);
}
bootstrap();

Ahora cualquier dato inválido devuelve 400 con detalle, automáticamente.

💡 Tip de seguridad: whitelist: true + forbidNonWhitelisted: true es una defensa importante: aunque un atacante mande { "es_admin": true }, el DTO lo descarta. Igual que separar modelos entrada/salida en FastAPI.


6.6 · Service (lógica de negocio)

typescript
// src/productos/productos.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CrearProductoDto } from './dto/crear-producto.dto';

@Injectable()
export class ProductosService {
  private items = new Map<number, any>();
  private seq = 0;

  listar() {
    return [...this.items.values()];
  }

  buscar(id: number) {
    const p = this.items.get(id);
    if (!p) throw new NotFoundException(`Producto ${id} no existe`);   // → 404 solo
    return p;
  }

  crear(dto: CrearProductoDto) {
    const producto = { id: ++this.seq, ...dto, disponible: (dto.stock ?? 0) > 0 };
    this.items.set(producto.id, producto);
    return producto;
  }

  editar(id: number, dto: CrearProductoDto) {
    const p = this.buscar(id);
    Object.assign(p, dto);
    return p;
  }

  borrar(id: number) {
    this.buscar(id);            // lanza 404 si no existe
    this.items.delete(id);
  }
}

🧠 Excepciones semánticas: throw new NotFoundException() → Nest responde 404 con JSON automáticamente. Hay una para cada caso: BadRequestException (400), UnauthorizedException (401), ForbiddenException (403), ConflictException (409). No armas el response a mano.


6.7 · Base de datos con Prisma

El ORM más popular con NestJS en 2026 es Prisma (tipado, migraciones, excelente DX):

bash
npm i prisma @prisma/client
npx prisma init --datasource-provider postgresql

Define el esquema en prisma/schema.prisma:

prisma
generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Producto {
  id        Int      @id @default(autoincrement())
  nombre    String
  precio    Decimal  @db.Decimal(10, 2)
  stock     Int      @default(0)
  activo    Boolean  @default(true)
  creadoEn  DateTime @default(now()) @map("creado_en")

  @@map("productos")
}
bash
npx prisma migrate dev --name init      # crea la migración y la aplica

Un servicio Prisma inyectable:

typescript
// src/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

Y lo usas en el service (fíjate en el tipado total):

typescript
@Injectable()
export class ProductosService {
  constructor(private prisma: PrismaService) {}

  listar() {
    return this.prisma.producto.findMany({ orderBy: { creadoEn: 'desc' } });
  }

  async buscar(id: number) {
    const p = await this.prisma.producto.findUnique({ where: { id } });
    if (!p) throw new NotFoundException(`Producto ${id} no existe`);
    return p;
  }

  crear(dto: CrearProductoDto) {
    return this.prisma.producto.create({ data: dto });
  }
}

💡 Prisma vs TypeORM: Prisma tiene mejor experiencia y tipado; TypeORM se parece más a Eloquent (patrón Active Record) y se integra "más Nest". Para empezar, Prisma.


6.8 · Módulo (el pegamento)

typescript
// src/productos/productos.module.ts
import { Module } from '@nestjs/common';
import { ProductosController } from './productos.controller';
import { ProductosService } from './productos.service';
import { PrismaService } from '../prisma.service';

@Module({
  controllers: [ProductosController],
  providers: [ProductosService, PrismaService],
  exports: [ProductosService],       // para que otros módulos lo usen
})
export class ProductosModule {}

Y se importa en el módulo raíz AppModule. Así se compone la aplicación: módulos que importan módulos. Escala a cientos de features sin caos.


6.9 · Autenticación (JWT + Guards)

NestJS usa Guards para proteger rutas (deciden si una petición pasa o no):

bash
npm i @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
typescript
// auth.guard.ts (simplificado)
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwt: JwtService) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const token = req.headers.authorization?.split(' ')[1];
    if (!token) throw new UnauthorizedException();
    try {
      req.user = await this.jwt.verifyAsync(token, { secret: process.env.JWT_SECRET });
      return true;
    } catch {
      throw new UnauthorizedException('Token inválido');
    }
  }
}
typescript
// Proteger un endpoint:
@UseGuards(AuthGuard)
@Post()
crear(@Body() dto: CrearProductoDto) { ... }

🧠 Guard vs Middleware vs Pipe vs Interceptor: NestJS tiene una "tubería" ordenada: Middleware (antes de todo) → Guard (¿puede pasar? auth/permisos) → Pipe (validar/transformar datos) → ControllerInterceptor (envolver la respuesta, logging). Cada uno tiene su rol. Esta separación limpia es la razón de existir de NestJS.


6.9.1 · Interceptors y Exception Filters

Un Interceptor envuelve la ejecución del handler: puede transformar la respuesta, medir tiempos o cachear, corriendo tanto antes como después del controller (a diferencia del Guard, que solo decide sí/no antes):

typescript
// logging.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const inicio = Date.now();
    const req = ctx.switchToHttp().getRequest();
    return next.handle().pipe(               // next.handle() ejecuta el controller
      tap(() => console.log(`${req.method} ${req.url} — ${Date.now() - inicio}ms`)),
    );
  }
}
typescript
// Envolver TODAS las respuestas en { data, timestamp } — un formato uniforme:
@Injectable()
export class RespuestaInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(map(data => ({ data, timestamp: new Date().toISOString() })));
  }
}

// Aplícalo global en main.ts:
app.useGlobalInterceptors(new LoggingInterceptor(), new RespuestaInterceptor());

Un Exception Filter hace lo opuesto: captura errores y decide la respuesta, útil para dar un formato de error consistente en toda la API (o traducir excepciones de Prisma a HTTP):

typescript
// prisma-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
  catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
    if (exception.code === 'P2002') {        // violación de constraint único
      const res = host.switchToHttp().getResponse();
      return res.status(409).json({ message: 'Ese registro ya existe' });
    }
    throw exception;
  }
}

🧠 Orden completo de la tubería de Nest (amplía el diagrama de 6.9): Middleware → Guard → Interceptor (parte "antes") → Pipe → Controller → Interceptor (parte "después") → Exception Filter (solo si algo lanzó una excepción). Cada capa tiene una responsabilidad y ninguna conoce la siguiente — el mismo principio de capas del capítulo 11.


6.9.2 · Colas de trabajo con BullMQ

Igual que Flask, NestJS no trae colas de fábrica. La librería estándar es BullMQ (sobre Redis, apéndice B):

bash
npm i @nestjs/bullmq bullmq
typescript
// productos.module.ts
import { BullModule } from '@nestjs/bullmq';

@Module({
  imports: [BullModule.registerQueue({ name: 'notificaciones' })],
  // ...
})
export class ProductosModule {}
typescript
// productos.service.ts — encolar en vez de bloquear la petición
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';

@Injectable()
export class ProductosService {
  constructor(@InjectQueue('notificaciones') private cola: Queue) {}

  async crear(dto: CrearProductoDto) {
    const producto = await this.prisma.producto.create({ data: dto });
    await this.cola.add('producto-creado', { id: producto.id }, {
      attempts: 3,                                       // reintentos
      backoff: { type: 'exponential', delay: 5000 },
    });
    return producto;
  }
}
typescript
// notificaciones.processor.ts — el worker que procesa la cola
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';

@Processor('notificaciones')
export class NotificacionesProcessor extends WorkerHost {
  async process(job: Job) {
    if (job.name === 'producto-creado') {
      // enviar email, avisar a un webhook, etc. — el mismo principio de idempotencia
      // que en Laravel (3.14) y Flask (4.13): reintentos = puede ejecutarse más de una vez.
    }
  }
}

6.10 · Tests (Jest — unit + e2e)

NestJS trae Jest configurado y genera tests con cada recurso.

Test unitario del service (con dependencias simuladas):

typescript
// productos.service.spec.ts
import { Test } from '@nestjs/testing';
import { ProductosService } from './productos.service';
import { PrismaService } from '../prisma.service';

describe('ProductosService', () => {
  let service: ProductosService;
  const prismaMock = {
    producto: { create: jest.fn(dto => ({ id: 1, ...dto.data })) },
  };

  beforeEach(async () => {
    const modulo = await Test.createTestingModule({
      providers: [
        ProductosService,
        { provide: PrismaService, useValue: prismaMock },   // inyecta el mock
      ],
    }).compile();
    service = modulo.get(ProductosService);
  });

  it('crea un producto', () => {
    const r = service.crear({ nombre: 'Teclado', precio: 99 });
    expect(r).resolves.toHaveProperty('id', 1);
  });
});

Test e2e (petición HTTP real contra la app):

typescript
// test/productos.e2e-spec.ts
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('Productos (e2e)', () => {
  let app;
  beforeAll(async () => {
    const modulo = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = modulo.createNestApplication();
    await app.init();
  });

  it('POST /productos crea', () => {
    return request(app.getHttpServer())
      .post('/productos')
      .send({ nombre: 'Mouse', precio: 49.9 })
      .expect(201);
  });

  it('POST /productos sin nombre → 400', () => {
    return request(app.getHttpServer())
      .post('/productos').send({ precio: 10 }).expect(400);
  });

  afterAll(() => app.close());
});
bash
npm run test          # unitarios
npm run test:e2e      # end to end
npm run test:cov      # cobertura

💡 Tip: el Test.createTestingModule es DI para tests: reemplazas cualquier dependencia por un mock con useValue. Igual filosofía que dependency_overrides de FastAPI. Testear es fácil porque usaste inyección de dependencias.


6.11 · Buenas prácticas NestJS

  1. Controllers finos, services con la lógica. El controller solo traduce HTTP ↔ service.
  2. Un módulo por feature. Nada de un módulo gigante.
  3. DTOs con class-validator + ValidationPipe global (whitelist activado).
  4. Excepciones de Nest (NotFoundException…), no armes responses a mano.
  5. Config con @nestjs/config y .env validado por esquema.
  6. Interceptors para logging y formato de respuesta uniforme.
  7. Swagger gratis: @nestjs/swagger genera /docs como FastAPI.

✅ Ejercicio del capítulo

La API del blog en NestJS con arquitectura completa:

1. Módulos: articulos, comentarios, auth.
2. Prisma con relación Articulo↔Comentario↔User.
3. DTOs validados + ValidationPipe global.
4. Services con toda la lógica; controllers finos.
5. AuthGuard JWT protegiendo escritura.
6. Tests: unit del service (con mock de Prisma) + e2e de los endpoints.
7. Añade Swagger y revisa /api (docs).
8. Añade un Interceptor de logging global que mida cuánto tarda cada petición.
9. Encola con BullMQ el envío de notificación al crear un comentario.

Ya dominas el framework más estructurado. Ahora, el contrapunto: Elysia, minimalista y rapidísimo sobre Bun.

💡 Pistas de la solución
  • La relación Articulo↔Comentario↔User en Prisma: Comentario tiene articuloId y autorId como claves foráneas; en el schema.prisma, cada relación necesita el campo Y su contraparte @relation en el otro modelo.
  • El AuthGuard JWT protege rutas de escritura con @UseGuards(AuthGuard) en el controller o método — pero recuerda que el JwtModule debe registrarse con el mismo secret que firma el token en el login.
  • Para mockear Prisma en tests unitarios de servicio: useValue: { producto: { findMany: jest.fn() } } en el providers del TestingModule — no necesitas una BD real para testear la lógica del service.

🧠 Autoevaluación

Porque el controller es el punto de entrada HTTP (traduce petición↔respuesta) y no debería saber nada del dominio; la lógica de negocio vive en el service para poder testearla sin levantar un servidor HTTP, y para poder reutilizarla desde otro controller o un job en segundo plano sin duplicar código.

Testeabilidad y desacoplamiento: en Test.createTestingModule, cualquier dependencia se sustituye por un mock con useValue sin tocar la clase que la usa. Es el mismo principio de inversión de dependencias que viste con Depends en FastAPI (cap. 05) — cambia la sintaxis, no la idea.

Porque descarta automáticamente cualquier campo del body que no esté declarado en el DTO — sin esto, un atacante podría mandar campos extra (como esAdmin: true) que tu código podría usar sin querer si en algún punto haces un ...body sin filtrar.

Que el secret usado para firmar el JWT en el login sea EXACTAMENTE el mismo que el configurado en el JwtModule/estrategia que verifica el guard — un secret distinto (o leído de una variable de entorno distinta) hace que la verificación falle o, peor, que un guard mal configurado no se ejecute en absoluto.

El Guard solo decide sí/no antes de que se ejecute el controller (autenticación/autorización); el Interceptor envuelve la ejecución completa y puede actuar tanto antes como después — transformar la respuesta, medir tiempos o cachear resultados una vez el controller ya respondió.

Porque BullMQ reintenta jobs fallidos (attempts/backoff), y un reintento vuelve a ejecutar el process() completo — si el efecto (enviar un email, cobrar) no es idempotente, un reintento tras un fallo parcial lo duplicaría. Es el mismo principio de idempotencia de colas que en Laravel (3.14) y Flask (4.13).


Siguiente: 07-bun-elysia.md — el runtime más rápido de JavaScript.