Saltar al contenido principal

Por Qué Elegí NestJS sobre Express para un SaaS Real

Decisiones de arquitectura reales: por qué NestJS gana a Express en un SaaS multitenant, cómo estructuré la API y errores que evité con la arquitectura modular.

Fran Cobos 6 min de lectura 1128 palabras

Tabla de contenidos

TL;DR

Elegí NestJS sobre Express para construir un SaaS con 18 entidades, 75+ endpoints y 3 roles con 8 permisos. Express me habría dado libertad total, pero en un proyecto de esta escala esa libertad es una trampa. Aquí explico por qué, con código real del proyecto.


Contexto: qué iba a construir

El proyecto era Atrapaclientes, un SaaS multitenant con:

  • 18 entidades en PostgreSQL (usuarios, tenants, campañas, códigos, participaciones, terminales, formularios…)
  • 75+ endpoints REST documentados con Swagger
  • 3 roles (SUPERADMIN, ADMIN, GERENTE) con 8 permisos granulares
  • WebSockets para comunicación en tiempo real con tablets
  • Multi-tenancy con aislamiento a nivel de fila
  • App móvil consumiendo la misma API

Cuando empecé, tenía que hacer la elección: Express con estructura propia o NestJS con convenciones.


Lo que Express me habría obligado a inventar

Con Express, el típico proyecto arranca así:

const express = require('express');
const app = express();

app.get('/api/campaigns', getCampaigns);
app.post('/api/campaigns', createCampaign);
// ... 73 rutas más

Para un proyecto con 75+ endpoints, necesitaría inventar:

  1. Sistema de módulos — ¿carpetas por feature? ¿por tipo? ¿mono-router o multi-router?
  2. Inyección de dependencias — ¿singleton manual? ¿factory functions? ¿contenedor IoC?
  3. Middlewares de autenticación — ¿dónde van? ¿cómo comparto el usuario entre middlewares?
  4. Validación de DTOs — ¿Joi? ¿Yup? ¿Zod? ¿validación manual?
  5. Documentación de API — ¿Swagger manual? ¿comentarios JSDoc?
  6. Manejo de errores — ¿middleware global?¿try/catch en cada controller?
  7. Guards de permisos — ¿middleware por ruta? ¿decorador custom?

Cada una de estas decisiones es tiempo perdido reinventando lo que ya existe.


Lo que NestJS me dio “gratis”

1. Módulos que aíslan funcionalidad

@Module({
  imports: [TypeOrmModule.forFeature([Campaign, Code])],
  controllers: [CampaignController],
  providers: [CampaignService],
  exports: [CampaignService],
})
export class CampaignModule {}

Cada módulo tiene sus controllers, services y entidades. Si algo falla en campañas, no toco el módulo de usuarios. En Atrapaclientes tengo ~10 módulos independientes.

2. Decoradores para autenticación y permisos

@Controller('campaigns')
@UseGuards(JwtAuthGuard, TenantContextGuard)
export class CampaignController {
  
  @Post()
  @RequirePermission(Permission.MANAGE_CAMPAIGNS)
  create(@Body() dto: CreateCampaignDto, @Req() req) {
    return this.service.create(dto, req.tenantId);
  }
}

Una línea para proteger el endpoint. Una línea para requerir un permiso. No hay middleware chains confusos ni if (req.user.role !== 'admin') repetido en 30 sitios.

3. Validación automática de DTOs

export class CreateCampaignDto {
  @IsString()
  @MinLength(3)
  name: string;

  @IsEnum(CampaignType)
  type: CampaignType;

  @IsDateString()
  startDate: string;

  @IsOptional()
  @IsObject()
  formSchema?: Record<string, any>;
}

NestJS valida automáticamente el body contra el DTO. Si falta name o type no es un enum válido, devuelve un 400 con el error exacto. Sin código adicional.

4. Swagger auto-generado

@ApiTags('campaigns')
@ApiOperation({ summary: 'Crear campaña' })
@ApiResponse({ status: 201, type: CampaignResponseDto })
@Post()
create(@Body() dto: CreateCampaignDto) { ... }

