El problema: CI que reconstruye todo#
Tienes el monorepo del capítulo anterior: apps/web, packages/core, packages/ui,
packages/api-client. El package.json raíz tiene algo así:
{
"scripts": {
"build": "pnpm -r run build"
}
}Cada vez que alguien hace push, el CI ejecuta pnpm run build. Eso lanza pnpm -r run build,
que construye los cuatro paquetes, en orden o en paralelo según la bandera. El problema:
no sabe qué cambió. Cambiaste una línea en packages/core y ha reconstruido apps/web,
packages/ui y packages/api-client que no han tocado.
En un monorepo pequeño eso tarda segundos. En un monorepo real con veinte paquetes, TypeScript en cada uno y tests, tardas veinte minutos en cada push. Y la mayor parte de ese tiempo es trabajo que ya se hizo ayer, y anteayer, y la semana pasada.
La solución tiene dos piezas: un grafo de tareas que sabe en qué orden construir, y una caché que sabe qué ya se construyó.
El grafo de tareas y el orden topológico#
Un orquestador de tareas como Turborepo lee los package.json de cada paquete para construir
el grafo de dependencias del proyecto: sabe que packages/ui depende de packages/core,
que apps/web depende de los tres paquetes, y que packages/api-client no depende de ninguno
de los otros paquetes internos.
Con ese grafo construye el grafo de tareas: el conjunto de nodos (build de core,
build de ui, build de web…) y las aristas que dicen en qué orden ejecutarlos.
El fichero de configuración central es turbo.json en la raíz del monorepo:
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}Lo que dice cada campo:
dependsOn: ["^build"] — el prefijo ^ significa “en los paquetes dependencias”. Para
construir packages/ui, primero deben haber corrido el build de todos los paquetes que
ui tiene en sus dependencies. Esto garantiza el orden topológico: los paquetes sin
dependencias internas se construyen primero (en paralelo entre ellos), y los que los consumen
se construyen después.
outputs: ["dist/**"] — qué artefactos produce la tarea. Turbo los guarda en caché y los
restaura cuando hay un cache hit. Sin outputs, Turbo sabe que la tarea ya corrió (y no la
repite), pero no restaura los ficheros compilados al disco. Si otro paquete depende de dist/,
lo encontrará vacío: el build falla de forma confusa.
cache: false en dev — un proceso de desarrollo no termina, así que no tiene sentido
cachearlo. persistent: true le dice a Turbo que no espere a que este proceso termine antes
de arrancar otros (útil para correr dev en paralelo en varios paquetes).
Para lanzar el build con Turbo:
# primera ejecución: construye todo en orden topológico
turbo run build
# segunda ejecución: todos los paquetes muestran FULL TURBO (cache hit)
turbo run buildLa primera vez ves el log de compilación de cada paquete. La segunda vez ves FULL TURBO
en todos: Turbo ha calculado que los inputs no cambiaron y ha devuelto los outputs desde
caché sin ejecutar nada.
Solo lo que cambió: affected#
La caché local ya evita recompilar lo que no cambió. Pero a veces quieres ir más allá: ejecutar una tarea solo en los paquetes que un cambio concreto de git afecta.
# construye core y todos los paquetes que lo consumen (directa o transitivamente)
turbo run build --filter=@team-builder/core...
# construye solo web y los paquetes de los que depende
turbo run build --filter=...@team-builder/web
# construye todos los paquetes que cambiaron respecto al commit anterior
turbo run build --filter=...[HEAD^1]
# construye los paquetes que cambiaron respecto a la rama main
turbo run build --filter=...[origin/main...HEAD]La sintaxis del --filter:
@team-builder/core... — el paquete core y todos los paquetes que lo consumen (sus
dependientes). Los tres puntos a la derecha significan “y los que dependen de él”.
...@team-builder/web — web y todos los paquetes de los que depende (sus dependencias).
Los tres puntos a la izquierda significan “y de lo que depende”.
...[HEAD^1] — todos los paquetes que tienen al menos un fichero que cambió entre el commit
actual y el anterior, más los paquetes que los consumen. Esta es la variante que los pipelines
de CI usan para solo tocar lo que el PR modificó.
El test del «¿y qué?»: sin --filter, en un monorepo de veinte paquetes con PR que toca uno,
CI reconstruye los veinte (aunque la caché ayude). Con --filter=...[origin/main...HEAD], CI
identifica qué paquetes cambiaron, construye esos y sus dependientes, y deja los demás intactos.
En la práctica, esto convierte un pipeline de veinte minutos en uno de dos.
Caché local y remota#
Caché local#
Turbo calcula un hash de inputs para cada tarea: los ficheros fuente del paquete (los que
no están en .gitignore), las variables de entorno listadas en el campo env de la tarea,
la versión de las dependencias, y la configuración de la propia tarea. Si el hash coincide
con una entrada en ~/.turbo/, devuelve los outputs sin ejecutar nada.
El hash cambia cuando:
- modificas un fichero fuente del paquete,
- cambia una variable de entorno declarada en
env, - actualizas una dependencia (porque el
package.jsonforma parte de los inputs), - cambias la configuración de la tarea en
turbo.json.
Esto es lo que garantiza la corrección: la caché solo reutiliza un resultado si los inputs son exactamente los mismos. Si cambias un fichero, el hash cambia, la tarea se ejecuta de nuevo y el resultado nuevo va a caché.
Por eso es importante declarar las variables de entorno que afectan al build:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "API_URL"]
}
}
}Sin env: ["NODE_ENV"], Turbo podría servir desde caché el build de desarrollo cuando en
realidad estás haciendo el build de producción. Silenciosamente.
Caché remota#
La caché local es por máquina. Si el CI construye en un agente nuevo en cada ejecución (lo más habitual), la caché local del agente anterior no existe.
La caché remota resuelve esto: Turbo (o Nx) sube los outputs a un servidor compartido. La próxima vez que alguien corra la misma tarea con los mismos inputs, descarga el resultado del servidor en lugar de ejecutar la tarea. Funciona entre máquinas y entre el CI y la máquina local del desarrollador.
Para Turborepo, Vercel ofrece Remote Cache integrado (gratuito para proyectos personales):
# autentícate con Vercel desde la raíz del monorepo
npx turbo login
# enlaza este repo al Remote Cache de tu equipo
npx turbo linkTambién puedes usar un servidor de caché propio (compatible con el protocolo de Turbo) o soluciones como Nx Cloud para Nx.
En CI: junta lo afectado y la caché compartida#
Todo esto —affected para tocar solo lo que cambió, caché remota para no repetir trabajo entre
máquinas— cobra sentido de verdad en CI. Este es el workflow que un junior se encuentra el primer
día en una empresa con monorepo: un .github/workflows/ci.yml que, en cada PR, construye, testea
y linta solo los paquetes afectados, reutilizando de la caché remota lo que ya compiló otra
ejecución.
# .github/workflows/ci.yml — build, test y lint de lo afectado por el PR
name: CI
# Se dispara en cada pull request contra main.
on:
pull_request:
branches: [main]
# El token y el equipo de la caché remota de Turbo, leídos de los secrets del repo.
# Con ellos, este job reutiliza lo que ya compiló el CI de otra rama o un compañero.
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
affected:
runs-on: ubuntu-latest
steps:
# fetch-depth: 0 trae TODO el historial de git: Turbo lo necesita para
# comparar el PR con origin/main y calcular qué paquetes cambiaron.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Instala pnpm (lee la versión de packageManager en el package.json raíz).
- uses: pnpm/action-setup@v4
# Node con caché del store de pnpm entre ejecuciones.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
# --frozen-lockfile: falla si el lockfile no está al día, en vez de "arreglarlo" en CI.
- run: pnpm install --frozen-lockfile
# La línea clave: build + test + lint SOLO de los paquetes que el PR tocó
# (y sus dependientes), reutilizando la caché remota para el resto.
- run: pnpm turbo run build test lint --filter=...[origin/main...HEAD]Las cuatro decisiones que hacen que esto funcione:
fetch-depth: 0— por defecto,actions/checkouthace un clon superficial (solo el último commit). Turbo necesita el historial para hacer el diff contraorigin/main; sin esto, el--filter=...[origin/main...HEAD]no encuentra la base de comparación y falla.TURBO_TOKEN/TURBO_TEAM— habilitan la caché remota. Sin ellos, cada agente de CI arranca con la caché vacía y recompila todo. Con ellos, un PR que no tocapackages/coredescarga sudist/en lugar de reconstruirlo.--frozen-lockfile— en CI nunca quieres queinstallmodifique el lockfile; quieres que falle si alguien olvidó commitearlo. Es la diferencia entre un build reproducible y uno que “funciona en mi máquina”.--filter=...[origin/main...HEAD]— el corazón del ahorro: de veinte paquetes, construye los que cambiaron respecto amainy sus dependientes, y sirve el resto desde caché.
Turborepo vs Nx#
Ambas herramientas resuelven el mismo problema —hacer usable un monorepo— pero con filosofías distintas.
Turborepo es deliberadamente ligero. Lee los package.json de los workspaces de pnpm/npm/yarn
para construir el grafo, ejecuta las tareas declaradas en turbo.json y cachea los outputs.
No genera código, no tiene opiniones sobre el stack y no instala plugins. Si tu monorepo es
fundamentalmente un conjunto de paquetes TypeScript que se construyen y testean, Turbo es
todo lo que necesitas.
Nx es un sistema de build más completo. Tiene su propio grafo de proyectos (que puede incluir
proyectos que no son workspaces de pnpm), plugins oficiales que entienden Angular, React, Node,
Java y otros stacks, generadores de código (nx generate), y un análisis de impacto que va
más allá de las dependencias explícitas del package.json (puede analizar imports reales).
También tiene caché local y Nx Cloud para la remota. Si el monorepo mezcla tecnologías, si
necesitas scaffolding unificado para crear nuevos paquetes, o si el equipo va a crecer mucho,
Nx ofrece más infraestructura desde el principio.
La elección sin dogma:
| Turborepo | Nx | |
|---|---|---|
| Curva de entrada | Baja (turbo.json es sencillo) | Media (project graph, plugins) |
| Grafo de dependencias | Lee package.json de los workspaces | Más flexible, puede incluir proyectos no-workspace |
| Generadores | No incluye | nx generate, con plugins por stack |
| Análisis de imports reales | No | Sí (detecta dependencias no declaradas) |
| Caché remota integrada | Vercel Remote Cache | Nx Cloud |
| Cuándo usarlo | Monorepo TypeScript/JS, equipo pequeño-mediano, quieres empezar rápido | Monorepo heterogéneo, equipos grandes, necesitas scaffolding y análisis avanzado |
El Team Builder del curso funciona perfectamente con Turborepo. En una empresa con veinte equipos y stacks distintos, Nx empieza a justificar su mayor superficie de API.
Comprueba lo que sabes#
Pregunta 1 de 8
¿Por qué sin un orquestador de tareas, un CI en un monorepo con cuatro paquetes reconstruye los cuatro aunque solo hayas cambiado uno?
Tu turno#
El ejercicio añade Turborepo al monorepo del capítulo anterior: instalas turbo, escribes
el turbo.json, observas el orden topológico en la primera ejecución y el FULL TURBO en
la segunda, y pruebas el --filter. Las soluciones muestran el turbo.json de cada tier
con sus comentarios de por qué mejora al anterior.
Ejercicio · hazlo en local
Añadir Turborepo al monorepo del Team Builder
Sobre el monorepo del capítulo anterior (apps/web + packages/{core,ui,api-client}), añade Turbo como orquestador de tareas: instálalo, crea el turbo.json con las tareas build y test, córrelo dos veces para ver el cache hit y prueba el flag --filter. Las soluciones muestran el turbo.json resultante de cada tier con sus comentarios. Hazlo primero por tu cuenta y léelas después.
Paso 1: Turbo corre las tareas
- `turbo.json` existe en la raíz del monorepo con la tarea `build` declarada.
- `turbo run build` completa sin errores y muestra el grafo de ejecución.
- El segundo `turbo run build` muestra al menos algún cache hit.
Paso 2: `outputs` y `dependsOn` bien declarados, segundo build en caché
- La tarea `build` tiene `dependsOn: ["^build"]`: Turbo construye las dependencias antes.
- `outputs` declara los artefactos correctos (`dist/**`): Turbo los guarda y restaura en cache hits.
- El segundo `turbo run build` muestra `FULL TURBO` en todos los paquetes.
- La tarea `test` también está declarada con `dependsOn: ["build"]`.
Paso 3: Comprende el hash de inputs y documenta qué invalida la caché
- El `turbo.json` o el README explica qué forma el hash de inputs (ficheros fuente, variables de entorno en `env`, config de la tarea) y qué lo invalida.
- Las variables de entorno críticas (`NODE_ENV`, `API_URL`) están declaradas en el campo `env` de la tarea.
- La tarea `dev` tiene `cache: false` y `persistent: true`: no tiene sentido cachear un proceso que no termina.
- El README razona cuándo `cache: false` en `test` es la decisión correcta y cuándo no.
Cómo hacerlo en local
Clona el repositorio del curso y entra en la carpeta del ejercicio. El ejercicio se hace sobre el monorepo del capítulo anterior: sigue el README y compara el turbo.json que escribas con las soluciones de cada tier.
git clone <repo>
cd exercises/dificil-escala/nx-y-turborepo
# añade turbo como dependencia de desarrollo en la raíz del monorepo
pnpm add -D -w turbo
# primera ejecución: observa el orden topológico
turbo run build
# segunda ejecución: todos los paquetes deben mostrar FULL TURBO
turbo run build
# ejecuta solo core y sus dependientes
turbo run build --filter=@team-builder/core... Ver soluciones
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
}
}
} Por qué este nivel
- `dependsOn: ["^build"]` ya está: Turbo sabe que debe construir las dependencias antes. Es el mínimo para que el orden sea correcto.
- Sin `outputs`, Turbo evita ejecutar la tarea si hay cache hit, pero no restaura los ficheros compilados al disco. Si otro paquete depende de `dist/`, lo encontrará vacío.
- Su límite principal: sin `outputs` declarados, la caché es parcialmente inútil en un monorepo con dependencias entre paquetes.
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
}
}
} Por qué es mejor que el anterior
- `outputs: ["dist/**", ".next/**", "!.next/cache/**"]` dice exactamente qué guardar en caché y qué excluir (la caché interna de Next no se versiona).
- Con `outputs` correctos, el segundo build restaura `dist/` desde la caché local: el paquete dependiente encuentra los ficheros compilados aunque no haya ejecutado nada.
- La tarea `test` hereda el patrón: `dependsOn: ["build"]` (primero construye, luego testa) y sus propios `outputs` para cachear los informes de cobertura.
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"env": ["NODE_ENV", "API_URL"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"cache": false
},
"lint": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
} Por qué es mejor que el anterior
- El campo `env` en `build` incluye `NODE_ENV` y `API_URL`: si esas variables cambian entre entornos, el hash de inputs cambia y la caché no reutiliza un build de desarrollo para producción.
- `cache: false` en `test`: si los tests llaman a una API real o escriben en una base de datos, cachear el resultado anterior daría un falso positivo. En ese caso, forzar la ejecución siempre es más seguro aunque tarde más.
- `dev` tiene `cache: false` y `persistent: true`: un proceso de desarrollo no termina, así que cachearlo no tiene sentido; `persistent: true` le dice a Turbo que no espere a que termine para arrancar otros procesos.
- El campo `lint` con `dependsOn: ["^build"]` asegura que el linter vea los tipos generados por los paquetes dependencias antes de analizar el código.