Cómo estructurar un monorepo .NET + Next.js sin que se vuelva un desastre
Un monorepo políglota funciona cuando cada cadena de herramientas es dueña de su propio subárbol y nada cruza el límite salvo un contrato HTTP y un puñado de archivos de configuración. Se vuelve un desastre cuando alguien intenta hacer que .NET y Node compartan un sistema de compilación.
La estructura
apps/
marketing-site/ Next.js: sitio público, SEO, precios
web-app/ React: área de cliente autenticada
admin-panel/ React: operación interna
backend/
api/
src/ Solución .NET: Api, Application, Domain, Infrastructure
tests/ xUnit
config/
branding.json product.json pricing.json features.json
docs/
scripts/
package.json npm workspaces, solo en la raíz
Dos cadenas de herramientas, dos raíces. npm nunca mira dentro de backend/; dotnet nunca mira dentro de apps/. El directorio config/ es lo único que ambos lados leen.
Regla 1: un solo lockfile, en la raíz
npm workspaces pone un único package-lock.json en la raíz del repositorio y ninguno en las apps individuales. Esto no es cosmético. Los lockfiles por app producen tres resoluciones distintas de la misma dependencia transitiva, tres conjuntos de alertas de seguridad para un mismo aviso, y una actualización de dependencia que hay que aplicar tres veces.
Agregá los lockfiles por app al .gitignore para que un npm install despistado dentro de apps/web-app no pueda commitear uno por accidente. Si tu plataforma de despliegue construye una sola app de forma aislada, hacé que instale desde el lockfile de la raíz en vez de dejar que genere el suyo.
El fijado de versiones también va en la raíz, con overrides en el package.json raíz, para que una versión forzada aplique en todas partes de una sola vez.
Regla 2: el límite entre los stacks es HTTP, y nada más
La tentación es un paquete de tipos compartidos, generado desde los records de C# e importado por TypeScript. Resistila hasta haber sentido el dolor que lo justifique, porque acopla los ciclos de despliegue de dos cosas que se despliegan de forma independiente.
Lo que funciona en la práctica: la API publica un documento OpenAPI, y cada frontend mantiene un tipo de respuesta pequeño, escrito a mano, cerca del código que llama al endpoint. Se duplican unas pocas declaraciones de interfaz. A cambio, los frontends no tienen dependencia de compilación con el backend, y una refactorización del backend no puede romper la compilación de un frontend.
El corolario es que ningún frontend importa a otro frontend. Tres apps que comparten un lenguaje visual van a querer compartir componentes, y en el momento en que web-app importa de admin-panel, ya no tenés tres apps. Tenés una app con tres puntos de entrada y un radio de daño compartido. O duplicás el componente, o lo promovés a un paquete real del workspace con su propio package.json.
Regla 3: la configuración es dato, y vive en un solo lugar
El nombre del producto, los colores de marca, los nombres de planes y las claves de funcionalidades aparecen en el sitio de marketing, en la app de clientes, en el panel de administración, en la API y en la semilla de la base de datos. Cinco copias son cinco lugares para olvidarse de uno.
Ponelos en config/*.json en la raíz y hacé que todos los consumidores lean de ahí: los frontends importan el JSON directamente, la API lo lee al arrancar, el script de semilla lo genera a partir de él. Renombrar el producto pasa a ser un diff de una línea en vez de una búsqueda por todo el repositorio.
La prueba de que esto funciona: rebrandear el producto entero debería tocar exactamente un archivo.
Regla 4: la CI corre por proyecto, no todo o nada
Un monorepo donde cada push corre la matriz completa, es decir tres compilaciones de frontend, dotnet test y toda la suite end-to-end, entrena al equipo a ignorar la CI, porque un error de tipeo en un título de marketing tarda doce minutos en mergearse.
Dividila por ruta:
- Cambios bajo
apps/*→ lint, chequeo de tipos y tests unitarios de esa app - Cambios bajo
backend/→dotnet buildydotnet test - Cambios en
config/o en cualquier cosa compartida → todo - Tests end-to-end → en la rama principal y bajo demanda, no en cada pull request
La suite end-to-end es la que sí debe estar acotada. Necesita una base de datos, una API corriendo y frontends compilados; es lo más lento y lo más frágil que tenés. Correla donde una falla merezca la atención de una persona, no en cada push de borrador.
Qué sale mal de verdad
Tres patrones de falla explican la mayor parte del dolor de un monorepo políglota.
El primero es un Dockerfile compartido. La API .NET necesita una imagen con el SDK y un publish multi-etapa. Los frontends necesitan Node, y en la mayoría de las plataformas no necesitan contenedor en absoluto. Un solo Dockerfile para ambos significa que cada cambio en el backend reconstruye las capas del frontend. Dale a la API su propio Dockerfile y dejá que los frontends se desplieguen como salida estática o serverless.
El segundo es un entorno local que exige todo. Si trabajar en el sitio de marketing implica levantar Postgres, la API y otros dos frontends, la gente va a evitar el monorepo. Cada app debería correr sola contra una URL de API configurada, con un único docker compose up que levante la base cuando de verdad haga falta el backend.
El tercero es una CI por rutas que dispara de menos en silencio. Las reglas de arriba solo son seguras si el disparador "compartido" está realmente completo. Cuando cambia config/ y la CI corre una sola app, publicás un cambio de nombre en dos de tres frontends. Mantené la lista de rutas compartidas lo bastante corta como para auditarla, y ante la duda, corré todo.
Por qué un monorepo, después de todo
La recompensa es el cambio atómico. Agregar un campo a la API, exponerlo en el panel de administración y mostrarlo en el sitio de marketing es un commit, una revisión, un despliegue, un revert. Repartido en cuatro repositorios, son cuatro pull requests en un orden obligatorio, y una mala tarde si hay que deshacerlas.
Eso se paga con disciplina de compilación. Las cuatro reglas de arriba son el pago. Si las salteás, obtenés el acoplamiento de un monolito junto con la complejidad de herramientas de los microservicios.