Durante casi tres años, un solo backend de NestJS nos sirvió bien. Un monolito, un despliegue, un modelo mental. Hasta que dejó de servir. No fue una caída dramática ni un incidente de producción: fue la fricción diaria de tocar el código de un portal y tener que pensar en los otros tres. En JXBS construimos un SaaS de reclutamiento AI-first, con varios portales para audiencias distintas —candidatos, empresas, administración interna— y todos vivían dentro del mismo backend. Esta es la historia de por qué lo partimos en backends por audiencia, y las cosas que nadie te advierte sobre pnpm y la inyección de dependencias de NestJS cuando lo haces.
Por qué un solo backend dejó de escalar con nosotros
El problema nunca fue el rendimiento. Fue el acoplamiento y el radio de impacto de cada despliegue.
Cuando todo vive en un módulo raíz de NestJS, las fronteras se difuminan solas. Un servicio de candidatos empieza a importar algo del módulo de empresas "solo por ahora". Un guard pensado para administración se cuela en una ruta pública. Y como es un único proceso, todo comparte la misma configuración, el mismo pool de conexiones a PostgreSQL, el mismo ciclo de arranque.
Tres síntomas concretos nos empujaron a movernos:
- Radio de impacto en cada deploy. Un cambio en el portal de administración obligaba a redesplegar el backend entero. Si algo salía mal, se caía todo, incluido el portal público de candidatos que no había cambiado en semanas.
- Audiencias mezcladas. Distintos portales tienen distintos requisitos de autenticación, distintos límites de rate, distinta superficie de exposición. Meterlos en el mismo proceso significaba que la ruta más sensible definía el nivel de paranoia de todas.
- Cognición compartida. Cada persona del equipo tenía que cargar todo el dominio en la cabeza para tocar una parte pequeña.
Qué significa "backends por audiencia"
La idea es simple: un backend por portal. Uno para candidatos, uno para empresas, uno para administración. Cada uno es un servicio NestJS 11 independiente, con su propio despliegue, su propia configuración y su propia superficie de API. El frontend sigue siendo Next.js 15 con React 19, y cada portal habla con su backend.
Lo que no cambia es la base de datos ni el dominio. Seguimos con PostgreSQL + pgvector y Prisma. El truco está en que el código compartido —entidades, lógica de dominio, utilidades, el cliente de Prisma— vive en paquetes del monorepo (Turborepo + pnpm), no duplicado en cada servicio.
apps/
candidates-api/ # NestJS 11 — puerto candidatos
companies-api/ # NestJS 11 — puerto empresas
admin-api/ # NestJS 11 — puerto administración
web-candidates/ # Next.js 15
web-companies/ # Next.js 15
packages/
domain/ # lógica de dominio pura, sin NestJS
database/ # Prisma client + repos
auth/ # módulo de auth reutilizable
config/ # carga y validación de env
La regla que nos salvó: los paquetes de packages/ exponen clases y funciones; los apps/ deciden cómo cablearlas. El dominio no sabe en qué backend corre.
Las trampas de pnpm + inyección de dependencias que nadie menciona
Aquí es donde perdí horas. NestJS asume que un provider es un singleton dentro de su contenedor. pnpm, con su estructura de node_modules estricta y sus symlinks, puede romper esa suposición sin decir nada.
El primer golpe: instancias de provider duplicadas. Si un paquete compartido declara un provider y dos módulos distintos lo importan por caminos ligeramente diferentes, terminas con dos instancias. Un "singleton" que no lo es. Lo notas cuando un caché en memoria o un pool de conexiones se comporta de forma inconsistente.
La causa casi siempre es la misma: el token del provider no es idéntico entre importadores, o la dependencia compartida quedó instalada en dos sitios del árbol.
El patrón que lo evita: un módulo dinámico global con un token explícito, declarado una sola vez.
// packages/database/src/database.module.ts
import { Global, Module, DynamicModule } from '@nestjs/common';
import { PrismaService } from './prisma.service';
export const PRISMA = Symbol('PRISMA'); // token estable y único
@Global()
@Module({})
export class DatabaseModule {
static forRoot(): DynamicModule {
return {
module: DatabaseModule,
providers: [{ provide: PRISMA, useClass: PrismaService }],
exports: [PRISMA],
};
}
}
Cada app llama a DatabaseModule.forRoot() una vez en su módulo raíz. El token es un Symbol exportado, así que no hay ambigüedad de string ni riesgo de colisión.
Las otras dos que me mordieron:
- Peer deps, no deps directas. Los paquetes compartidos declaran
@nestjs/commony@nestjs/corecomopeerDependencies, nunca como dependencias normales. Si un paquete trae su propia copia de NestJS, sus decoradores y metadatos viven en otro realm y la DI simplemente no los reconoce. Enpnpm-workspace.yaml, apoyarse en el catálogo para fijar una sola versión ayuda mucho. - Fronteras de módulo, no de carpeta. Un paquete puede exportar código; que sea un
@Modulede NestJS es una decisión aparte. Mantuvimospackages/domainlibre de NestJS —clases y funciones puras— y solopackages/authypackages/databaseexponen módulos. Así el dominio se reusa sin arrastrar el contenedor de DI a todas partes.
💡 En un monorepo, un "singleton" solo lo es si el token es idéntico y la dependencia está instalada una sola vez. pnpm no te avisa cuando no se cumple: te lo cuenta en producción.
Cómo compartir dominio sin volver a acoplarlo
El riesgo obvio de partir el monolito es reconstruir el acoplamiento dentro de los paquetes. Un paquete shared que lo sabe todo es el monolito con otro nombre.
Lo que funcionó:
- El dominio no importa infraestructura.
packages/domainno conoce Prisma ni NestJS. Recibe interfaces; las apps inyectan implementaciones. - Un paquete, una responsabilidad.
auth,database,configpor separado. Si dudas de a qué paquete pertenece algo, probablemente pertenece a la app. - Sin dependencias entre backends.
candidates-apinunca importa decompanies-api. Si necesitan hablar, es por API o por un contrato compartido enpackages/, jamás por import directo.
Desplegar varios servicios en ECS Fargate
Cada backend es un servicio propio en ECS Fargate: su task definition, su imagen, su auto-scaling. La CI/CD la gestionamos con OpenTofu, así que añadir un backend es declarar un módulo nuevo, no clicar en una consola.
Turborepo nos da el otro pilar: builds afectados. Solo se construye y despliega lo que cambió. Tocar admin-api no reconstruye candidates-api. Ese fue el objetivo original —reducir el radio de impacto— y aquí se vuelve real.
El detalle que cuida el bolsillo: cada servicio dimensiona su CPU y memoria según su carga. El portal de candidatos y el de administración no tienen por qué pagar el mismo tamaño de tarea.
Hacerlo sin un big-bang
No reescribimos nada de golpe. Extrajimos primero un backend —el de menor riesgo— dejando el monolito sirviendo al resto. Portal por portal, movimos rutas al nuevo servicio y apuntamos el frontend correspondiente. El monolito fue encogiendo hasta desaparecer.
Lo que le diría a mi yo del pasado
Dibuja las fronteras de audiencia desde el día uno, aunque todo viva en un solo proceso al principio. Separar módulos con límites claros es barato; desenredar un monolito acoplado es caro. Y trata la inyección de dependencias en un monorepo pnpm como lo que es: un tema de identidad de instancias, no de imports. Fija las versiones, usa tokens explícitos, mantén el dominio puro. El resto se deja llevar.