Cuando un proyecto crece, llega un momento en que un solo repositorio con una sola carpeta de código se queda pequeño. No por el volumen de líneas, sino por la estructura: hay lógica que varios proyectos necesitan, hay equipos que trabajan en partes distintas, hay componentes que conviene versionar por separado. Aquí es donde entran los monorepos y los workspaces.
Polyrepo vs monorepo#
La decisión más básica es cuántos repositorios de Git usas para tu proyecto.
Con un polyrepo cada paquete o aplicación vive en su propio repositorio. El aislamiento es máximo: cada equipo trabaja en su repo, con sus permisos, su CI y su ciclo de releases. El problema aparece cuando necesitas cambiar algo que afecta a varios repos a la vez: actualizas el paquete de utilidades, publicas una nueva versión, y luego tienes que ir repo a repo actualizando la dependencia. Si el cambio rompe algo, lo descubres tarde, en producción, cuando el equipo que usa tu paquete finalmente lo actualiza.
Con un monorepo todo el código que tiene sentido vivir junto vive en el mismo repositorio. El Team Builder, por ejemplo, podría tener:
packages/core— el tipoHeroe, la lista de héroes y la funciónfiltrarPorRolpackages/ui— la tarjeta de héroe como componente reutilizablepackages/api-client— el cliente para llamar al backendapps/web— la aplicación web que une todo
Si cambias la firma de filtrarPorRol en packages/core, el error aparece inmediatamente en packages/ui y en apps/web, en el mismo commit, en el mismo CI. No hay ventana de tiempo en que la incompatibilidad se oculta detrás de versiones publicadas.
Los trade-offs son reales en los dos sentidos:
| Polyrepo | Monorepo |
|---|---|
| Aislamiento claro por repo | Visibilidad inmediata de cambios cross-paquete |
| Permisos independientes por repo | Tooling compartido más fácil de mantener |
| CI pequeños y enfocados | CI que necesita saber qué cambió para no recompilar todo |
| Actualizar una dep compartida requiere varios PRs | Un PR puede actualizar todo al mismo tiempo |
El monorepo no es siempre mejor: en organizaciones muy grandes con equipos con necesidades radicalmente distintas, el polyrepo puede ser la decisión correcta. Pero para la mayoría de proyectos donde los paquetes están relacionados, el monorepo reduce fricción y hace los cambios más seguros.
Workspaces#
La pieza técnica que hace funcionar un monorepo con pnpm es el archivo pnpm-workspace.yaml. Vive en la raíz del repositorio y declara qué carpetas contienen paquetes:
packages:
- "apps/*"
- "packages/*"Con esa declaración, pnpm sabe que cualquier carpeta dentro de apps/ o packages/ es un workspace independiente. La estructura del Team Builder quedaría así:
team-builder/
pnpm-workspace.yaml
package.json
apps/
web/
package.json
src/
packages/
core/
package.json
src/
ui/
package.json
src/
api-client/
package.json
src/Cada carpeta con package.json es un workspace. El package.json de la raíz del monorepo no tiene código propio: solo configura el tooling compartido (linter, TypeScript, scripts globales).
El protocolo workspace:*#
Cuando apps/web necesita usar packages/core, no lo instala de npm. Lo referencia dentro del monorepo con el protocolo workspace:*:
{
"name": "@team-builder/web",
"version": "0.1.0",
"dependencies": {
"@team-builder/core": "workspace:*",
"@team-builder/ui": "workspace:*"
}
}El * significa “la versión que tengo en local, la que sea”. pnpm resuelve @team-builder/core al directorio packages/core del monorepo, sin tocar npm. Cuando ejecutas pnpm install desde la raíz, crea un symlink en node_modules/@team-builder/core que apunta directamente a packages/core/src/. Editas el código de packages/core, y apps/web lo ve de inmediato sin ningún paso de publicación.
Cuando llegue el momento de publicar a npm, pnpm sustituye automáticamente workspace:* por la versión real del paquete (por ejemplo ^0.1.0) antes de subir el package.json. Durante el desarrollo, sin embargo, todo va directo al código local.
Hoisting de dependencias y phantom dependencies#
Aquí es donde los gestores de paquetes difieren de forma importante, y donde se esconde uno de los bugs más difíciles de rastrear del ecosistema.
Con npm y yarn clásico, el node_modules es plano: las dependencias de tus dependencias flotan hacia arriba, a node_modules/ raíz. Si packages/ui depende de react, React aparece en node_modules/react en la raíz del monorepo, accesible desde cualquier paquete. Esto se llama hoisting (que en este contexto significa subir dependencias a un nivel común para compartirlas, distinto del hoisting de variables en JavaScript que ya conoces).
El problema: esa accesibilidad no es intencional desde el punto de vista del código. Si apps/web importa React directamente en su código pero no lo declara en su package.json, funciona porque React está en node_modules/ por culpa de otro paquete que sí lo declaró. Eso es una phantom dependency (dependencia fantasma): usas algo que no declaraste, y funciona por accidente.
El bug llega cuando alguien más instala el proyecto, o cuando el paquete que arrastraba React cambia de versión y deja de incluirlo. Tu código rompe sin que hayas tocado nada. Y lo peor: sin error inmediato. Simplemente deja de funcionar en un entorno donde no lo esperabas.
pnpm resuelve esto de raíz. Su modelo de node_modules es estricto: usa un content-addressable store global donde cada versión de cada paquete se guarda una sola vez, y enlaza a ella con symlinks. Cada workspace solo tiene acceso en su node_modules/ a lo que declaró explícitamente en su package.json. Si apps/web no declara react, no puede importarlo, aunque React esté instalado para otro paquete del monorepo.
El resultado práctico: con pnpm, el test de “¿funciona en la máquina de otra persona?” pasa de verdad, porque el grafo de dependencias es explícito y verificado. No hay dependencias invisibles que funcionen por casualidad.
Catálogos de pnpm#
Cuando tienes varios paquetes en un monorepo que comparten dependencias externas (TypeScript, React, la versión de un linter), es tentador que cada package.json gestione su versión de forma independiente. El problema: con el tiempo divergen sin que nadie se dé cuenta. packages/core usa TypeScript ^5.4 y packages/ui usa ^5.7. El CI pasa porque cada paquete instala su versión, pero el comportamiento difiere entre paquetes.
Los catálogos de pnpm resuelven esto. En pnpm-workspace.yaml añades una sección catalog: con las versiones canónicas de las dependencias compartidas:
packages:
- "apps/*"
- "packages/*"
catalog:
typescript: "^5.8.3"
react: "^19.1.0"
"@types/react": "^19.1.8"En cada package.json del monorepo, en lugar de poner la versión, usas catalog::
{
"name": "@team-builder/core",
"devDependencies": {
"typescript": "catalog:"
}
}pnpm lee el catálogo y resuelve catalog: a la versión declarada en pnpm-workspace.yaml. Cuando quieres actualizar TypeScript en todo el monorepo, cambias la versión en un solo lugar: el catálogo. Todos los paquetes recogen el cambio automáticamente en el siguiente pnpm install.
Monorepo no es monolito, ni micro-frontend#
Hay dos confusiones frecuentes que conviene aclarar de forma explícita porque llevan a decisiones de arquitectura incorrectas.
Monorepo no es monolito. Un monolito es una decisión de runtime: una aplicación que se despliega y ejecuta como una sola unidad. Un monorepo es una decisión de organización del código: dónde vive el código fuente. Puedes tener un monorepo que produce tres servicios completamente independientes, cada uno con su propio proceso y su propio despliegue. También puedes tener tres repositorios separados que todos juntos forman un único monolito. Son ejes distintos.
Monorepo no implica micro-frontends, ni al revés. Los micro-frontends son una arquitectura de runtime donde la UI se compone a partir de fragmentos desplegados de forma independiente por equipos distintos, integrados en el navegador mediante Module Federation, iframes, web components u otros mecanismos. El monorepo es una decisión de repo. Puedes tener un monorepo que produce una única SPA, o tres repos separados que producen tres micro-frontends. Lo que sí es cierto es que muchos equipos que adoptan micro-frontends también usan un monorepo porque les facilita compartir componentes y tipos, pero uno no requiere el otro.
La confusión suele llevar a rechazar monorepos con el argumento de “no queremos un monolito”, o a adoptar micro-frontends creyendo que eso implica múltiples repos. Separar los ejes —repo vs runtime— te permite tomar cada decisión con sus propios criterios.
Comprueba lo que sabes#
Pregunta 1 de 8
¿Qué ventaja concreta ofrece un monorepo frente a varios repositorios separados (polyrepo) cuando cambias la API de un paquete compartido?
Tu turno#
El ejercicio es una reestructuración real: partes del mini Team Builder de starter/,
lo reorganizas en workspaces y compruebas que pnpm install lo resuelve todo. Las
soluciones muestran los ficheros de configuración clave de cada tier con sus comentarios.
Hazlo primero por tu cuenta y léelas después.
Ejercicio · hazlo en local
Reestructurar el Team Builder en workspaces
Tienes el Team Builder como un proyecto de un solo paquete en `exercises/dificil-escala/monorepos-y-workspaces/starter/`. Tu tarea: convertirlo en un monorepo con workspaces de pnpm, extrayendo la lógica a `packages/core`, la UI a `packages/ui`, y haciendo que `apps/web` los consuma con `workspace:*`. Las soluciones muestran los ficheros de configuración clave de cada tier con sus comentarios. Hazlo primero por tu cuenta y léelas después: la comparación es donde está el aprendizaje.
Paso 1: Se instala y arranca el workspace
- Creas `pnpm-workspace.yaml` en la raíz con los globs `apps/*` y `packages/*`.
- Cada carpeta (`packages/core`, `packages/ui`, `apps/web`) tiene su `package.json` con `name` y `version`.
- `apps/web` declara `@team-builder/core` y `@team-builder/ui` en `dependencies` con `workspace:*`.
- `pnpm install` termina sin error y se crean los symlinks en `node_modules/@team-builder/`.
- La app arranca con `pnpm --filter @team-builder/web dev`.
Paso 2: Dependencias en el paquete correcto, sin phantom dependencies
- `packages/core` no tiene dependencias de UI en su `package.json`.
- `packages/ui` declara explícitamente `@team-builder/core` con `workspace:*` en su propio `package.json`. No depende de que otro paquete lo haya instalado.
- `apps/web` importa solo de los paquetes que ha declarado en `dependencies`; no hace imports relativos a rutas internas de otros paquetes.
- Ningún paquete usa código de otro sin haberlo declarado.
Paso 3: Catálogo para versiones compartidas y comentarios de porqué
- `pnpm-workspace.yaml` incluye una sección `catalog:` con las versiones de las dependencias externas compartidas (TypeScript, por ejemplo).
- Los `package.json` de los paquetes que usan esas deps referencian `catalog:` en lugar de un rango de versión concreto.
- Los ficheros de config incluyen comentarios explicando por qué `workspace:*` en lugar de un número de versión, qué resuelve el catálogo y qué diferencia hay entre el modelo de symlinks de pnpm y el hoisting plano de npm.
Cómo hacerlo en local
Clona el repositorio del curso y entra en la carpeta del ejercicio. La solución no es un fichero suelto, sino la reestructuración del proyecto en apps/ y packages/ más la config del workspace: sigue el README y compara con las soluciones de cada tier.
git clone <repo>
cd exercises/dificil-escala/monorepos-y-workspaces
# reestructura el mini Team Builder en apps/web + packages/{core,ui} y crea pnpm-workspace.yaml
pnpm install
pnpm --filter @team-builder/web dev Ver soluciones
packages:
- "apps/*"
- "packages/*" Por qué este nivel
- El `pnpm-workspace.yaml` con los dos globs es el punto de partida: sin él, pnpm no sabe que hay varios paquetes.
- Los `package.json` de cada carpeta tienen `name` y `version`, suficiente para que pnpm los reconozca como workspaces y cree los symlinks.
- Su límite: `packages/ui` podría estar usando `@team-builder/core` sin haberlo declarado en su propio `package.json` — funciona por accidente, es una phantom dependency en potencia.
packages:
- "apps/*"
- "packages/*" Por qué es mejor que el anterior
- La diferencia clave respecto al tier anterior está en los `package.json` de cada paquete: cada uno declara explícitamente lo que usa.
- `packages/ui` tiene `@team-builder/core` en su `dependencies` con `workspace:*`. No se fía de que otro paquete lo haya instalado.
- Eliminar las phantom dependencies hace el grafo de dependencias explícito: cualquier herramienta (Nx, Turbo, changesets) puede leerlo y saber qué depende de qué.
packages:
- "apps/*"
- "packages/*"
catalog:
typescript: "^5.8.3" Por qué es mejor que el anterior
- La sección `catalog:` centraliza la versión de TypeScript. Todos los paquetes que lo necesiten referencian `catalog:` y pnpm garantiza que usan la misma versión.
- Sin catálogos, es común que `packages/core` use TypeScript `^5.4` y `packages/ui` use `^5.7` sin que nadie se dé cuenta. Con el tiempo el desfase crece.
- Los comentarios en los ficheros de config convierten la decisión técnica en documentación: quien llegue después entiende el porqué sin tener que buscar en el historial de Git.