Saltar al contenido
Ver todas las notas
monorepoturborepotypescriptarquitectura

Un monorepo con web, móvil y backend compartiendo dominio

Compartir tipos entre backend y clientes es gratis. Compartir valores —enums, códigos de error, constantes— no lo es, y ahí es donde los monorepos se rompen en producción con el build en verde.

· 11 min de lectura

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ónEmpaquetadorQué hace con el paquete
admin, portalVite / NextLo empaqueta desde la fuente. Funciona
mobileMetroLo empaqueta desde TypeScript. Funciona
backendwebpack de NestLo 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 --noEmit pasa. Los tipos están bien.
  • Los tests pasan. Jest resuelve los paquetes desde la fuente.
  • nest build pasa. 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, document o localStorage al 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.

¿Tienes un sistema con este tipo de problemas?

Cuéntame en qué estás y te digo cómo lo abordaría yo.

Hablemos de tu sistema