La promesa del monorepo suena obvia: si el backend y los clientes hablan del mismo dominio, que compartan el código que lo describe. Un cambio en un contrato rompe el build de quien lo consume, en el mismo commit, antes de desplegar.
Funciona. Pero hay una distinción que casi nadie hace explícita y que decide si el monorepo te ayuda o te explota a las tres semanas: la diferencia entre compartir tipos y compartir valores.
La estructura#
apps/
├── backend/ NestJS
├── admin/ Vite + React
├── portal/ Next.js
└── mobile/ Expo
packages/
├── api-contracts/ DTOs, enums, códigos de error ← el contrato
├── shared-kernel/ Result, Money, helpers de fecha
├── ui-kit/ design system web
└── mobile-kit/ design system nativo
api-contracts es lo que en DDD se llama Published Language: el vocabulario
que el backend expone y que los clientes consumen. No contiene lógica de negocio;
contiene la forma de los datos que cruzan la frontera.
Los tipos son gratis#
import type { ExpenseResponseDto } from '@app/api-contracts';
Esto no tiene riesgo alguno. TypeScript borra los tipos al compilar: en el JavaScript resultante ese import no existe. Da igual cómo empaquete cada aplicación, da igual si el paquete está transpilado o en fuente.
Compartir tipos entre cuatro aplicaciones es la parte fácil, y ya paga el monorepo por sí sola: renombrar un campo del DTO rompe el build de los tres clientes en el acto.
Los valores no#
import { TREASURY_ERROR_CODES } from '@app/api-contracts';
Esto es otra cosa. TREASURY_ERROR_CODES es un objeto que tiene que existir
en tiempo de ejecución, así que el import sobrevive a la compilación y ahora
depende de cómo empaquete cada aplicación.
Y ahí las cuatro no se comportan igual:
| Aplicación | Empaquetador | Qué hace con el paquete |
|---|---|---|
| admin, portal | Vite / Next | Lo empaqueta desde la fuente. Funciona |
| mobile | Metro | Lo empaqueta desde TypeScript. Funciona |
| backend | webpack de Nest | Lo externaliza: espera encontrarlo en node_modules en tiempo de ejecución |
El backend de NestJS empaqueta a un solo dist/main.js y externaliza las
dependencias del workspace salvo las que estén en una lista explícita. Si
importas un valor de un paquete no incluido en esa lista:
tsc --noEmitpasa. Los tipos están bien.- Los tests pasan. Jest resuelve los paquetes desde la fuente.
nest buildpasa. Solo empaqueta.- Y el proceso revienta al arrancar con
ERR_MODULE_NOT_FOUND.
Todo verde, y el backend no levanta. Es el fallo más caro que he tenido en un monorepo, porque los tres gates que deberían detectarlo miran a otro lado.
Cómo lo resuelvo: espejo con test de paridad#
Los valores que el backend necesita en runtime viven en su dominio, y
api-contracts mantiene una copia para los clientes:
// apps/backend/src/modules/treasury/domain/treasury.errors.ts
export const TREASURY_ERROR_CODES = {
INVALID_INPUT: 'TREASURY_INVALID_INPUT',
EXPENSE_NOT_FOUND: 'TREASURY_EXPENSE_NOT_FOUND',
CURRENCY_MISMATCH: 'TREASURY_CURRENCY_MISMATCH',
} as const;
// packages/api-contracts/src/treasury/errors.ts — el mismo set, para clientes
export const TREASURY_ERROR_CODES = { /* … */ } as const;
Duplicar código así normalmente es un olor. Aquí es deliberado, y lo que lo vuelve seguro es que la duplicación está vigilada:
// Falla el build si los dos se separan
import { TREASURY_ERROR_CODES as domain } from './treasury.errors';
import { TREASURY_ERROR_CODES as published } from '@app/api-contracts';
it('el dominio y el contrato publicado no divergen', () => {
expect(domain).toEqual(published);
});
Un test de siete líneas convierte una duplicación peligrosa en una duplicación aburrida. Si alguien añade un código en un sitio y no en el otro, se entera en segundos y no en producción.
Metro tiene sus propias reglas#
La app móvil consume los paquetes como TypeScript fuente, no compilado. Eso trae una regla que hay que respetar en todos los paquetes compartidos:
Ningún punto de entrada compartido puede tener efectos secundarios de navegador: nada de
window,documentolocalStorageal importar.
Un helper que al cargarse mira window.matchMedia funciona perfecto en las tres
webs y rompe el empaquetado nativo. Y el error que da no menciona window: dice
que no encuentra un módulo, o revienta en un punto que no tiene nada que ver.
Qué hace Turborepo y qué no#
Turborepo no acelera nada por sí solo: cachea tareas según lo que declares.
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
"type-check": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["^build"] }
}
}
El ^build significa "construye primero mis dependencias del workspace". Sin
eso, el type-check de una app puede correr contra una versión vieja de un
paquete y dar un falso verde.
Y la caché tiene una trampa conocida: si una tarea produce salidas que no
declaraste en outputs, Turbo cachea un resultado incompleto y la siguiente
ejecución "exitosa" no genera los archivos. Cuando algo funcione en limpio y
falle con caché, sospecha de ahí antes que de nada.
Las fronteras hay que vigilarlas#
Un monorepo facilita compartir, y esa facilidad es justamente el riesgo: nada impide que el design system importe el motor de contenido, o que una feature importe los internals de otra. Se cruza sin querer y sin aviso.
// dependency-cruiser: severidad error, no advertencia
{
name: 'ui-no-conoce-el-contenido',
severity: 'error',
from: { path: '^packages/ui' },
to: { path: '^packages/content' },
}
La regla escrita en un documento se erosiona; la regla que rompe el build, no. Es el mismo principio que aplico dentro de cada backend con la arquitectura hexagonal, subido un nivel.
Cuándo no montaría un monorepo#
Con una sola aplicación, nunca. El monorepo resuelve el problema de compartir entre varias; con una, solo añade configuración.
Cuando las aplicaciones no comparten dominio. Dos productos distintos que solo comparten que los hizo la misma gente no ganan nada estando juntos: ganan acoplamiento en el pipeline de CI.
Cuando los ciclos de despliegue son incompatibles. Si una parte despliega diez veces al día y otra una vez al trimestre con certificación de por medio, el monorepo obliga a coordinar lo que no quiere coordinarse.
Compensa cuando varias aplicaciones hablan del mismo dominio y ese dominio cambia. Que es exactamente el caso de un backend con sus clientes web y móvil: el día que un estado del flujo cambia de nombre, quieres enterarte en el build y no por un cliente que ya no puede aprobar un gasto.