NestJS no es automáticamente “el mejor backend para TypeScript”. Es una buena elección cuando el coste de coordinar un proyecto empieza a ser mayor que el coste de seguir sus convenciones.
Su valor principal no está en los decoradores. Está en ofrecer un vocabulario común —módulos, controladores, providers, pipes, guards e interceptors— para que distintas personas puedan encontrar y extender el código sin inventar una estructura nueva en cada feature.
La decisión en una tabla
| Contexto | NestJS suele encajar | Una opción más ligera puede encajar mejor |
|---|---|---|
| Equipo | Varias personas o crecimiento previsto | Una persona y alcance pequeño |
| Dominio | Reglas, permisos e integraciones | CRUD sencillo |
| Vida útil | Producto mantenido durante años | Prototipo o servicio temporal |
| Integraciones | HTTP, colas, tareas, WebSockets | Solo unos pocos endpoints |
| Prioridad | Consistencia y testabilidad | Arranque mínimo y control manual |
Elegir NestJS para tres endpoints no es necesariamente un error, pero debes reconocer el intercambio: aceptas más estructura inicial a cambio de límites más explícitos cuando el sistema crezca.
El flujo de una petición
NestJS separa piezas que suelen terminar mezcladas en un único handler:
flowchart LR
R["Petición HTTP"] --> G["Guard: autorización"]
G --> P["Pipe: validación"]
P --> C["Controller: protocolo"]
C --> U["Caso de uso"]
U --> D["Dominio"]
U --> Repo["Repositorio"]
Repo --> DB[("Base de datos")]
U --> C
C --> I["Interceptor / respuesta"]
Esta separación solo aporta valor si cada pieza conserva una responsabilidad:
- El controller traduce HTTP a una llamada de aplicación.
- El pipe valida y transforma la entrada.
- El guard decide si la operación está autorizada.
- El caso de uso coordina reglas y dependencias.
- El repositorio oculta detalles de persistencia.
Organiza por capacidad, no por tipo técnico global
Una estructura como controllers/, services/ y entities/ funciona al inicio, pero obliga a saltar por todo el repositorio para entender una funcionalidad. En proyectos medianos suele ser más claro agrupar por dominio:
src/
articles/
application/
publish-article.use-case.ts
domain/
article.ts
articles.repository.ts
infrastructure/
mongo-articles.repository.ts
http/
articles.controller.ts
dto/create-article.dto.ts
articles.module.ts
shared/
database/
auth/
app.module.ts
El módulo debe exponer una API pequeña. La documentación oficial de Nest explica que los providers quedan encapsulados por defecto y solo los incluidos en exports forman la interfaz pública del módulo.
import { Module } from '@nestjs/common';
@Module({
controllers: [ArticlesController],
providers: [
PublishArticle,
{
provide: ARTICLES_REPOSITORY,
useClass: MongoArticlesRepository,
},
],
exports: [PublishArticle],
})
export class ArticlesModule {}
Evita convertir todos los módulos en globales. Los imports explícitos documentan la dependencia entre capacidades y reducen el acoplamiento accidental.
Inyección de dependencias con contratos reales
Una interfaz de TypeScript desaparece en tiempo de ejecución, por lo que Nest necesita un token para resolverla. Puedes usar un Symbol:
export const ARTICLES_REPOSITORY = Symbol('ARTICLES_REPOSITORY');
export interface ArticlesRepository {
findBySlug(slug: string): Promise<Article | null>;
save(article: Article): Promise<void>;
}
El caso de uso depende del contrato, no de MongoDB:
import { Inject, Injectable } from '@nestjs/common';
@Injectable()
export class PublishArticle {
constructor(
@Inject(ARTICLES_REPOSITORY)
private readonly articles: ArticlesRepository,
) {}
async execute(input: PublishArticleInput): Promise<Article> {
const existing = await this.articles.findBySlug(input.slug);
if (existing) {
throw new ArticleSlugAlreadyExists(input.slug);
}
const article = Article.publish(input);
await this.articles.save(article);
return article;
}
}
Ahora la persistencia puede cambiar y el caso de uso puede probarse con un repositorio en memoria.
Valida la entrada en el borde
Los tipos de TypeScript no validan datos que llegan por red. En Nest puedes activar un ValidationPipe global y usar DTOs con reglas explícitas:
// main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
import { IsString, Length, Matches } from 'class-validator';
export class CreateArticleDto {
@IsString()
@Length(5, 120)
title!: string;
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug!: string;
}
whitelist elimina propiedades no declaradas y forbidNonWhitelisted convierte su presencia en error. Esto ayuda con la forma del mensaje, pero las reglas de negocio —por ejemplo, que el slug sea único— todavía pertenecen al caso de uso.
Mantén los controllers pequeños
Un controller no debería contener queries, reglas de negocio ni transformaciones complejas:
@Controller('articles')
export class ArticlesController {
constructor(private readonly publishArticle: PublishArticle) {}
@Post()
async create(@Body() dto: CreateArticleDto) {
const article = await this.publishArticle.execute(dto);
return {
id: article.id,
title: article.title,
slug: article.slug,
};
}
}
Si mañana la misma operación se invoca desde una cola, un comando o GraphQL, el caso de uso puede reutilizarse sin simular una petición HTTP.
Prueba la regla, luego la integración
Una prueba unitaria útil verifica comportamiento, no decoradores:
describe('PublishArticle', () => {
it('rechaza un slug existente', async () => {
const repository = new InMemoryArticlesRepository([
Article.publish({ title: 'Existente', slug: 'existente' }),
]);
const publish = new PublishArticle(repository);
await expect(
publish.execute({ title: 'Otro', slug: 'existente' }),
).rejects.toBeInstanceOf(ArticleSlugAlreadyExists);
});
});
Compleméntala con pocas pruebas de integración que verifiquen el wiring de Nest, la validación y la persistencia real:
flowchart TB
A["Muchas pruebas unitarias"] --> B["Casos de uso y dominio"]
C["Algunas pruebas de integración"] --> D["Módulos + base de datos"]
E["Pocas pruebas E2E"] --> F["Flujos HTTP críticos"]
Errores frecuentes en proyectos NestJS
Un módulo compartido que conoce todo
Un SharedModule con decenas de exports se convierte en una dependencia invisible entre todas las features. Comparte infraestructura verdaderamente transversal; mantén el dominio dentro de su módulo.
Services con cientos de líneas
ArticlesService termina creando, publicando, buscando, notificando y exportando artículos. Divide por casos de uso: PublishArticle, UpdateArticle, ListArticles.
Inyectar el ORM directamente en todas partes
Es rápido al principio, pero hace que consultas, transacciones y detalles de persistencia se filtren hasta las reglas. Usa un repositorio cuando exista lógica que quieras proteger o probar de forma aislada.
Confundir DTO con entidad de dominio
El DTO representa un mensaje externo y puede cambiar por razones de API. La entidad representa invariantes del negocio. Que ambas tengan campos parecidos no significa que deban ser la misma clase.
Añadir CQRS o microservicios por anticipado
Nest facilita ambos patrones, pero no elimina su coste. Empieza con un monolito modular. Extrae procesos cuando existan razones medibles de despliegue, escala o autonomía.
Checklist antes de elegirlo
- El equipo valora convenciones compartidas.
- Hay reglas más allá de guardar y leer registros.
- El proyecto tendrá varias capacidades o integraciones.
- Podemos mantener módulos con interfaces públicas pequeñas.
- Vamos a probar casos de uso sin depender del transporte.
- Aceptamos el coste inicial de aprender el framework.
Si la mayoría no aplica, Fastify, Express o Hono pueden ofrecer una solución más directa. Si sí aplica, NestJS puede convertirse en una buena inversión: no porque escriba la arquitectura por ti, sino porque te da mecanismos consistentes para hacerla explícita.