NestJS 11 Enterprise API Patterns

Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.

E
ECOSIRE Research and Development Team
|19 de marzo de 202613 min de lectura2.9k Palabras|

Patrones de API empresarial de NestJS 11

NestJS 11 se ha convertido en el marco de referencia para crear API de nivel de producción en TypeScript, combinando una arquitectura inspirada en Angular con la potencia bruta de Node.js. Cuando opera a escala empresarial (manejando millones de solicitudes, administrando datos de múltiples inquilinos y coordinando docenas de módulos), los patrones que elija el primer día determinan si su código base escala con gracia o colapsa por su propio peso.

Esta guía resume las lecciones aprendidas con mucho esfuerzo al crear un backend NestJS 11 de 56 módulos con más de 310 archivos TypeScript, y cubre todo, desde la organización de módulos y la composición de la protección hasta patrones de múltiples inquilinos que realmente se mantienen bajo carga.

Conclusiones clave

  • Utilice forRoutes('*path') no forRoutes('*') — NestJS 11 cambió la coincidencia de rutas comodín
  • Los filtros de excepción globales deben registrarse en main.ts, no a través de APP_FILTER
  • El arrendamiento múltiple requiere filtrado organizationId en cada capa de consulta, no solo en el middleware
  • El patrón decorador @Public() es más seguro que desactivar las guardias por completo para rutas abiertas
  • Nunca use sql.raw() en consultas de Drizzle: siempre literales de plantilla sql parametrizados
  • EmailModule debe ser @Global() para evitar volver a importar en cada módulo de funciones
  • Dotenv debe estar precargado en main.ts antes de que NestJS arranque para evitar problemas de resolución de entorno
  • La limitación de velocidad es obligatoria en todos los puntos finales públicos: use @nestjs/throttler

Estructura del proyecto a escala

La decisión arquitectónica más importante en NestJS es cómo organizar los módulos. A escala empresarial, una lista de módulos planos en app.module.ts se vuelve inmanejable. El patrón que funciona es la agrupación de módulos basada en dominios con declaraciones de dependencia explícitas.

apps/api/src/
  modules/
    auth/
      auth.module.ts
      auth.controller.ts
      auth.service.ts
      guards/
        jwt.guard.ts
        roles.guard.ts
      decorators/
        public.decorator.ts
        roles.decorator.ts
    contacts/
      contacts.module.ts
      contacts.controller.ts
      contacts.service.ts
      contacts.spec.ts
      dto/
        create-contact.dto.ts
        update-contact.dto.ts
    billing/
      billing.module.ts
      billing.service.ts
      webhook.controller.ts
  shared/
    filters/
      global-exception.filter.ts
    interceptors/
      transform.interceptor.ts
    pipes/
      validation.pipe.ts
  health/
    health.controller.ts
    indicators/
  main.ts
  app.module.ts

Cada módulo de dominio es autónomo. El directorio shared/ contiene preocupaciones transversales. Esta separación le permite agregar nuevos dominios sin tocar el código existente.


El patrón decorador @Public()

NestJS 11 aplica protecciones JWT a nivel mundial, pero es necesario abrir determinados puntos finales: comprobaciones de estado, devoluciones de llamadas de autenticación, receptores de webhooks. El patrón decorador @Public() es muy superior a deshabilitar guardias por ruta.

// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) return true;
    return super.canActivate(context);
  }
}
// app.module.ts
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: RolesGuard },
  ],
})
export class AppModule {}

Ahora cualquier método de controlador decorado con @Public() omite por completo la validación JWT. Los controladores de webhook, los puntos finales de estado y las rutas de autenticación utilizan este patrón.


Multiinquilino con OrganizationId

La regla fundamental de las API multiinquilino: cada consulta de base de datos debe filtrarse por organizationId. El middleware que establece req.organizationId no es suficiente: un desarrollador que se olvide de aplicar el filtro expone datos entre inquilinos.

El patrón consiste en extraer organizationId de la carga útil JWT y adjuntarlo a una interfaz de solicitud escrita:

// types/authenticated-request.interface.ts
import { Request } from 'express';

export interface AuthenticatedRequest extends Request {
  user: {
    sub: string;
    email: string;
    name: string;
    role: 'admin' | 'support' | 'user';
    organizationId: string;
  };
}
// contacts/contacts.controller.ts
import { Controller, Get, Post, Body, Req } from '@nestjs/common';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';

@Controller('contacts')
export class ContactsController {
  constructor(private contactsService: ContactsService) {}

  @Get()
  findAll(@Req() req: AuthenticatedRequest) {
    return this.contactsService.findAll(req.user.organizationId);
  }

