Mover un backend de NestJS a un monorepo con pnpm y Turborepo
Comparta contratos de TypeScript entre APIs mediante espacios de trabajo de pnpm, gráficos de tareas de Turborepo, Dockerfiles amigables con el caché y catálogos de pnpm, sin convertir los archivos comunes en un cajón de trastos.
Fuentes: Why I Chose a Monorepo for My Backend (And What Actually Happened)
Aproximadamente nueve meses después de desarrollar un backend con NestJS en constante crecimiento, una sola API, un único Dockerfile y un solo pipeline ya no eran suficientes. Llegó una segunda API, luego una tercera, además de una interfaz de administración en React. La pregunta pendiente era cómo dividir las responsabilidades sin perder los contratos compartidos en TypeScript.
La solución adoptada fue un monorepo con espacio de trabajo pnpm, orquestado mediante Turborepo: qué funcionó, qué causó problemas y qué cambiaría en un segundo intento; solo decisiones técnicas, sin consideraciones relacionadas con la marca del producto.
El problema que lo inició
El primer servicio comenzó como una sola aplicación NestJS que gestionaba la autenticación, la gestión de contenidos, las subidas a Cloudflare R2 y búsquedas basadas en Meilisearch. Esa estructura se mantuvo hasta que la búsqueda necesitó su propio proceso de despliegue y límites de fallo, mientras seguía compartiendo enums y funciones auxiliares con la API principal.
Aparecieron dos opciones: copiar los bits compartidos a otro repositorio y mantenerlos sincronizados manualmente, o compartir el código en el mismo lugar mientras cada aplicación se compila y distribuye por separado.
Los enums duplicados entre repositorios parecen inofensivos hasta que un lado se actualiza y el otro no. Un error casi imperceptible con un enum de tipo de contenido fue suficiente para rechazar el compartir entre múltiples repositorios a esta escala.
Por qué pnpm, por qué Turborepo
Se compararon tres soluciones: Nx, los espacios de trabajo de Yarn con Lerna, y los espacios de trabajo de pnpm con Turborepo.
Nx era capaz, pero resultaba como un segundo framework: tenía una curva de aprendizaje pronunciada y un esqueleto generado más pesado del necesario para cuatro aplicaciones y unos pocos paquetes compartidos. Los visualizadores de grafos y las herramientas para comandos afectados eran más de lo que el problema requería.
Los espacios de trabajo de Yarn junto con Lerna resultaban familiares en Yarn 1.x, pero la situación de mantenimiento de Lerna era preocupante y la falta de control estricto en Yarn ya había permitido la existencia de dependencias fantasma: importaciones que funcionaban solo porque por casualidad había un paquete transitivo presente localmente y luego fallaban en producción.
pnpm se impuso gracias a su aislamiento estricto: cada importación debe declararse en package.json. Durante la migración, marcó inmediatamente @nestjs/mapped-types como utilizado pero no declarado, algo que Yarn permitía a través de su estructura de dependencias. Sumado al enlace entre espacios de trabajo sin necesidad de conexiones manuales complejas, esa rigurosidad fue decisiva.
Turborepo orquesta las compilaciones por encima de todo esto. Al declarar "dependsOn": ["^build"] se hacen compilar los paquetes compartidos antes que aquellos que los utilizan, se almacenan en caché los resultados y se paralelizan las tareas independientes. La mayor parte de las políticas se encuentran en un único turbo.json:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
El token ^build significa “construir primero las dependencias”. Turborepo lee el grafo de package.json para el resto.
Esa configuración sencilla es intencionada. El objetivo de la migración era obtener una respuesta más rápida para un grafo de servicios pequeño, no contar con un conjunto completo de herramientas para tareas afectadas para todo el equipo de la plataforma. Mantener la orquestación declarativa hizo que la integración fuera más económica cuando llegó la tercera API: las nuevas aplicaciones copiaban los nombres de tareas de turbo.json y utilizaban las mismas claves de caché.
Lo que realmente comparto
Al principio existían paquetes separados para tipos y utilidades. En cuestión de meses se fusionaron en un único paquete @myapp/common. La separación parecía ordenada en teoría, pero resultaba molesta en la práctica: cada enumeración necesitaba un lugar específico, y la mayoría de los archivos importaban ambos paquetes de todos modos.
El árbol compartido ahora se ve así:
packages/common/src/
├── enums/
│ ├── subdirectory-path.enum.ts # R2 bucket paths
│ ├── content.enum.ts # Content field types
│ └── index.ts
├── types/
│ ├── pagination.types.ts
│ └── index.ts
└── index.ts
Manténgalo pequeño a propósito. Comparta únicamente tipos entre servicios, enums referenciados por múltiples aplicaciones y un único helper de UUID. Todo lo demás debe permanecer local a la aplicación. El paquete es un contrato, no un cajón de trastos.
Más tarde se incluyeron tres paquetes compartidos adicionales:
Un paquete config carga los secretos. Ambas APIs obtienen la información del gestor de secretos al iniciar, por lo que loadSecrets() se ejecuta una sola vez desde cada main.ts antes de que se realicen otras tareas de inicio.
Un paquete telemetry encapsula el seguimiento de OpenTelemetry y el registro estructurado en JSON, incluyendo la filtración de los spans de verificación de estado que generan ruido. La observabilidad transversal es donde el código compartido elimina las diferencias reales entre servicios.
Un paquete vitest-config exporta una fábrica compartida de Vitest para que la configuración de las pruebas sea consistente sin necesidad de copiar configuraciones.
El problema de Dockerfile
Las compilaciones de Monorepo con Docker son un aspecto que los tutoriales subestiman. El contexto de compilación es la raíz del repositorio. Cada aplicación tiene su propio Dockerfile, pero debe acceder a paquetes compartidos. El orden adecuado de las órdenes COPY que favorecen el caché es la herramienta principal.
El patrón que se ha consolidado:
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.12.4 --activate
WORKDIR /app
# 1. Copy workspace manifests first (cache the install layer)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc turbo.json ./
COPY packages/common/package.json ./packages/common/
COPY packages/config/package.json ./packages/config/
COPY packages/telemetry/package.json ./packages/telemetry/
COPY apps/core/package.json ./apps/core/
RUN pnpm install --frozen-lockfile
# 2. Copy source and build in dependency order
COPY packages/common ./packages/common
COPY packages/config ./packages/config
COPY packages/telemetry ./packages/telemetry
COPY apps/core ./apps/core
RUN pnpm --filter @myapp/common build
RUN pnpm --filter @myapp/config build
RUN pnpm --filter @myapp/telemetry build
RUN pnpm --filter @myapp/core build:prod
# 3. Deploy step creates a self-contained directory
RUN pnpm deploy --filter @myapp/core --prod --legacy /deploy/core
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /deploy/core ./
CMD ["node", "dist/main"]
Primero se copian todos los archivos de package.json, luego se instalan las dependencias y, finalmente, se copian los archivos fuente. Docker reutiliza la capa de instalación hasta que cambien las dependencias, y no con cada modificación en los archivos fuente.
La desventaja: agregar un paquete compartido implica actualizar las líneas COPY en cada Dockerfile que lo utilice. Una lista de verificación es útil, ya que la omisión de alguna línea provoca fallos de compilación difíciles de diagnosticar. Al final, pnpm deploy genera un árbol de producción independiente en el que los paquetes del espacio de trabajo se convierten en copias reales, por lo que la imagen final no necesita ni pnpm ni toda la estructura del monorepo.
Gestión de versiones con catálogos
La deriva de versiones es el impuesto silencioso de los monorepo: NestJS 11.0 en una aplicación y 11.1 en otra genera errores sutiles relacionados con un servicio específico.
Los catálogos de pnpm definen los rangos compartidos una sola vez en pnpm-workspace.yaml:
catalogs:
nestjs:
'@nestjs/common': ^11.1.28
'@nestjs/core': ^11.1.28
'@nestjs/platform-fastify': ^11.1.28
testing:
vitest: ^3.2.6
'@vitest/coverage-v8': ^2.1.0
Las aplicaciones hacen referencia luego a la entrada del catálogo:
{
"dependencies": {
"@nestjs/common": "catalog:nestjs"
},
"devDependencies": {
"vitest": "catalog:testing"
}
}
Ajustar la versión de NestJS en todas partes se reduce a una edición del espacio de trabajo más un pnpm install. El catálogo solo incluye dependencias compartidas por dos o más aplicaciones; la versión de Mongoose utilizada por un servicio único puede permanecer local hasta que cruce un límite.
Los catálogos también modifican la forma en que se revisan las actualizaciones. Un aumento de versión en NestJS se convierte en una diferencia a nivel de espacio de trabajo en lugar de un conjunto disperso de archivos de paquetes, lo que permite que la revisión del código se enfoque en las notas de lanzamiento y los cambios disruptivos en lugar de verificar si todas las aplicaciones se actualizaron de manera consistente.
Qué hice mal
Dividir los tipos y las utilidades fue una arquitectura prematura. Fusionarlos más tarde significó realizar actualizaciones masivas de importaciones que un único paquete habría evitado.
Al principio, el sistema de CI estaba sobreadaptado: se utilizaban flujos de trabajo separados en GitHub Actions para cada aplicación con filtros de ruta para que los cambios en búsquedas no afectaran a la API principal. Los cambios en los paquetes compartidos debían llegar a todos los consumidores, y los filtros generaban más iteraciones de las que ahorraban. Un único flujo de trabajo de PR que compilaba todo, acelerado por el caché de Turborepo, resultó ser más sencillo.
Lo que hice bien
La resolución estricta detectó una dependencia realmente no declarada durante la migración; ese tipo de problema que, de otro modo, surgiría meses después.
Los paquetes compartidos mínimos se mantuvieron así. La infraestructura facilita el compartir; la disciplina evita que el código específico de cada aplicación se encuentre en common. Unas pocas cientos de líneas de código realmente compartido son una ventaja.
La ardua configuración del Dockerfile rindió frutos: cada servicio se compila por separado, solo descarga los paquetes del espacio de trabajo que necesita y genera una imagen de producción pequeña. Para cuando se añadieron una tercera API y un panel de administración junto a seis paquetes compartidos, incorporar otro servicio consistía principalmente en repetir los templates ya establecidos para Docker, CI y telemetría.
Omite el monorepo si solo tienes un servicio y no planeas añadir otro. Cuando se acerca el momento de agregar un segundo servicio y ya se están copiando tipos entre repositorios, suele ser demasiado tarde para hacer el cambio. El costo de la migración se incurre al principio; los beneficios se notan la primera vez que cambia un enum compartido y todos los consumidores lo actualizan en una sola commit.
Un monorepo no inventa una buena arquitectura. Hace que los patrones adecuados sean más fáciles de repetir y que los incorrectos resulten más difíciles de ocultar.
Desde el punto de vista operativo, la mayor ventaja duradera fue la previsibilidad durante el crecimiento del sistema. Los enums compartidos dejaron de desviarse. Los campos de telemetría se alinearon en todas las APIs. Las imágenes Docker permanecieron pequeñas porque pnpm deploy descartaba la estructura de trabajo temporal. Los costos que quedaron —el cambio constante en los comandos COPY de Dockerfile y la tentación de compartir demasiada información— eran manejables con listas de verificación y límites en los paquetes, no con otro cambio en la topología del repositorio.
Si se comenzara a reescribir el sistema hoy, la misma combinación de herramientas seguiría siendo la opción por defecto para un sistema de este tamaño: pnpm para instalaciones estrictas, Turborepo para la gestión de tareas, catálogos para versiones compartidas, y un paquete common deliberadamente pequeño. El cambio consistiría en empezar con un solo paquete compartido en lugar de dos, y con un único flujo de trabajo CI en lugar de varios.
Lecturas relacionadas
- Dónde encaja Turborepo en un monorepo de NestJS y cuándo saltárselo — Cómo Turborepo, los workspaces de pnpm y NestJS dividen el trabajo en un monorepo de TypeScript: paquetes compartidos, gráficos de tareas, caché, riesgos de acoplamiento y cuándo saltárselo.
- Crear una estructura de monorepo con pnpm y Turborepo paso a paso para aplicaciones de Node.js — Configurar un monorepo de TypeScript a partir de una carpeta vacía con workspaces de pnpm y Turborepo, luego ejecutar, compilar y filtrar tareas en una aplicación web, una API y paquetes compartidos.