Dónde encaja Turborepo en un monorepo de NestJS y cuándo omitirlo
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 omitirlo.
Fuentes: Turborepo + NestJS: The Monorepo Architecture That Makes Large TypeScript Systems Manageable
Un backend de NestJS suele comenzar como una única API con un package.json, una carpeta node_modules y un pipeline de CI. A medida que el producto crece, la autenticación, los pedidos, los pagos, el inventario y las notificaciones se dividen en servicios separados, y la pregunta operativa pasa de “¿cómo escalamos este servicio?” a “¿cómo gestionamos siete aplicaciones en TypeScript sin tener que copiar la misma configuración, tipos y pipelines siete veces?”. Este artículo explica qué aportan respectivamente un monorepo, pnpm y Turborepo, cómo el grafo de tareas y la caché ahorran trabajo, qué se puede compartir sin riesgos, y cuándo la configuración cuesta más de lo que aporta.
Tenga siempre en mente un límite fundamental: Turborepo no crea microservicios, no reemplaza a NestJS y por sí solo no hace que la arquitectura sea escalable. Resuelve un problema más específico: coordinar las tareas de tipo compilación en muchos paquetes dentro de un único repositorio.
De un servicio a varios repositorios
El repositorio de una plataforma en crecimiento podría terminar contando con una carpeta por servicio:
auth-service/
order-service/
payment-service/
inventory-service/
notification-service/
shipment-service/
api-gateway/
Dividir los servicios facilita la escalabilidad y la asignación de responsabilidades, pero cada servicio ahora trae consigo sus propias dependencias, configuración, procesos de compilación, pruebas, reglas de validación y flujos de trabajo.
El costo de coordinación en un polyrepo
Imagínese un sistema típico de comercio electrónico en el que una pasarela gestiona los servicios de autenticación, pedidos y productos; el servicio de pedidos llama a los servicios de pago e inventario, y el sistema de pagos activa las notificaciones:
API Gateway
|
┌─────────────────┼─────────────────┐
↓ ↓ ↓
Auth Order Product
|
┌────────┴────────┐
↓ ↓
Payment Inventory
|
↓
Notification
Un primer paso común es asignar a cada servicio su propio repositorio, el enfoque polyrepo:
github.com/company/auth-service
github.com/company/order-service
github.com/company/product-service
github.com/company/payment-service
github.com/company/inventory-service
github.com/company/notification-service
Se trata de una arquitectura legítima. La dificultad surge cuando se examina cada repositorio y se encuentra el mismo esqueleto de código repetido en todas partes:
package.json
tsconfig.json
eslint.config.js
Dockerfile
CI configuration
README.md
node_modules
Y, peor aún, los mismos tipos de código que necesitan varios servicios al mismo tiempo:
DTOs
TypeScript types
logging utilities
configuration
authentication helpers
database utilities
Kafka/RabbitMQ abstractions
testing utilities
Ahora, cada cambio en el material compartido se convierte en una tarea de sincronización. Cuando cambia un tipo compartido, ¿qué repositorios deben actualizarse? Cuando aumenta una dependencia, ¿qué servicios necesitan la actualización? Cuando cambia una regla de lint o un flujo de trabajo de CI, ¿cuántas copias deben editarse y mantenerse al día? La cantidad de servicios no es el verdadero problema: la coordinación sí lo es.
Un monorepo es una estrategia; Turborepo es una herramienta
La alternativa es mantener todas las aplicaciones y el código compartido en un único repositorio, con las aplicaciones bajo apps/, las bibliotecas internas bajo packages/, y la configuración del espacio de trabajo y las tareas en la raíz:
ecommerce-platform/
│
├── apps/
│ ├── api-gateway/
│ ├── auth-service/
│ ├── order-service/
│ ├── product-service/
│ ├── payment-service/
│ ├── inventory-service/
│ └── notification-service/
│
├── packages/
│ ├── types/
│ ├── logger/
│ ├── config/
│ ├── database/
│ └── messaging/
│
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
Ese diseño corresponde a un monorepo: una decisión sobre dónde se encuentra el código. Turborepo es una herramienta que coordina el trabajo dentro de él. Se puede tener uno sin el otro.
Qué hace realmente Turborepo
Turborepo es un sistema de compilación para monorepos de JavaScript y TypeScript. Orquesta tareas que existen en varios paquetes al mismo tiempo, como por ejemplo:
build
test
lint
type-check
dev
Sin él, compilar varios servicios implica visitar cada uno manualmente. Primero el servicio de autenticación:
cd apps/auth-service
pnpm build
luego los demás uno tras otro (el fragmento de código ejecuta dos comandos en una misma línea; cada cd y pnpm build debe estar en su propia línea):
cd ../order-service
pnpm buildcd ../payment-service
pnpm build
Con Turborepo, declara las tareas una sola vez y ejecutas un único comando desde la raíz:
turbo build
Luego determina qué tareas son necesarias, cuáles pueden ejecutarse de forma concurrente y qué resultados se pueden recuperar del caché en lugar de volver a calcularlos.
Estratificación de aplicaciones NestJS y paquetes compartidos
Una versión orientada a producción del repositorio proporciona a cada aplicación Nest su propio src/ y package.json, de modo que cada aplicación sea un paquete de espacio de trabajo adecuado:
ecommerce-platform/
│
├── apps/
│ │
│ ├── api-gateway/
│ │ ├── src/
│ │ └── package.json
│ │
│ ├── auth-service/
│ │ ├── src/
│ │ └── package.json
│ │
│ ├── order-service/
│ │ ├── src/
│ │ └── package.json
│ │
│ └── payment-service/
│ ├── src/
│ └── package.json
│
├── packages/
│ │
│ ├── types/
│ ├── logger/
│ ├── config/
│ ├── database/
│ └── messaging/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
Conceptualmente existen dos capas: las aplicaciones, que son servicios NestJS que implementan funcionalidades empresariales, y los paquetes, que albergan código reutilizable.
Monorepo
|
┌─────────┴─────────┐
↓ ↓
Apps Packages
| |
NestJS Shared Code
Las aplicaciones implementan funcionalidades, los paquetes ofrecen componentes reutilizables, y Turborepo coordina las tareas en ambos.
Paquetes internos como única fuente de verdad
La ventaja más importante en el uso diario de un monorepo son los paquetes internos. Un paquete de tipos, por ejemplo, puede contener los modelos con los que varios servicios están de acuerdo:
packages/
└── types/
├── src/
│ ├── user.ts
│ ├── order.ts
│ ├── payment.ts
│ └── pagination.ts
└── package.json
Cualquier servicio Nest puede luego importarlos mediante su nombre de paquete, resuelto a través del espacio de trabajo y no a través de un registro:
import { OrderStatus } from '@repo/types';
en lugar de pegar el mismo enum en cada servicio con la esperanza de que las copias nunca difieran:
enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
CANCELLED = 'cancelled',
}
Cuando el enum recibe un valor, el compilador marca todos los lugares afectados en la misma actualización.
Compartir contratos no es lo mismo que compartir la propiedad
Compartir es sencillo en un monorepo, y precisamente por eso se necesita disciplina. La lógica de dominio del servicio de pedidos pertenece al propio servicio de pedidos:
order-service
↓
order-domain
No debería migrar silenciosamente a un paquete:
packages/
└── order-domain/
que luego importan otros servicios:
payment-service
inventory-service
notification-service
Una vez realizado el pago, el código relacionado con el inventario y las notificaciones depende de los componentes internos del dominio de pedidos; por lo tanto, un cambio en las reglas de pedido puede afectar negativamente a tres implementaciones que no están relacionadas entre sí. Regla general: comparta la infraestructura y los contratos de forma intencionada, pero nunca comparta la propiedad del negocio por defecto. Los candidatos razonables para paquetes compartidos son los siguientes:
@repo/types
@repo/logger
@repo/config
@repo/testing
@repo/messaging
La lógica de negocio debería permanecer normalmente en el servicio que la gestiona. Para un análisis más profundo de este equilibrio, consulte duplicación versus acoplamiento: cómo decidir cuándo debe existir código compartido.
El grafo de dependencias determina qué se ejecuta
Turborepo lee las relaciones de dependencia declaradas en el package.json de cada espacio de trabajo. En este ejemplo, ambos servicios dependen del paquete types, y el servicio order también depende de un paquete de base de datos:
@repo/types
/ \
/ \
↓ ↓
auth-service order-service
|
↓
@repo/database
Dado que Turborepo conoce este grafo, si editas un archivo dentro del servicio order:
apps/order-service/src/orders/order.service.ts
no hay razón para volver a compilar un servicio no relacionado como:
notification-service
Con filtrado (por ejemplo turbo build --filter=...), puedes limitar la ejecución a los paquetes modificados y sus dependencias.
Caché: omitir el trabajo ya realizado
La primera vez que ejecutas una compilación:
turbo build
se ejecuta realmente cada tarea requerida:
auth-service → BUILD
order-service → BUILD
payment-service → BUILD
inventory-service → BUILD
Turborepo analiza los insumos de cada tarea (archivos fuente, dependencias, configuración, variables de entorno relevantes) y almacena los resultados y registros. Ejecute nuevamente la misma orden sin realizar cambios relevantes:
turbo build
Y el resultado se ve así:
auth-service → CACHE HIT
order-service → CACHE HIT
payment-service → CACHE HIT
inventory-service → CACHE HIT
Los resultados se recuperaron del caché. Para que esto funcione, Turborepo debe saber qué archivos genera una tarea, y eso es lo que indica la configuración outputs en turbo.json (en el caso de una compilación con Nest, generalmente dist/**). Una tarea con resultados no declarados puede indicar que hay un acceso al caché, pero sin proporcionarle los artefactos de la compilación.
Por qué los entornos CI se benefician más
Un pipeline ingenuo repite todo el trabajo con cada actualización:
Git Push
↓
Install dependencies
↓
Build everything
↓
Test everything
↓
Lint everything
↓
Deploy
Con Turborepo en el pipeline, cada tarea se verifica primero contra el grafo y la caché, y solo se ejecutan las tareas cuyas entradas han cambiado:
Git Push
↓
CI
↓
Turborepo
↓
┌─────────┴─────────┐
↓ ↓
Dependency Task Graph
Analysis ↓
Cache Check
↓
┌─────────┴─────────┐
↓ ↓
Cached Execute
Tasks Tasks
En repositorios grandes, esto elimina una gran cantidad de trabajo redundante en CI.
Caché local versus caché remota
Existen dos niveles de caché:
- Caché local. Tu propia máquina reutiliza los resultados de las tareas que ya ha ejecutado, lo cual acelera el desarrollo diario.
- Caché remota. Una caché compartida permite a los desarrolladores y las máquinas de CI reutilizar los resultados unos de otros.
La configuración remota se ve así:
Developer A
│
↓
Remote Cache
↑
│
Developer B
↑
│
CI
Si un desarrollador ya ha creado un servicio y el CI posteriormente recibe las mismas entradas de tarea, el CI descarga el resultado en lugar de volver a construirlo. Trate la caché remota como parte de su cadena de suministro: controle quién puede escribir en ella, ya que un artefacto dañado se entregaría a todos.
Ejecución de tareas independientes en paralelo
Algunos servicios no dependen de los resultados de construcción de otros:
auth-service
order-service
product-service
Se ejecutan secuencialmente, cada uno espera al anterior:
auth → finish
↓
order → finish
↓
product
Turborepo puede ejecutarlos de forma concurrente, limitado por la CPU disponible:
┌── auth ────┐
│ │
turbo ──┼── order ───┼──→ Done
│ │
└── product ─┘
Descripción de las relaciones entre tareas
La misma orquestación se aplica a todo tipo de tarea, no solo a las construcciones:
turbo build
turbo test
turbo lint
turbo typecheck
Las relaciones se declaran en turbo.json. En la configuración a continuación, "^build" significa “primero compilar las dependencias de este paquete”, por lo que los paquetes compartidos se compilan antes que los servicios que los importan. La tarea test depende de build sin el carácter de careta, lo que se refiere a la compilación del mismo paquete. lint y typecheck no tienen dependencias y pueden ejecutarse de inmediato en paralelo.
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {},
"typecheck": {}
}
}
Usted describe cómo las tareas dependen unas de otras, y Turborepo deriva un plan de ejecución. En un proyecto real también agregaría outputs a las tareas cachebillables, como se describió anteriormente, y se aseguraría de que el nombre de cada tarea coincida con un script en los archivos package.json de los paquetes del espacio de trabajo.
pnpm y Turborepo realizan tareas diferentes
Esa combinación aparece con tanta frecuencia que puede parecer una sola herramienta:
Turborepo + pnpm
Se trata de capas separadas. pnpm gestiona los paquetes y los espacios de trabajo: instala dependencias, vincula paquetes internos y aplica un esquema estricto para node_modules. Turborepo orquesta tareas y almacena sus resultados sobre ese espacio de trabajo.
pnpm
│
Workspace Management
│
↓
┌─────────────┐
│ Monorepo │
└──────┬──────┘
│
↓
Turborepo
│
┌───────┼────────┐
↓ ↓ ↓
Build Test Lint
Para conocer más detalles sobre pnpm en sí, consulte npm vs pnpm: almacenamiento, velocidad y consideraciones prácticas.
¿Por qué no usar simplemente el modo monorepo integrado de Nest?
La CLI de Nest cuenta con su propio modo monorepo que incluye aplicaciones y bibliotecas, por lo que añadir Turborepo requiere un motivo concreto. La diferencia radica en el alcance: el espacio de trabajo de Nest entiende proyectos Nest, mientras que Turborepo funciona con cualquier paquete de JavaScript o TypeScript presente en el espacio de trabajo. Tan pronto como el repositorio contiene más que servicios Nest, como por ejemplo interfaces de usuario, componentes frontales y workers Node estándar:
apps/
├── web/ # Next.js
├── admin/ # Next.js
├── api-gateway/ # NestJS
├── order-service/ # NestJS
└── worker/ # Node.js
además de paquetes compartidos para la interfaz de usuario, tipos y configuraciones:
packages/
├── ui/
├── types/
├── config/
└── eslint-config/
ya no se está gestionando un proyecto Nest, sino una plataforma TypeScript, y es ahí donde Turborepo resulta atractivo.
Turborepo, Nx o la CLI de Nest
No existe una opción universalmente correcta. Como orientación general: el modo monorepo de Nest es la opción más sencilla cuando todo está basado en Nest. Turborepo representa una capa relativamente ligera de ejecución de tareas y caché encima de los espacios de trabajo del gestor de paquetes. Nx también ofrece un grafo de proyectos y funcionalidades de caché, además de un conjunto más amplio de plugins, generadores de código y convenciones, que algunos equipos valoran mientras que otros lo consideran excesivo. Elija según sus necesidades reales, no según qué herramienta recibe más atención en las redes sociales.
Cuando Turborepo es un exceso
Supongamos que todo el sistema consiste en una API de Nest respaldada por PostgreSQL y Redis:
NestJS API
↓
PostgreSQL
↓
Redis
con un equipo pequeño y un único elemento desplegable:
1 application
5 developers
1 deployment
Turborepo probablemente sea innecesario. Un repositorio simple de Nest es más sencillo, y la implementación de todo esto:
Turborepo
pnpm workspace
multiple packages
complex task graph
Añade carga cognitiva sin que esto se compense. Las herramientas ganan su lugar cuando el problema justifica su uso.
Cuando comienza a dar resultados
La situación cambia cuando varias de estas condiciones se cumplen al mismo tiempo:
Multiple applications
+
Shared packages
+
Shared tooling
+
Repeated CI tasks
+
Large TypeScript codebase
Un caso típico es una plataforma con interfaces frontales en Next.js, APIs en Nest y trabajadores en segundo plano que todos utilizan paquetes compartidos:
Platform
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Next.js NestJS Workers
Web APIs Jobs
│ │ │
└──────────────┼──────────────┘
↓
Shared Packages
Un diseño maduro con responsabilidades claras
Una plataforma Nest desarrollada plenamente podría incluir una área separada para la infraestructura, además de las aplicaciones y los paquetes:
platform/
│
├── apps/
│ ├── api-gateway/
│ ├── auth-service/
│ ├── user-service/
│ ├── order-service/
│ ├── inventory-service/
│ ├── payment-service/
│ └── notification-service/
│
├── packages/
│ ├── config/
│ ├── logger/
│ ├── types/
│ ├── database/
│ ├── messaging/
│ └── testing/
│
├── infrastructure/
│ ├── docker/
│ └── deployment/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
Cada herramienta tiene una función específica. NestJS es el marco de aplicaciones:
NestJS
↓
Application framework
Y las capas restantes se dividen de la siguiente manera (en el fragmento de código varias entradas se han presentado juntas; considérelas como pnpm para dependencias y espacios de trabajo, Turborepo para la orquestación de tareas y el caché, Docker para la contenerización, Kafka o RabbitMQ para la comunicación asíncrona, y PostgreSQL o Redis para los datos y el caché):
pnpm
↓
Dependency & workspace managementTurborepo
↓
Task orchestration + cachingDocker
↓
ContainerizationKafka / RabbitMQ
↓
Asynchronous communicationPostgreSQL / Redis
↓
Data & caching
Turborepo no es su infraestructura, ni su broker de mensajes ni su plataforma de despliegue. Es la capa de compilación y tareas que rodea al monorepo.
Un repositorio no significa un despliegue
Una preocupación frecuente es que al colocar todo en un único repositorio se pierda la independencia que deberían ofrecer los microservicios. Pero eso no ocurre. Dónde reside el código y cómo se despliega son decisiones separadas. Un solo repositorio puede contener muchos servicios:
One Git repository
│
├── auth-service
├── order-service
├── payment-service
└── notification-service
Mientras cada uno sigue enviándose como su propio contenedor:
auth-service → Container A
order-service → Container B
payment-service → Container C
notification → Container D
y escala según sus propios criterios:
order-service × 10
payment-service × 5
notification × 3
Un monorepo no es un monolito, y un polyrepo no te proporciona automáticamente microservicios. Son dimensiones independientes. Lo que sí necesitas es un CI que despliegue únicamente los servicios cuyo código o dependencias han cambiado, algo que el grafo de dependencias hace posible.
El riesgo oculto: un paquete común que lo abarca todo
El fácil intercambio de código tiene efectos en ambos sentidos. A menudo comienza de forma inocente con un solo paquete:
packages/common
que gradualmente recopila herramientas auxiliares para cada dominio:
packages/common
├── AuthUtils
├── OrderUtils
├── PaymentUtils
├── InventoryUtils
├── NotificationUtils
├── DatabaseUtils
└── EverythingUtils
hasta que todos los servicios dependen de él. En ese punto se ha creado un monolito distribuido dentro de un monorepo: un cambio en una herramienta auxiliar obliga a reconstruir, volver a probar y volver a desplegar todo. Las carpetas de nivel superior siguen pareciendo ordenadas:
apps/
packages/
Pero la arquitectura subyacente está estrechamente acoplada. Mantenga la regla estricta: comparta infraestructura, contratos y utilidades verdaderamente genéricas; mantenga la lógica de dominio dentro del servicio que la posee.
Un modelo mental en capas
Es útil imaginar el sistema como capas, con los dominios de negocio en la parte superior, los contratos e infraestructura compartidos debajo, y las herramientas de monorepo en la base:
Business Domains
│
┌───────────────┼───────────────┐
↓ ↓ ↓
Order Payment Inventory
│ │ │
└───────────────┼───────────────┘
↓
Shared Contracts
│
↓
Shared Infrastructure
│
↓
Monorepo Tooling
│
↓
Turborepo
Turborepo se encuentra cerca de la parte inferior. Debe respaldar su arquitectura, no darle forma.
Decidir si adoptarlo
Responda a cuatro preguntas en orden.
¿Tiene más de una aplicación?
Si no, la respuesta es sencilla:
Keep it simple.
Si sí, pase al siguiente paso.
¿Las aplicaciones comparten herramientas o código significativos?
Si no lo hacen:
Polyrepo may be perfectly reasonable.
Si sí lo hacen:
Monorepo becomes attractive.
¿Están volviéndose costosos los tiempos de compilación, prueba y análisis de código?
Si es así:
Turborepo's orchestration and caching become valuable.
¿Ejecuta Next.js, NestJS, workers y paquetes compartidos de TypeScript al mismo tiempo?
Si es así:
Turborepo becomes a strong candidate.
La previsibilidad es el verdadero beneficio
La velocidad es un efecto secundario. El valor real radica en que un repositorio grande se vuelve predecible. A medida que aumentan las aplicaciones, alguien debe responder, para cada cambio:
What changed?
y un conjunto de preguntas adicionales (el código las muestra en una sola línea): qué debe recompilarse, qué debe volver a probarse, qué se puede ejecutar en paralelo, qué se puede obtener del caché y qué paquetes dependen del cambio:
What needs to rebuild?What needs to be tested?What can run in parallel?What can come from cache?Which packages depend on this change?
Sin herramientas, los ingenieros responden a esto manualmente; con un sistema de compilación de monorepo, la mayor parte de la información se obtiene del grafo.
Puntos clave
Turborepo no solucionará problemas como límites de servicio deficientes, un diseño de base de datos débil, acoplamiento excesivo, contratos API inadecuados, falta de claridad en la propiedad del dominio o microservicios mal diseñados. Lo que sí hace es mantener un repositorio de TypeScript en constante crecimiento bajo control. Toda la estructura se puede ver en una sola imagen:
Business Architecture
│
↓
NestJS Apps
│
↓
Shared Packages
│
↓
pnpm
│
↓
Turborepo
│
┌──────────┼──────────┐
↓ ↓ ↓
Build Test Lint
│ │ │
└──────────┼──────────┘
↓
CI/CD
- Un monorepo decide dónde se encuentra el código; pnpm gestiona el espacio de trabajo; Turborepo orquesta y almacena en caché las tareas; NestJS construye los servicios. Mantenga estas funciones separadas.
- Comparta abstracciones de tipos, configuración, registro y mensajería; mantenga la lógica del dominio junto con su servicio correspondiente para evitar un monolito distribuido.
- Declare las relaciones entre tareas mediante
dependsOny los resultados enturbo.jsonpara que el almacenamiento en caché sea eficaz y preciso. - El diseño del repositorio y el despliegue son independientes: cada servicio aún puede desplegarse y escalar por separado.