  @Post()
  create(@Body() dto: CreateContactDto, @Req() req: AuthenticatedRequest) {
    return this.contactsService.create(dto, req.user.organizationId);
  }
}
// contacts/contacts.service.ts
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';

@Injectable()
export class ContactsService {
  async findAll(organizationId: string) {
    return db
      .select()
      .from(contacts)
      .where(eq(contacts.organizationId, organizationId))
      .limit(100);
  }
}

La capa de servicio aplica organizationId en el nivel de consulta. Ninguna falla del middleware o un decorador olvidado puede filtrar datos entre inquilinos porque el propio SQL impone el aislamiento.


Filtro de excepción global

Las respuestas de error consistentes en todos los puntos finales requieren un filtro de excepción global. La jerarquía HttpException de NestJS 11 maneja la mayoría de los casos, pero también es necesario detectar errores inesperados y nunca exponer los seguimientos de la pila en producción.

// filters/global-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(GlobalExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';
    let errors: Record<string, string[]> | undefined;

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();

      if (typeof exceptionResponse === 'string') {
        message = exceptionResponse;
      } else if (typeof exceptionResponse === 'object') {
        const resp = exceptionResponse as Record<string, unknown>;
        message = (resp.message as string) || message;
        if (Array.isArray(resp.message)) {
          // Validation errors from class-validator
          errors = this.formatValidationErrors(resp.message as string[]);
          message = 'Validation failed';
        }
      }
    } else if (exception instanceof Error) {
      this.logger.error(exception.message, exception.stack);
      // Never expose stack traces in production
      if (process.env.NODE_ENV !== 'production') {
        message = exception.message;
      }
    }

    response.status(status).json({
      statusCode: status,
      message,
      errors,
      path: request.url,
      timestamp: new Date().toISOString(),
    });
  }

  private formatValidationErrors(messages: string[]): Record<string, string[]> {
    const errors: Record<string, string[]> = {};
    for (const msg of messages) {
      const [field, ...rest] = msg.split(' ');
      if (!errors[field]) errors[field] = [];
      errors[field].push(rest.join(' '));
    }
    return errors;
  }
}

Regístrelo en main.ts:

// main.ts
import { NestFactory } from '@nestjs/core';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';

async function bootstrap() {
  // CRITICAL: Load env vars before NestJS bootstraps
  require('dotenv').config({ path: join(__dirname, '..', '..', '..', '.env.local') });

  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new GlobalExceptionFilter());
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(3001);
}

El cambio del comodín de ruta de NestJS 11

Uno de los cambios más importantes en NestJS 11 es la coincidencia de rutas comodín. Si estás migrando desde NestJS 10, esto dañará silenciosamente tu middleware:

// NestJS 10 — works
consumer.apply(LoggerMiddleware).forRoutes('*');

// NestJS 11 — use '*path' instead
consumer.apply(LoggerMiddleware).forRoutes('*path');

Lo mismo se aplica a la configuración de Swagger y a cualquier patrón de ruta basado en cadenas. Este cambio afecta el registro de middleware, las exclusiones de rutas y cualquier lugar donde utilice la coincidencia de rutas de estilo global.


Control de acceso basado en roles

Más allá de la autenticación JWT, las API empresariales necesitan autorización basada en roles. El patrón decorador más protección mantiene limpios los controladores:

// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export type Role = 'admin' | 'support' | 'user';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, Role } from '../decorators/roles.decorator';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) return true;

    const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
    const hasRole = requiredRoles.some((role) => user.role === role);

    if (!hasRole) {
      throw new ForbiddenException('Insufficient permissions');
    }

    return true;
  }
}

Uso en controladores:

// For top-level module controllers — one `..`
import { Roles } from '../auth/guards/roles.guard';

// For nested sub-module controllers — two `../..`
import { Roles } from '../../auth/guards/roles.guard';

@Controller('admin/contacts')
@Roles('admin', 'support')
export class AdminContactsController {}

La profundidad de la ruta de importación es una fuente común de errores. Los controladores de módulo de nivel superior utilizan un ..; Los controladores de submódulos anidados utilizan dos ../...


Patrón de módulo de correo electrónico global

El decorador @Global() resuelve el problema de importación repetitiva de servicios utilizados en todas partes. El correo electrónico es el caso de uso canónico: no desea importar EmailModule en cada módulo de funciones que envía notificaciones.

// email/email.module.ts
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';

@Global()
@Module({
  providers: [EmailService],
  exports: [EmailService],
})
export class EmailModule {}

Regístrelo una vez en AppModule:

@Module({
  imports: [
    EmailModule, // Global — available everywhere
    ContactsModule,
    BillingModule,
    // ...
  ],
})
export class AppModule {}

Ahora cualquier servicio puede inyectar EmailService sin tocar las importaciones de módulos. El mismo patrón funciona para Redis, EventBus y cualquier servicio de infraestructura transversal.