Mis 75+ endpoints están documentados con Swagger. Los decoradores generan la documentación automáticamente. Un frontend developer o un partner que consuma la API tiene documentación actualizada siempre.

5. Inyección de dependencias real

@Injectable()
export class CampaignService {
  constructor(
    @InjectRepository(Campaign)
    private readonly repo: Repository<Campaign>,
    private readonly codeService: CodeService,
    private readonly notificationService: NotificationService,
  ) {}
}

NestJS resuelve las dependencias automáticamente. Si CampaignService necesita CodeService, NestJS lo instancia y lo inyecta. No hay new CodeService() manual ni singletons caseros.


Estructura real del proyecto

src/
├── auth/               → JWT, RBAC, guards, strategies
├── campaigns/          → CRUD de campañas, wizard logic
├── codes/              → Generación y validación de códigos
├── terminals/          → WebSocket gateway, comandos remotos
├── participants/       → Participaciones y formularios
├── tenants/            → Multi-tenancy, aislamiento
├── users/              → Gestión de usuarios y roles
├── notifications/      → Email con Nodemailer
├── uploads/            → Imágenes con volúmenes persistentes
├── common/             → Decoradores, guards, pipes, filters
└── app.module.ts       → Root module

Cada carpeta es autónoma. Si mañana necesito quitar el módulo de notificaciones, borro la carpeta y quito el import del app.module. Sin efectos secundarios.

Con Express, esta estructura sería posible pero no obligatoria. Y “no obligatoria” significa que en la tercera iteración alguien mete una ruta en el archivo equivocado y empieza el caos.


Cuándo NO elegiría NestJS

NestJS no es la respuesta a todo. Lo evitaría en:

  • APIs pequeñas (5-15 endpoints) — el boilerplate de NestJS no se justifica
  • Serverless puro — los cold starts de NestJS son más largos que Express vanilla
  • Scripts/workers simples — si solo necesito un cron job, Express o incluso un script puro es mejor
  • Prototipo rápido — si necesito validar una idea en 2 días, Express + Zod es más ágil

Mi regla: si el proyecto va a tener más de 20 endpoints o más de 2 desarrolladores, NestJS.


Express vs NestJS: comparación honesta

AspectoExpressNestJS
Arranque2 min10 min
EstructuraTú decidesConvenciones
20 endpointsPerfectoOverkill
75 endpointsCaos sin disciplinaManejable
TypeScriptPosibleNativo
TestingTu setupJest integrado
Documentación APIManualSwagger auto
Curva de aprendizajeBajaMedia
MantenibilidadDepende del devBuena por defecto

Decisiones de arquitectura que funcionaron

TypeORM con migraciones manuales

pnpm migration:generate -- -n AddFormSchemaToParticipation
pnpm migration:run

Nunca synchronize: true en producción. Las 27 migraciones están versionadas y son reversibles. Si algo falla, puedo hacer rollback a cualquier punto.

Monorepo con Turborepo

apps/
├── api/          → NestJS
├── web/          → React
└── mobile/       → React Native
packages/
└── shared-types/ → Interfaces compartidas

Los tipos se comparten entre las tres apps. Si cambio un DTO en la API, TypeScript me avisa en el frontend y en la app móvil. Cero “el campo se llama campaignId en la API pero campaign_id en el front”.

Autenticación JWT dual

  • Access token: 15 minutos de vida
  • Refresh token: 7 días con sliding session

Si alguien roba un access token, tiene 15 minutos. Si roba el refresh, el sliding session lo invalida al detectar uso sospechoso.


Conclusión

Express es una herramienta excelente. Pero para un SaaS con 18 entidades, 75 endpoints, RBAC, multi-tenancy y WebSockets, necesitaba estructura obligatoria, no sugerida. NestJS me la dio, y el resultado fue un proyecto mantenible que construí solo en 2 meses.

La verdadera pregunta no es “¿NestJS o Express?”. Es: ¿tu proyecto va a crecer lo suficiente como para que la estructura importe? Si la respuesta es sí, NestJS.


Recursos relacionados

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.