wandres.dev
PNPM WORKSPACES · el monorepo básico

El grafo interno: orden topológico y compartir sin publicar

Cómo pnpm convierte las aristas workspace: en un DAG, ejecuta las tareas en orden topológico, detecta ciclos, y las dos estrategias para compartir codigo interno: consumir fuente o consumir dist.

⏱ 15 min

El valor de un monorepo no está en tener muchos paquetes juntos, sino en que unos dependan de otros: la app usa la UI, la UI usa las utilidades, las utilidades usan el cliente de logs. Cada dependencia workspace: es una arista, y el conjunto forma un grafo dirigido acíclico. pnpm no solo lo conoce: lo respeta. Construye en el orden correcto, detecta los ciclos imposibles y te deja elegir si compartes tu código como fuente o como artefacto compilado.

🎯 Al terminar esta lección sabrás
  • Ver las dependencias workspace: como un grafo dirigido acíclico (DAG).
  • Entender por qué las tareas recursivas corren en orden topológico.
  • Reconocer y romper dependencias circulares entre paquetes internos.
  • Elegir entre compartir código como fuente o como dist compilado.

El grafo interno como DAG

Cuando @acme/web declara "@acme/ui": "workspace:*" y @acme/ui declara "@acme/utils": "workspace:*", has dibujado dos aristas de un grafo. pnpm materializa ese grafo en el disco mediante enlaces simbólicos y lo carga en memoria para cualquier operación que dependa del orden. Puedes inspeccionarlo:

pnpm list -r --depth -1        # lista todos los paquetes del workspace
pnpm why @acme/utils           # quien depende de utils, y por que
pnpm list --filter @acme/web   # el arbol de un paquete concreto

pnpm list -r no es cosmético: es la forma de auditar el grafo cuando algo no cuadra —una dependencia que creías interna resulta venir del registro, o un paquete que esperabas como hoja arrastra media docena de aristas—. Y pnpm why <pkg> responde la pregunta inversa: por qué está aquí este paquete, rastreando todas las cadenas que lo introducen.

La propiedad crítica es que el grafo debe ser acíclico: si A depende de B y B de A, no existe un orden en que construir uno antes que el otro. pnpm detecta esos ciclos y los reporta; algunos comandos fallan directamente, porque un ciclo hace irresoluble la pregunta “¿qué construyo primero?”.

⚠️
Un ciclo es un error de diseño, no de configuración

Cuando pnpm reporta una dependencia circular, la tentación es buscar una bandera que lo silencie. No hay ninguna que arregle el fondo: un ciclo entre A y B significa que las dos piezas son en realidad una sola, o que falta un tercer paquete C con lo común del que ambas dependan. Romper ciclos extrayendo ese núcleo compartido es una de las refactorizaciones más frecuentes y sanas al hacer crecer un monorepo.

Orden topológico: por qué B antes que A

Cuando lanzas una tarea recursiva —lo verás a fondo en el nivel 7.5— pnpm no visita los paquetes en orden alfabético ni en el orden del pnpm-workspace.yaml, sino en orden topológico: un paquete se procesa solo después de que todas sus dependencias internas ya se procesaron.

flowchart TD
utils[acme utils] --> ui[acme ui]
utils --> api[acme api]
ui --> web[acme web]
api --> web
L1[Ola 1: utils] --> L2[Ola 2: ui y api en paralelo]
L2 --> L3[Ola 3: web]
style L1 fill:#f9e2af,color:#11111b
style L2 fill:#a6e3a1,color:#11111b
style L3 fill:#89b4fa,color:#11111b

La razón es de correctitud, no de estética: si @acme/ui compila su TypeScript a dist/ y @acme/web importa desde ese dist/, entonces la UI tiene que compilarse antes que la web, o la web importaría un artefacto inexistente o rancio. El orden topológico garantiza que cuando un paquete se construye, todo aquello de lo que depende ya está listo. Los paquetes que no dependen entre sí —ui y api arriba— se procesan en paralelo dentro de la misma “ola”, exprimiendo los núcleos disponibles sin violar ninguna precedencia.

Esta estructura de olas tiene una consecuencia de rendimiento medible: el tiempo de un build recursivo no es la suma de los tiempos de cada paquete, sino la suma de las olas —la longitud del camino crítico del grafo—. Ensanchar el grafo, con muchos paquetes independientes, paraleliza bien; alargarlo, con cadenas largas de dependencias, lo serializa. Por eso, a escala, aplanar la profundidad del grafo importa tanto como acelerar cada compilación individual.

pnpm -r run build                              # respeta las olas del grafo
pnpm -r --workspace-concurrency=1 run build    # serializa, util para depurar
💡
El orden lo da el grafo, no tú

Nunca deberías codificar a mano “primero construye utils, luego ui”. Ese conocimiento ya vive en las aristas workspace:. Si añades una dependencia nueva, el orden se recalcula solo. Hardcodear secuencias de build es reintroducir a mano una información que el grafo ya tiene, y se desincroniza a la primera.

Compartir sin publicar: fuente o dist

Aquí está la decisión de diseño más jugosa del nivel. Un paquete interno se consume por symlink, pero ¿qué apunta ese symlink? Hay dos escuelas:

🏗️

Consumir dist (compilado)

El paquete tiene un paso de build que emite dist/, y su exports apunta ahí. Los consumidores importan JavaScript ya transpilado. El orden topológico es obligatorio y hay latencia de build, pero cada paquete es autónomo y publicable tal cual.

Consumir fuente (internal packages)

El paquete no compila nada: su exports apunta directo al .ts. Es el bundler de la app (Vite, el de Astro) quien transpila todo junto. Cero paso de build intermedio, HMR instantáneo entre paquetes, a cambio de que solo sirve puertas adentro del monorepo.

El patrón de internal packages —popularizado por el equipo de Vercel— usa una condición de exportación a medida para servir el .ts crudo dentro del repo mientras conserva un dist/ para cuando el paquete se publique:

{
  "name": "@acme/ui",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "development": "./src/index.ts",
      "default": "./dist/index.js"
    }
  }
}

Con la condición development activa, el consumidor lee el TypeScript fuente y no hay nada que compilar; sin ella, cae a dist/. Es la forma de tener lo mejor de ambos mundos: velocidad de desarrollo de un monorepo de fuentes y capacidad de publicar artefactos limpios.

La disciplina de tipos merece su propio mecanismo. Con project references de TypeScript, cada paquete declara de quién depende y tsc --build compila los tipos en el mismo orden topológico que pnpm usa para el código:

// apps/web/tsconfig.json
{
  "references": [
    { "path": "../../packages/ui" },
    { "path": "../../packages/utils" }
  ]
}

Así el editor entiende las fronteras internas —salta a la definición fuente, no a un .d.ts rancio— y el chequeo de tipos incremental solo revisa lo que cambió. pnpm dibuja el grafo de módulos; las references dibujan ese mismo grafo para el sistema de tipos.

El compromiso fuente-frente-a-dist se resume en dos columnas:

  • dist (compilado): autónomo y publicable, fronteras nítidas, pero paso de build por arista, latencia en el bucle interno y orden topológico obligatorio.
  • fuente (internal package): cero build intermedio y HMR entre paquetes, pero solo consumible dentro del repo y acoplado al bundler de la app que lo transpila.

La regla de oro: si el paquete algún día saldrá al registro, dale dist desde el principio, porque migrar de fuente a compilado con consumidores ya escritos es doloroso. Si es puramente interno de una app, la fuente te ahorra un paso de build en cada guardado.

📝
publint valida la frontera

Antes de publicar un paquete que se consumía por dist, publint comprueba que su exports, sus types y sus formatos (ESM y CJS) sean correctos. Es la red que evita subir al registro un paquete que compilaba dentro del monorepo pero resulta inconsumible fuera de él.

El enlace simbólico funciona casi siempre, pero rompe cuando dos paquetes necesitan resoluciones distintas de una misma dependencia peer —el caso clásico es React con dos versiones, o un plugin que exige verse a sí mismo una sola vez. Para eso pnpm ofrece las dependencias inyectadas: en vez de un symlink, copia físicamente el paquete dentro del consumidor, dándole su propio árbol de peers.

{
  "dependencies": { "@acme/ui": "workspace:*" },
  "dependenciesMeta": {
    "@acme/ui": { "injected": true }
  }
}

Es la válvula de escape para cuando la semántica de “un solo enlace compartido” choca con la semántica de peers que exige NPM. Se usa con cuidado, porque copiar reintroduce parte del coste que el symlink ahorraba. El síntoma que delata la necesidad de inyectar suele ser inconfundible: un error de tipo invalid hook call o dos instancias de React cargadas a la vez, señal de que el enlace hizo que un peer se resolviera por duplicado.

El grafo es a la vez correctitud y estrategia

El grafo interno de un monorepo es dos cosas superpuestas. Como estructura de correctitud, impone un orden parcial: el orden topológico no es una optimización, es la única forma de que un artefacto compilado se construya después de aquello de lo que depende, y su exigencia de aciclicidad es una verdad matemática, no una regla de estilo —un ciclo vuelve la pregunta del build formalmente irresoluble. Pero como estructura de estrategia, el mismo grafo es donde decides el gran compromiso del monorepo: consumir fuente o consumir dist. Consumir dist te da paquetes autónomos, publicables y con fronteras nítidas, al precio de un paso de compilación en cada arista y la latencia que arrastra. Consumir fuente colapsa todas las fronteras en un solo acto de bundling de la app, regalándote HMR entre paquetes y cero build intermedio, al precio de renunciar a publicar y de acoplar tus paquetes al toolchain del consumidor. No hay respuesta universal: una librería de diseño destinada a npm quiere dist; una app monolítica dividida en paquetes por higiene quiere fuente. Lo que un ingeniero senior entiende es que esta elección no se hace paquete a paquete al azar, sino como política del repo, porque determina la topología de builds, la velocidad del bucle interno de desarrollo y si mañana podrás extraer un paquete al mundo exterior sin reescribirlo entero.

⚔️ Recorre y rompe el grafo
  1. Con pnpm list -r y pnpm why, dibuja el grafo real de un workspace y localiza sus hojas (sin dependencias internas) y su raíz.
  2. Introduce a propósito un ciclo: haz que utils dependa de ui y ui de utils. Ejecuta una tarea recursiva y lee el error de dependencia circular.
  3. Deshaz el ciclo y añade un paquete nuevo que dependa de dos existentes; predice en qué “ola” del orden topológico se construirá.
  4. Convierte un paquete al patrón de internal package con la condición development en exports y comprueba que la app lo consume sin paso de build.
  5. Investiga dependenciesMeta.injected: crea un caso con un peer conflictivo y observa cómo pnpm copia en vez de enlazar.