Limitación de velocidad de puntos finales públicos

Las API empresariales se enfrentan a intentos de abuso a diario. @nestjs/throttler de NestJS se integra limpiamente con el sistema de protección:

// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      { name: 'short', ttl: 1000, limit: 10 },
      { name: 'medium', ttl: 60000, limit: 100 },
    ]),
  ],
  providers: [
    { provide: APP_GUARD, useClass: ThrottlerGuard },
  ],
})

Anule por punto final con @Throttle() para límites más estrictos en rutas sensibles:

import { Throttle } from '@nestjs/throttler';

@Controller('auth')
export class AuthController {
  @Post('exchange')
  @Public()
  @Throttle({ short: { ttl: 60000, limit: 5 } }) // 5 per minute
  async exchangeCode(@Body() dto: ExchangeCodeDto) {
    return this.authService.exchangeCode(dto.code);
  }
}

Comprobaciones de estado con @nestjs/terminus

Las API de producción necesitan puntos finales de estado para las comprobaciones y el monitoreo del balanceador de carga. @nestjs/terminus proporciona un sistema declarativo con indicadores integrados:

// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
import { Public } from '../auth/decorators/public.decorator';
import { DatabaseHealthIndicator } from './indicators/database.indicator';
import { RedisHealthIndicator } from './indicators/redis.indicator';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: DatabaseHealthIndicator,
    private redis: RedisHealthIndicator,
  ) {}

  @Get()
  @Public()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.isHealthy('database'),
      () => this.redis.isHealthy('redis'),
    ]);
  }
}

Indicador personalizado para Drizzle ORM (ya que el terminal no tiene uno incorporado):

// health/indicators/database.indicator.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { db } from '@ecosire/db';
import { sql } from 'drizzle-orm';

@Injectable()
export class DatabaseHealthIndicator extends HealthIndicator {
  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      await db.execute(sql`SELECT 1`);
      return this.getStatus(key, true);
    } catch (error) {
      throw new HealthCheckError('Database check failed', this.getStatus(key, false));
    }
  }
}

Patrones de validación de DTO

El ValidationPipe con whitelist: true elimina propiedades desconocidas antes de que lleguen a su capa de servicio. Combinados con class-transformer, los DTO se convierten en su primera línea de defensa:

// contacts/dto/create-contact.dto.ts
import { IsString, IsEmail, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';

export enum ContactType {
  INDIVIDUAL = 'individual',
  COMPANY = 'company',
}

export class CreateContactDto {
  @ApiProperty({ example: 'Acme Corp' })
  @IsString()
  @MinLength(2)
  @MaxLength(255)
  @Transform(({ value }) => value?.trim())
  name: string;

  @ApiProperty({ example: '[email protected]' })
  @IsEmail()
  @Transform(({ value }) => value?.toLowerCase().trim())
  email: string;

  @ApiPropertyOptional({ enum: ContactType })
  @IsOptional()
  @IsEnum(ContactType)
  type?: ContactType;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  @MaxLength(500)
  notes?: string;
}

Los decoradores @Transform normalizan los datos en la capa de validación. Recortar espacios en blanco y correos electrónicos en minúsculas evita registros duplicados por diferencias entre mayúsculas y minúsculas.


Integración de arrogancia

Las API empresariales necesitan documentación. El módulo Swagger de NestJS genera especificaciones OpenAPI de los decoradores, pero la configuración debe ser deliberada:

// main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('ECOSIRE API')
    .setDescription('Enterprise API for ECOSIRE platform')
    .setVersion('1.0')
    .addBearerAuth()
    .addTag('auth', 'Authentication endpoints')
    .addTag('contacts', 'Contact management')
    .build();

  const document = SwaggerModule.createDocument(app, config);

  // Only expose in non-production environments
  if (process.env.NODE_ENV !== 'production') {
    SwaggerModule.setup('api/docs', app, document);
  }

  await app.listen(3001);
}

Cada método de controlador necesita @ApiOperation y @ApiResponse:

@Get(':id')
@ApiOperation({ summary: 'Get contact by ID' })
@ApiResponse({ status: 200, description: 'Contact found', type: ContactResponseDto })
@ApiResponse({ status: 404, description: 'Contact not found' })
async findOne(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
  return this.contactsService.findOne(id, req.user.organizationId);
}

Errores y soluciones comunes

Error 1: Dependencias circulares entre módulos

Las importaciones circulares bloquean NestJS al inicio con un error críptico. La solución es forwardRef():

@Module({
  imports: [forwardRef(() => BillingModule)],
})
export class LicenseModule {}

Mejor solución: extraer la lógica compartida a un tercer módulo que ambos importen sin dependencia circular.

