Monorepo y proyectos grandes: muchos Workers, un repositorio
Organizar varios Workers en un mismo repositorio: un manifiesto por unidad desplegable, configuración y tipos compartidos sin herencia mágica, desarrollo local con varios Workers a la vez, y pipelines que detectan qué cambió para desplegar solo eso.
Ningún sistema serio termina siendo un solo Worker. Aparece la API, el frontend con renderizado en el servidor, el consumidor de la cola, el cron que reconcilia, el servicio de imágenes. Ponerlos todos en un repositorio es la decisión correcta la mayoría de las veces, porque mantiene un único historial, un único conjunto de tipos compartidos y la posibilidad de cambiar un contrato y a sus dos lados en el mismo commit. Pero un monorepo mal montado paga esa comodidad con un peaje diario: cada push reconstruye todo, cada despliegue publica servicios que nadie tocó, y la configuración se duplica hasta que nadie sabe por qué un Worker tiene una fecha de compatibilidad distinta de su vecino. Esta lección trata de la disciplina que hace que un repositorio con doce Workers se sienta ligero, y su núcleo es una idea sencilla: saber con precisión qué cambió.
- Estructurar un repositorio con un manifiesto de Wrangler por unidad desplegable.
- Compartir código, tipos y convenciones de configuración sin depender de herencia inexistente.
- Levantar varios Workers a la vez en desarrollo y probar los service bindings de verdad.
- Construir un pipeline que detecte los proyectos afectados y despliegue solo esos.
Un manifiesto por unidad desplegable
La unidad de despliegue en Cloudflare es el Worker, así que la estructura del repositorio debe reflejarla: cada Worker vive en su carpeta, con su wrangler.jsonc, su package.json y su código. El código común se extrae a paquetes del workspace que ningún manifiesto conoce, porque son dependencias normales que el bundler resuelve.
apps/
api/ wrangler.jsonc package.json src/index.ts
web/ wrangler.jsonc package.json src/index.ts
cola-emails/ wrangler.jsonc package.json src/index.ts
packages/
dominio/ tipos y logica de negocio pura
contratos/ esquemas compartidos entre productor y consumidor
pnpm-workspace.yaml
La pregunta que define la calidad de este diseño no es cuántos Workers tienes sino dónde cortas. Un buen límite coincide con algo que cambia a su propio ritmo, se despliega por separado y falla sin arrastrar a los demás. Cortar por capas técnicas —un Worker para la validación, otro para el acceso a datos— produce servicios acoplados que siempre se despliegan juntos y que solo han añadido latencia de red donde antes había una llamada a función. Cortar demasiado tarde produce el Worker monolito, cuyo bundle crece hasta rozar los límites de tamaño y cuyo despliegue es siempre arriesgado porque toca todo.
Antes de crear un Worker nuevo, responde a tres preguntas: ¿se despliega en momentos distintos que el resto?, ¿escala en una dimensión distinta?, ¿puede caerse sin que caiga lo demás? Si las tres respuestas son negativas, lo que tienes es un módulo, y un módulo se importa. La red entre dos Workers de Cloudflare es rapidísima, pero no es gratis en complejidad: añade un contrato que versionar, un fallo parcial que manejar y un despliegue más que coordinar.
Configuración compartida sin herencia mágica
Aquí conviene ser directo: los manifiestos de Wrangler no se extienden unos de otros. No hay una clave que importe la configuración del padre, y buscarla lleva a inventar generadores frágiles. Lo que sí funciona es aceptar la duplicación deliberada de lo poco que de verdad debe coincidir y automatizar su verificación.
| Qué compartir | Cómo |
|---|---|
| Fecha y flags de compatibilidad | Duplicados en cada manifiesto, con un test o un script de lint que falle si divergen |
Tipos de env |
wrangler types por Worker, generando su propio archivo de declaración junto a su código |
| Lógica de negocio | Un paquete del workspace importado como dependencia normal |
| Esquemas de mensajes y respuestas | Un paquete de contratos que importan a la vez el productor y el consumidor |
| Convenciones de nombres y entornos | Documentadas y verificadas en CI, no heredadas |
La deriva de la fecha de compatibilidad es el problema silencioso más común de un monorepo grande: cada Worker se creó en un momento distinto, nadie las actualiza a la vez, y al cabo de un año tienes servicios corriendo con semánticas de runtime diferentes que se comunican entre sí. Un script de veinte líneas que lea todos los manifiestos y compare ese campo cuesta menos que el primer incidente que causa.
Para los tipos, la regla es que cada Worker genere los suyos con wrangler types a partir de su propio manifiesto. Compartir un único tipo de env entre Workers con bindings distintos es tentador y es un error: convierte en compilable un código que accede a un binding que ese Worker no tiene, y el fallo aparece en producción como una propiedad indefinida.
flowchart TD R[repositorio] --> A[apps api] R --> W[apps web] R --> Q[apps cola emails] R --> P[packages dominio y contratos] P --> A P --> W P --> Q W -->|service binding| A A -->|queue| Q style P fill:#a6e3a1,color:#11111b
Desplegar solo lo que cambió
Este es el corazón operativo del monorepo. La estrategia tiene dos niveles y conviene entender que resuelven cosas distintas.
El primero filtra a la entrada: el pipeline decide qué proyectos se ven afectados por el diff antes de construir nada. Con una herramienta de orquestación es directo, porque conoce el grafo de dependencias y sabe que un cambio en el paquete de contratos afecta al productor y al consumidor aunque sus carpetas no se hayan tocado.
# Solo los proyectos afectados por el diff contra la rama principal
pnpm turbo run deploy --filter="...[origin/main]"
El segundo nivel filtra por rutas explícitas, y es lo que ofrecen tanto los disparadores de GitHub Actions como las rutas vigiladas de Workers Builds. Es más simple y más frágil: solo ve carpetas, así que un cambio en un paquete compartido no dispara los Workers que dependen de él salvo que incluyas esa ruta en cada filtro.
jobs:
detectar:
runs-on: ubuntu-latest
outputs:
afectados: ${{ steps.filtro.outputs.changes }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: filtro
uses: dorny/paths-filter@v3
with:
filters: |
api:
- 'apps/api/**'
- 'packages/**'
web:
- 'apps/web/**'
- 'packages/**'
desplegar:
needs: detectar
if: needs.detectar.outputs.afectados != '[]'
strategy:
matrix:
proyecto: ${{ fromJSON(needs.detectar.outputs.afectados) }}
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: npx wrangler deploy
working-directory: apps/${{ matrix.proyecto }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
La matriz dinámica es el patrón que hace esto escalable: la lista de proyectos afectados se calcula una vez y se convierte en tantos jobs paralelos como haga falta. Con fetch-depth en cero para que el diff tenga historia suficiente, y con la precaución de incluir siempre los paquetes compartidos en cada filtro.
Un filtro demasiado agresivo produce el fallo más difícil de diagnosticar del monorepo: un cambio en un contrato compartido se despliega en el productor pero no en el consumidor, porque el filtro de este último no incluía esa ruta. El sistema queda temporalmente inconsistente y el error aparece lejos de la causa. Ante la duda, incluye de más: el coste de un despliegue innecesario es un minuto de pipeline y una versión nueva sin cambios funcionales; el coste de uno omitido es un incidente. Y diseña los contratos para que un despliegue parcial sea tolerable, porque tarde o temprano ocurrirá.
Desarrollo local y contratos entre Workers
Un monorepo que solo se puede probar desplegando no sirve. Wrangler permite levantar varios Workers en una única sesión pasando varios manifiestos, y en ese modo los service bindings entre ellos se resuelven contra los procesos locales en lugar de contra los desplegados.
wrangler dev -c apps/web/wrangler.jsonc -c apps/api/wrangler.jsonc
Eso convierte la composición entre servicios en algo comprobable en tu máquina, con recarga en caliente de ambos lados. Es la diferencia entre poder cambiar un contrato y su consumidor en el mismo commit con confianza, o descubrir el desajuste en staging media hora después.
Los contratos entre Workers del mismo repositorio tienen una trampa que conviene nombrar. Que compartan repositorio y tipos crea la ilusión de que se despliegan atómicamente, y no es así: son artefactos independientes que se publican en momentos distintos, con segundos u horas de diferencia. La misma disciplina que aplicarías entre servicios de equipos distintos —añadir campos antes de exigirlos, tolerar los desconocidos, retirar lo viejo en un despliegue posterior— sigue siendo obligatoria. El monorepo elimina el problema de coordinar el código, no el de coordinar el tiempo.
La razón por la que los equipos adoptan monorepos casi nunca es la que declaran. Se dice que es para simplificar el despliegue, y es justo al revés: desplegar desde un monorepo es estrictamente más complicado que desde repositorios separados, porque tienes que resolver el problema de qué cambió, que en repositorios separados no existe. Lo que un monorepo te da de verdad es otra cosa, mucho más valiosa y más difícil de conseguir: en cualquier instante existe un commit que describe el estado completo y coherente de todo tu sistema. Puedes hacer un cambio que atraviese cinco servicios y revisarlo como una sola unidad. Puedes cambiar el nombre de un campo en el paquete de contratos y que el compilador te enseñe, de una vez, los siete sitios que se rompen. Puedes hacer una bisección sobre el historial de un sistema entero en lugar de sobre el de una pieza. Esa capacidad —razonar sobre el sistema como un todo— es lo que compras, y el precio es exactamente el trabajo de tooling que esta lección describe. Quien adopta el monorepo por comodidad y no invierte en detección de afectados, en caché y en verificación de la coherencia entre manifiestos, acaba con lo peor de ambos mundos: la complejidad del despliegue múltiple sin el beneficio de la vista unificada, porque nadie se atreve ya a tocar los paquetes compartidos. La pregunta de diseño no es cuántos repositorios quieres, es dónde prefieres pagar la coordinación: en el momento del cambio, revisando un diff grande que abarca todo, o en el momento del incidente, reconstruyendo qué versión de cada servicio estaba viva cuando falló.
- Reorganiza un proyecto con dos Workers en carpetas separadas, cada uno con su manifiesto, y extrae a un paquete compartido el tipo de la respuesta que se intercambian.
- Levanta ambos con una sola sesión de
wrangler devpasando los dos manifiestos y comprueba que el service binding resuelve contra el proceso local. - Escribe un script que lea todos los manifiestos del repositorio y falle si sus fechas de compatibilidad no coinciden. Conéctalo al pipeline.
- Monta un job de detección de afectados con matriz dinámica y comprueba que un cambio solo en un Worker despliega uno y no dos.
- Rompe deliberadamente el filtro: cambia el paquete compartido sin incluirlo en los patrones y observa el despliegue parcial. Después arréglalo y documenta por qué el contrato debe tolerar ese estado.