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.
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:
- Sistema de módulos — ¿carpetas por feature? ¿por tipo? ¿mono-router o multi-router?
- Inyección de dependencias — ¿singleton manual? ¿factory functions? ¿contenedor IoC?
- Middlewares de autenticación — ¿dónde van? ¿cómo comparto el usuario entre middlewares?
- Validación de DTOs — ¿Joi? ¿Yup? ¿Zod? ¿validación manual?
- Documentación de API — ¿Swagger manual? ¿comentarios JSDoc?
- Manejo de errores — ¿middleware global?¿try/catch en cada controller?
- 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
| Aspecto | Express | NestJS |
|---|---|---|
| Arranque | 2 min | 10 min |
| Estructura | Tú decides | Convenciones |
| 20 endpoints | Perfecto | Overkill |
| 75 endpoints | Caos sin disciplina | Manejable |
| TypeScript | Posible | Nativo |
| Testing | Tu setup | Jest integrado |
| Documentación API | Manual | Swagger auto |
| Curva de aprendizaje | Baja | Media |
| Mantenibilidad | Depende del dev | Buena 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