Error 2: las variables de entorno no están disponibles durante la inicialización del módulo

Si accede a process.env.DATABASE_URL en un constructor de clases o fábrica de proveedores antes de que se cargue dotenv, obtendrá undefined. Solución: cargue dotenv en la parte superior de main.ts, antes de cualquier importación de NestJS.

// main.ts — must be FIRST
import * as path from 'path';
require('dotenv').config({ path: path.join(__dirname, '..', '..', '..', '.env.local') });

// Then NestJS imports
import { NestFactory } from '@nestjs/core';

Error 3: falta de espera en operaciones asíncronas en enlaces de ciclo de vida

// Wrong — database connection might not be ready
@Injectable()
export class AppService implements OnModuleInit {
  onModuleInit() {
    this.seedDatabase(); // Not awaited!
  }
}

// Correct
async onModuleInit() {
  await this.seedDatabase();
}

Error 4: usar sql.raw() en consultas de Drizzle

sql.raw() omite la parametrización y abre vectores de inyección SQL. Utilice siempre el literal de plantilla sql:

// Dangerous — never do this
const result = await db.execute(sql.raw(`SELECT * FROM contacts WHERE id = '${id}'`));

// Safe — parameterized
const result = await db.execute(sql`SELECT * FROM contacts WHERE id = ${id}`);

Preguntas frecuentes

¿Cómo manejo el control de versiones en las API de NestJS 11?

NestJS 11 admite versiones de URI, versiones de encabezados y versiones de tipos de medios listas para usar. Habilítelo en main.ts con app.enableVersioning({ type: VersioningType.URI }), luego decore los controladores con @Version('1'). Para las API empresariales, el control de versiones de URI (/v1/contacts) es el enfoque más explícito y compatible con el caché.

¿Cuál es la mejor manera de manejar la carga de archivos en NestJS?

Utilice @nestjs/platform-express con multer para cargar archivos. Para cargas de S3, configure un motor de almacenamiento personalizado a través de multer-s3. Valide siempre los tipos y tamaños de archivos a nivel de canalización antes de que se ejecute el controlador y nunca confíe en los tipos MIME proporcionados por el cliente; en su lugar, valide los bytes mágicos.

¿Cómo debo estructurar las transacciones de la base de datos en NestJS?

Drizzle ORM admite transacciones con db.transaction(async (tx) => { ... }). Pase el objeto de transacción a métodos de servicio en lugar de a la instancia global db. Para la lógica empresarial de operaciones múltiples (crear pedido + deducir inventario + enviar correo electrónico), envuelva todo en una transacción y haga que el envío de correo electrónico no sea bloqueante con .catch() después de la confirmación.

¿Cuándo debo usar guardias, middleware o interceptores?

Los guardias manejan la autorización (¿puede este usuario acceder a este recurso?). El middleware maneja la transformación de solicitudes transversales (registro, ID de correlación, análisis). Los interceptores envuelven el ciclo de solicitud-respuesta para transformación, almacenamiento en caché y métricas. El orden de ejecución es: Middleware → Guardias → Interceptores (antes) → Tuberías → Controlador → Interceptores (después) → Filtros de excepción.

¿Cómo pruebo los módulos NestJS de forma aislada?

Utilice Test.createTestingModule() para crear un entorno limitado de prueba con dependencias simuladas. Simule sus métodos de servicio con jest.fn() o vi.fn() y pruebe el comportamiento del controlador independientemente de su base de datos. Para las pruebas de integración, utilice @nestjs/testing con una conexión de base de datos real (base de datos de prueba separada) y reversión de transacciones después de cada prueba.

¿Cuál es el impacto de los guardias globales en el rendimiento?

Los guardias globales actúan ante cada solicitud, así que manténgalos rápidos. La verificación JWT suele tardar entre 1 y 5 ms. Evite búsquedas en bases de datos en guardias: cargue permisos durante la creación de tokens e inclúyalos en la carga útil de JWT. Si necesita permisos nuevos en cada solicitud, use Redis con un TTL corto en lugar de acceder a la base de datos.


Próximos pasos

La creación de API empresariales a escala requiere la arquitectura adecuada desde el primer día. El equipo de ingeniería de ECOSIRE ha creado y operado un backend NestJS 11 de 56 módulos que maneja flujos de trabajo complejos de múltiples inquilinos, facturación de Stripe, administración de licencias y análisis impulsados ​​por IA.

Ya sea que necesite una API NestJS personalizada, una integración con Odoo ERP o una plataforma empresarial completa, nuestro equipo aporta patrones probados en producción a su proyecto. Explore nuestros servicios de desarrollo para ver cómo podemos acelerar su próxima construcción.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp