30. Monorepos con Nx
TaskFlow no es una sola aplicación: es un frontend Angular, una API NestJS, entidades MikroORM, DTOs compartidos, componentes de interfaz y, con el tiempo, más servicios. Cuando cada pieza vive en su propio repositorio, el coste de coordinar cambios compartidos se come el tiempo de ingeniería. Este capítulo explica qué problema resuelve un monorepo, cómo organizarlo con Nx (apps, libs, tags, boundaries, affected y caché) y cómo aplicar esa estructura al dominio TaskFlow sin caer en microfrontends prematuros ni en acoplamientos circulares.
30.1 Qué vas a poder hacer al terminar
- Explicar con precisión qué es un monorepo, en qué se diferencia de un polyrepo y qué problemas de coordinación, versionado y CI resuelve (y cuáles no).
- Crear un workspace Nx y distinguir apps de libs, workspaces package-based e integrated, y el papel
de
project.json, generators y executors. - Diseñar una estructura de carpetas para TaskFlow (Angular + Nest + MikroORM) con librerías de tipos, DTOs, data-access y UI, y justificar cada frontera.
- Configurar tags y module boundaries para que una importación ilegal falle en lint, no en producción.
- Usar el grafo de dependencias, comandos
affectedy la caché local/remota para no reconstruir lo que no ha cambiado. - Decidir cuándo Module Federation aporta valor y cuándo es complejidad gratuita.
- Escribir un workflow de GitHub Actions con
nx affected, paralelismo y caché. - Comparar Nx con Turborepo, pnpm workspaces y Yarn de forma honesta, sin marketing.
- Reconocer los errores típicos (ciclos, app→app, libs «god», caché incorrecta) y corregirlos.
A lo largo del capítulo usamos TaskFlow: gestión de tareas con proyectos, asignaciones, estados y comentarios. El frontend es Angular; la API, NestJS; la persistencia, MikroORM sobre PostgreSQL. El monorepo debe permitir cambiar un DTO una sola vez y que tipen a la vez el cliente HTTP del frontend y los controladores del backend. Si no consigues eso, el monorepo no te está aportando su valor principal.
30.2 Qué es un monorepo y qué problema resuelve
Un monorepo (repositorio único) es un repositorio de control de versiones que contiene varios proyectos —aplicaciones, librerías, herramientas— que se desarrollan juntos, con un historial compartido y, normalmente, un pipeline único que entiende las dependencias entre ellos. Un polyrepo (o multirepo) es la estrategia contraria: un repositorio por aplicación o por equipo.
La definición importa porque mucha gente llama «monorepo» a meter dos carpetas en el mismo Git sin herramientas. Eso es un monorepo en bruto: funciona hasta que alguien cambia un tipo compartido y rompe tres aplicaciones sin enterarse. Un monorepo serio añade límites explícitos, grafo de dependencias, ejecución selectiva (affected) y, idealmente, caché reproducible.
30.2.1 El problema que el polyrepo no escala bien
Imagina TaskFlow partido en tres repos: taskflow-api, taskflow-web y
taskflow-shared. Quieres añadir el campo prioridad a una tarea. El flujo real
acaba siendo:
- Cambias el DTO en
taskflow-shared, subes versión (semver), publicas el paquete. - Actualizas la API para consumir la nueva versión, despliegas.
- Actualizas el frontend, que a menudo se queda una o dos versiones atrás «porque el PR es otro».
- Durante días conviven tres versiones del mismo contrato. Los bugs de desajuste aparecen en producción, no en CI.
Eso no es un fallo de disciplina: es el coste estructural de versionar contratos internos como si fueran APIs públicas. El monorepo elimina ese baile: un solo commit puede tocar DTO, controlador Nest y servicio Angular. El CI valida el conjunto. El atomic commit es la propiedad más valiosa.
Un polyrepo es tres solares con tres arquitectos, tres permisos de obra y tres camiones de hormigón distintos. Si el plano del ascensor cambia, hay que coordinar tres obras. Un monorepo es un único solar con varias plantas: el plano del núcleo vertical es compartido; cambiarlo afecta a todas las plantas a la vez, y el aparejador (Nx) te dice qué plantas hay que revisar. No es «más simple» en abstracto: es más barato coordinar cambios transversales.
30.2.2 Historia breve
Años 2000–2010. Google populariza (y mitifica) el monorepo a escala: un árbol enorme, herramientas internas (Piper, Blaze) y una cultura de «todo se ve». Facebook sigue un camino similar. Fuera de esas empresas, el consejo habitual sigue siendo «un repo por servicio».
2015–2017. Babel, Angular, React y otros proyectos open source consolidan monorepos con Lerna + Yarn workspaces. Aparece el patrón «paquetes versionados dentro de un solo Git».
2017 · Nx. Nrwl (fundadores del equipo original de Angular) publica Nx: primero orientado a Angular, después agnóstico. Aporta generators, executors, grafo, affected y caché. El mensaje no es «mete todo en un repo», sino «haz el repo inteligible para la máquina».
2019–2022. Turborepo (luego Vercel) y mejoras fuertes de pnpm workspaces democratizan la orquestación ligera. Bazel sigue siendo la referencia de hermeticidad en empresas muy grandes.
2023–2025. Nx distingue claramente workspaces integrated (plugins, project graph rico) y package-based (más cercanos a workspaces clásicos). La caché remota y Nx Cloud se vuelven pieza central del discurso de CI.
La lección histórica es simple: el monorepo no es una moda estética. Es una respuesta a un coste de coordinación que crece con el número de límites artificiales entre código que, en realidad, cambia junto.
30.2.3 Cuándo sí y cuándo no
| Situación | Monorepo suele ayudar | Polyrepo suele ser mejor |
|---|---|---|
| Frontend y API del mismo producto (TaskFlow) | Sí: contratos compartidos, un CI | Solo si equipos y ciclos de release son radicalmente distintos |
| Librería open source consumida por terceros | Monorepo interno + publicación selectiva | Repo propio si la comunidad y el versionado son el producto |
| Equipos sin confianza ni ownership claro | No: el monorepo amplifica el ruido | Sí: límites de acceso y ownership más claros |
| Código que no comparte tipos ni calendario | No aporta; añade fricción | Sí |
| Necesidad de secretos/ACL por equipo muy estrictos | Posible pero costoso | Más natural |
Meter código acoplado en un solo repo no lo desacopla. Sin límites de importación, tags y ownership, acabas con un big ball of mud versionado. Nx brilla cuando codificas las reglas que ya deberías tener en la cabeza.
POLYREPO MONOREPO (TaskFlow)
┌────────────┐ ┌──────────────────────────────┐
│ web.git │──npm pkg──┐ │ apps/web apps/api │
└────────────┘ │ │ libs/shared/dto │
┌────────────┐ ▼ │ libs/shared/types │
│ shared.git │──────► registry │ libs/data-access/... │
└────────────┘ ▲ │ │
┌────────────┐ │ │ un commit = cambio atómico │
│ api.git │──npm pkg──┘ │ CI conoce el grafo │
└────────────┘ └──────────────────────────────┘
30.3 Nx: instalación, workspace, apps vs libs, tags y boundaries
Nx es un sistema de build para monorepos JavaScript/TypeScript (y más allá). No sustituye a npm/pnpm/yarn como instalador de paquetes: se apoya en ellos. Su valor está en conocer el grafo de proyectos, generar estructura con plugins, ejecutar tareas (build, test, lint, serve) de forma inteligente y cachear resultados.
30.3.1 Instalación y creación del workspace
La forma recomendada hoy es crear el workspace con el CLI oficial. Elige el package manager que ya uses en el equipo; en este libro preferimos pnpm por su eficiencia de disco y sus workspaces nativos, pero npm y yarn son perfectamente válidos.
# Crear un workspace integrado orientado a aplicaciones
npx create-nx-workspace@latest taskflow --preset=apps
# Alternativa: workspace vacío para ir añadiendo plugins
# npx create-nx-workspace@latest taskflow --preset=ts
cd taskflow
# Añadir soporte Angular y Nest (plugins oficiales)
pnpm add -D @nx/angular @nx/nest @nx/js @nx/node
Tras la creación tendrás, como mínimo, un nx.json (configuración global: caché, named
inputs, target defaults), un package.json raíz y una estructura de proyectos. La CLI
nx (o pnpm exec nx) es el punto de entrada de casi todo.
npx nx graph # abre el grafo interactivo
npx nx show projects # lista proyectos
npx nx show project api # detalle de un proyecto
npx nx run api:build # ejecuta el target build de api
npx nx run-many -t test # tests de todos los proyectos
npx nx affected -t lint,test,build
30.3.2 Apps frente a libs
En la jerga de Nx (y de Angular desde hace años):
- App: artefacto desplegable. Tiene punto de entrada, se construye hacia un binario o bundle,
se sirve o se ejecuta. Ejemplos TaskFlow:
apps/web(Angular),apps/api(NestJS), quizáapps/api-e2e. - Lib: código reutilizable sin ciclo de vida de despliegue propio. Se importa desde apps u otras
libs. Ejemplos:
libs/shared/dto,libs/web/ui,libs/api/data-access-tasks.
La regla de oro: las apps orquestan; las libs encapsulan. Si una app importa otra app, has roto el modelo. Si una lib «hace de casi-app» (arranca servidor, lee variables de entorno de producción), también.
┌─────────────────────────────────────────────────────────┐
│ APPS │
│ apps/web (Angular) apps/api (NestJS) │
└──────────────┬──────────────────────────┬───────────────┘
│ importan │
▼ ▼
┌──────────────────────┐ ┌────────────────────────────┐
│ libs/web/* │ │ libs/api/* │
│ ui, feature-*, │ │ feature-*, data-access-*, │
│ data-access-* │ │ util-* │
└──────────┬───────────┘ └─────────────┬──────────────┘
│ │
└──────────────┬────────────────┘
▼
┌─────────────────────────┐
│ libs/shared/* │
│ types, dto, util │
│ (sin Angular ni Nest) │
└─────────────────────────┘
30.3.3 Tags y module boundaries
Los tags son etiquetas declarativas en cada proyecto (scope:web,
scope:api, scope:shared, type:feature, type:ui,
type:data-access, type:util). Por sí solos no hacen nada. Cobran sentido con la
regla de ESLint @nx/enforce-module-boundaries, que define qué tag puede depender de qué
otro.
{
"name": "shared-dto",
"tags": ["scope:shared", "type:util"],
"targets": {
"lint": { "executor": "@nx/eslint:lint" }
}
}
// Fragmento conceptual de la regla @nx/enforce-module-boundaries
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
{
sourceTag: 'scope:web',
onlyDependOnLibsWithTags: ['scope:web', 'scope:shared'],
},
{
sourceTag: 'scope:api',
onlyDependOnLibsWithTags: ['scope:api', 'scope:shared'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'type:feature',
onlyDependOnLibsWithTags: [
'type:feature',
'type:ui',
'type:data-access',
'type:util',
],
},
{
sourceTag: 'type:ui',
onlyDependOnLibsWithTags: ['type:ui', 'type:util'],
},
{
sourceTag: 'type:data-access',
onlyDependOnLibsWithTags: ['type:data-access', 'type:util'],
},
],
},
],
},
}
// El frontend importa código Nest / MikroORM del backend
import { Task } from '@taskflow/api/data-access-tasks';
import { EntityManager } from '@mikro-orm/core';
export class TasksBrowserService {
// Esto acopla el bundle del navegador al ORM del servidor
}
// Solo DTOs/tipos compartidos + cliente HTTP propio
import { CreateTaskDto, TaskDto } from '@taskflow/shared/dto';
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class TasksApi {
private readonly http = inject(HttpClient);
create(dto: CreateTaskDto): Observable<TaskDto> {
return this.http.post<TaskDto>('/api/tasks', dto);
}
}
libs/shared/* debe poder compilarse sin Angular ni Nest en el classpath de tipos. Si
empiezas a importar @angular/core o @nestjs/common en shared, has filtrado un
framework hacia el otro lado. Los decoradores de validación (class-validator) son un caso
límite aceptable si ambos lados los usan; las entidades MikroORM, no.
30.4 Generators y executors; project.json / package-based vs integrated
Nx separa dos conceptos que en otros sistemas se mezclan:
- Generator: código que escribe ficheros (scaffolding). Ejemplo: crear una lib Angular,
un módulo Nest, un componente. Se ejecuta con
nx g .... - Executor: código que ejecuta una tarea (build, test, serve, lint). Se declara en los
targetsde cada proyecto y se invoca connx run proyecto:target.
Esta separación es poderosa: puedes cambiar el executor de build (esbuild, webpack, vite, swc) sin cambiar cómo generas librerías, y puedes escribir generators propios que respeten las convenciones de TaskFlow.
30.4.1 Generators en la práctica
# Aplicación Angular
npx nx g @nx/angular:application web --directory=apps/web --routing --style=scss
# Aplicación NestJS
npx nx g @nx/nest:application api --directory=apps/api
# Librería de DTOs compartidos (JS/TS puro)
npx nx g @nx/js:library shared-dto --directory=libs/shared/dto --importPath=@taskflow/shared/dto
# Librería de UI Angular
npx nx g @nx/angular:library web-ui --directory=libs/web/ui --importPath=@taskflow/web/ui
# Librería data-access Nest (módulo Nest importable)
npx nx g @nx/nest:library api-data-access-tasks \
--directory=libs/api/data-access-tasks \
--importPath=@taskflow/api/data-access-tasks
El flag importPath define el alias TypeScript (paths en
tsconfig.base.json). Usa siempre un scope de organización (@taskflow/...) para
que las importaciones lean como paquetes reales, no como rutas relativas de diecisiete niveles.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@taskflow/shared/dto": ["libs/shared/dto/src/index.ts"],
"@taskflow/shared/types": ["libs/shared/types/src/index.ts"],
"@taskflow/web/ui": ["libs/web/ui/src/index.ts"],
"@taskflow/web/data-access-tasks": ["libs/web/data-access-tasks/src/index.ts"],
"@taskflow/api/data-access-tasks": ["libs/api/data-access-tasks/src/index.ts"]
}
}
}
Cada lib debe exportar solo lo estable desde src/index.ts. Importar un fichero interno
con ruta profunda (@taskflow/web/ui/src/lib/button/button.component) rompe el encapsulamiento
y hace frágiles los refactors. La regla de boundaries de Nx puede prohibir deep imports.
30.4.2 Executors y project.json
En un workspace integrated, cada proyecto declara sus targets en project.json (o en
el package.json con inferencia, según versión y plugins). Un target tipico:
{
"name": "api",
"tags": ["scope:api", "type:app"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json"
}
},
"serve": {
"executor": "@nx/js:node",
"options": {
"buildTarget": "api:build",
"watch": true
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts"
}
}
}
}
Los outputs son críticos para la caché: Nx necesita saber qué carpetas/ficheros produce un target
para reutilizarlos. Si omites outputs, la caché no restaura artefactos aunque el cálculo de
hash diga «hit».
30.4.3 Package-based frente a integrated
| Aspecto | Integrated | Package-based |
|---|---|---|
| Modelo mental | Proyectos Nx con plugins y project graph rico | Paquetes npm/pnpm clásicos + Nx como orquestador |
| Configuración | project.json, plugins @nx/* | package.json por paquete, scripts estándar |
| Generators | Muy ricos (Angular, Nest, React…) | Más ligeros; tú montas más a mano |
| Ideal para | TaskFlow full-stack con Angular+Nest | Monorepos de librerías publicables, equipos ya en workspaces |
| Curva | Más conceptos Nx al principio | Más familiar si vienes de pnpm workspaces |
Para este libro recomendamos integrated en TaskFlow: los plugins de Angular y Nest ahorran semanas de configuración webpack/vite/Jest y alinean targets. Si tu monorepo es sobre todo paquetes publicados a npm sin apps Angular, package-based puede ser más honesto.
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": {
"cache": true
}
}
}
dependsOn: ["^build"] significa: antes de construir este proyecto, construye las
dependencias del grafo. Es lo que evita que web compile contra una lib desactualizada en
disco.
30.4.4 Generators propios: codificar el estándar del equipo
Cuando el equipo supera las tres personas, el problema deja de ser «saber usar Nx» y pasa a ser
«hacer siempre lo mismo». Si cada feature Angular se crea a mano, acabas con cuatro estilos de carpeta,
tags a medias y un index.ts que exporta el mundo. Un generator propio (o un wrapper documentado
sobre los generators oficiales) es la forma de convertir la convención en código.
Un generator mínimo para TaskFlow debería: crear la lib bajo libs/web/feature-*, asignar
tags scope:web y type:feature, generar un fichero de rutas exportado, un
componente contenedor y un stub de test, y registrar el importPath en
tsconfig.base.json. No necesita ser sofisticado el primer día: necesita ser la única forma
aceptable de crear features. El coste de mantener el generator se amortiza en el primer mes.
Un generator malo multiplica basura. Revisa lo que genera igual que revisas un PR humano. Versiona
el generator en tools/generators o como plugin local del workspace, y añade un test que
ejecute el schematic sobre un árbol virtual y compruebe tags y paths.
En entrevistas y en auditorías de arquitectura, una señal de madurez del monorepo es precisamente esa: no solo hay carpetas bonitas, hay un camino automatizado que hace difícil hacerlo mal. Nx brilla aquí porque el mismo ecosistema que ejecuta builds también ejecuta scaffolding; no dependes de un script bash frágil copiado en un wiki.
30.5 Grafo de dependencias, affected commands, caché local y remota
El project graph es el mapa de quién importa a quién. Nx lo calcula analizando
package.json, imports TypeScript y configuración de proyectos. Sin grafo fiable, ni
affected ni la caché merecen confianza.
npx nx graph
npx nx graph --file=graph.json # exportar para CI o auditorías
npx nx show project web --web # detalle de un proyecto
Cambio en libs/shared/dto
│
▼
┌─────────────┐
│ shared-dto │ ← affected directamente
└──────┬──────┘
│ lo consumen
┌──────┴───────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ web │ │ api │ ← affected transitivamente
│ (build, │ │ (build, │
│ test) │ │ test) │
└─────────┘ └─────────┘
libs/web/ui NO affected si no depende de dto
30.5.1 Comandos affected
nx affected compara el grafo con un rango de commits (por defecto, contra la base de la
rama) y ejecuta targets solo en los proyectos tocados o que dependen de lo tocado.
# Contra main (local)
npx nx affected -t lint,test,build --base=main --head=HEAD
# En GitHub Actions suele usarse la base del PR
npx nx affected -t lint,test,build \
--base=origin/main \
--head=HEAD \
--parallel=3
Si --base apunta a un commit equivocado (por ejemplo, el mismo HEAD), affected puede
reportar «nada que hacer» y saltarse tests. En CI, fija la base al branch de destino del PR o al SHA
del último commit verde de la rama principal. Documenta el criterio en el README del repo.
30.5.2 Caché local y remota
Cuando un target es cacheable, Nx calcula un hash a partir de: fuentes del proyecto (según
inputs), fuentes de dependencias, runtime (versión de Node, variables listadas), flags del
comando. Si el hash ya existe, restaura stdout/stderr y outputs sin reejecutar.
- Caché local: carpeta
.nx/cacheen la máquina. Acelera el ciclo del desarrollador y el re-run del mismo job en el mismo runner. - Caché remota (Nx Cloud u otro remote cache compatible): comparte hashes entre máquinas y entre CI y desarrolladores. El primer build en CI beneficia a todos los demás.
{ "targets": {
"build": {
"executor": "@nx/js:tsc",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json"
}
}
}
}
{ "targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json"
}
}
}
}
Sin outputs, un cache hit puede «tener éxito» y dejarte sin artefactos en
dist/. El síntoma clásico: el job de Docker no encuentra la carpeta compilada aunque Nx diga
que el build estaba cacheado.
{
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["production", "^production", { "env": "NODE_ENV" }]
}
}
}
Incluye en inputs solo lo que realmente cambia el resultado. Meter
{workspaceRoot}/**/* invalida la caché ante cualquier README tocado. Por eso existen
namedInputs de producción que excluyen specs.
30.5.3 Determinismo: la caché solo es segura si el target lo es
La caché de Nx asume que, con los mismos inputs, obtienes el mismo resultado. Si tus tests leen la
hora del sistema, escriben en una carpeta fuera de outputs, dependen del orden de un
readdir o fallan uno de cada veinte, la caché se convierte en una máquina de falsos verdes
o de misterios. Antes de activar remote cache en serio, haz una pasada de higiene:
- Tests sin reloj real: inyecta relojes o usa librerías de fake timers.
- Prohibido escribir en rutas absolutas del home del usuario dentro de un target cacheable.
- Semillas fijas en datos aleatorios de fixtures.
- Variables de entorno relevantes declaradas en
inputso normalizadas en CI (TZ=UTC,NODE_ENV=test).
Un truco de diagnóstico: ejecuta dos veces el mismo target con caché fría y caliente y compara no solo
el exit code, sino el artefacto (hash de dist/). Si el hash del output cambia sin cambiar
inputs, tienes no-determinismo. Encontrarlo pronto es más barato que perseguir un flaky en producción
atribuido «a Nx».
En equipos que vienen de polyrepo, la primera reacción ante un cache hit sospechoso es desactivar la
caché globalmente. Resiste esa tentación: desactívala solo en el target enfermo
("cache": false), corrige la causa, y vuelve a activarla. La caché es un multiplicador; el
determinismo es el prerequisito.
30.6 Librerías compartidas: tipos, DTOs, UI, data-access
El diseño de libs es donde se gana o se pierde el monorepo. Una taxonomía clara evita el antipatrón
«libs/shared/misc con 200 exports».
30.6.1 Taxonomía recomendada para TaskFlow
| Tipo | Contiene | Puede depender de | Ejemplo |
|---|---|---|---|
type:util / types | Tipos TS, enums, guards de tipo, constantes | Otras util compartidas | TaskStatus, Priority |
type:util / dto | Clases/interfaces de contrato HTTP + validación | types | CreateTaskDto, TaskDto |
type:data-access (api) | Entidades MikroORM, repositorios, módulos Nest | dto, types, util api | Task entity, TasksService |
type:data-access (web) | Servicios HTTP Angular, stores/signals de servidor | dto, types, ui (evitar) | TasksApi |
type:ui | Componentes presentacionales | util, types (no data-access) | TaskStatusBadge |
type:feature | Páginas/casos de uso que componen ui + data-access | ui, data-access, util | feature-task-list |
export const TASK_STATUSES = ['todo', 'doing', 'done', 'blocked'] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const PRIORITIES = ['low', 'medium', 'high'] as const;
export type Priority = (typeof PRIORITIES)[number];
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { Priority, PRIORITIES, TaskStatus, TASK_STATUSES } from '@taskflow/shared/types';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
@MaxLength(200)
title!: string;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
@IsEnum(TASK_STATUSES)
status: TaskStatus = 'todo';
@IsEnum(PRIORITIES)
priority: Priority = 'medium';
}
export class TaskDto {
id!: string;
title!: string;
description?: string;
status!: TaskStatus;
priority!: Priority;
projectId!: string;
createdAt!: string;
updatedAt!: string;
}
import { Entity, PrimaryKey, Property, Enum } from '@mikro-orm/core';
import { Priority, TaskStatus } from '@taskflow/shared/types';
import { randomUUID } from 'crypto';
@Entity({ tableName: 'tasks' })
export class Task {
@PrimaryKey({ type: 'uuid' })
id: string = randomUUID();
@Property({ length: 200 })
title!: string;
@Property({ type: 'text', nullable: true })
description?: string;
@Enum({ items: () => ['todo', 'doing', 'done', 'blocked'] })
status: TaskStatus = 'todo';
@Enum({ items: () => ['low', 'medium', 'high'] })
priority: Priority = 'medium';
@Property({ fieldName: 'project_id' })
projectId!: string;
@Property({ onCreate: () => new Date() })
createdAt: Date = new Date();
@Property({ onCreate: () => new Date(), onUpdate: () => new Date() })
updatedAt: Date = new Date();
}
Observa la separación: la entidad conoce MikroORM; el DTO conoce class-validator; ambos comparten solo los tipos primitivos del dominio. El mapeo entidad↔DTO vive en el servicio de aplicación Nest, no en shared.
import { Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import { CreateTaskDto, TaskDto } from '@taskflow/shared/dto';
import { Task } from './task.entity';
@Injectable()
export class TasksService {
constructor(private readonly em: EntityManager) {}
async create(dto: CreateTaskDto, projectId: string): Promise<TaskDto> {
const task = this.em.create(Task, { ...dto, projectId });
await this.em.persistAndFlush(task);
return this.toDto(task);
}
private toDto(task: Task): TaskDto {
return {
id: task.id,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
projectId: task.projectId,
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString(),
};
}
}
30.6.2 Reglas de importación (no circular, no app→app)
- Prohibido app → app. Si
webnecesita algo deapi, ese algo debe bajar a una lib (normalmente shared dto/types, o un contrato OpenAPI generado). - Prohibido ciclo lib A ↔ lib B. Extrae el trozo común a una tercera lib más baja
(
utilotypes). - UI no importa data-access. El componente presentacional recibe inputs/outputs; la feature cablea el servicio.
- data-access web no importa data-access api. El navegador no debe ver entidades ORM.
- shared no importa scope web ni api.
// libs/web/feature-tasks importa data-access
// y data-access importa un helper de feature-tasks
import { taskListColumns } from '@taskflow/web/feature-tasks';
import { TasksApi } from '@taskflow/web/data-access-tasks';
// Ciclo: feature → data-access → feature
// Extraer columnas a libs/web/ui o libs/web/util-tasks
import { taskListColumns } from '@taskflow/web/ui';
import { TasksApi } from '@taskflow/web/data-access-tasks';
// feature-tasks importa ambos; nadie importa feature desde abajo
Un index.ts que reexporta demasiado facilita ciclos invisibles. Prefiere barrels
estrechos por lib y, si hace falta, subpaths (@taskflow/shared/dto/tasks) en lugar de un
único megabarrel.
30.7 Angular + Nest en el mismo workspace: estructura TaskFlow
Esta es la estructura que recomendamos como punto de partida. No es dogma: es un mapa que escala hasta varios dominios sin obligarte a microfrontends.
taskflow/
├── apps/
│ ├── web/ # Angular (shell + rutas)
│ ├── web-e2e/ # Playwright/Cypress
│ ├── api/ # NestJS (main, AppModule)
│ └── api-e2e/
├── libs/
│ ├── shared/
│ │ ├── types/ # enums y tipos de dominio
│ │ ├── dto/ # contratos HTTP + class-validator
│ │ └── util/ # helpers puros (fechas, ids…)
│ ├── web/
│ │ ├── ui/ # design system ligero
│ │ ├── util-*/ # pipes, guards Angular reutilizables
│ │ ├── data-access-*/ # HttpClient + estado remoto
│ │ └── feature-*/ # rutas smart (listado, detalle…)
│ └── api/
│ ├── util-*/ # filtros, pipes Nest transversales
│ ├── data-access-*/ # entidades MikroORM + servicios
│ └── feature-*/ # módulos de dominio (TasksModule…)
├── tools/ # scripts, generators propios
├── nx.json
├── tsconfig.base.json
└── package.json
30.7.1 Apps delgadas
La app Nest debería limitarse a arranque, configuración y composición de módulos. La lógica vive en libs.
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { TasksFeatureModule } from '@taskflow/api/feature-tasks';
import { ProjectsFeatureModule } from '@taskflow/api/feature-projects';
import mikroOrmConfig from './mikro-orm.config';
@Module({
imports: [
MikroOrmModule.forRoot(mikroOrmConfig),
TasksFeatureModule,
ProjectsFeatureModule,
],
})
export class AppModule {}
import { Routes } from '@angular/router';
export const appRoutes: Routes = [
{
path: 'tasks',
loadChildren: () =>
import('@taskflow/web/feature-task-list').then((m) => m.TASK_LIST_ROUTES),
},
{
path: 'projects',
loadChildren: () =>
import('@taskflow/web/feature-projects').then((m) => m.PROJECT_ROUTES),
},
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
];
El lazy loading por feature lib permite que el grafo de Nx y el code-splitting de Angular cuenten la misma historia: una feature es una unidad de ownership, de test y de carga diferida.
30.7.2 Desarrollo local: un comando, dos procesos
En local conviene levantar API y web con un target compuesto, y un proxy en el dev-server de Angular hacia Nest para evitar CORS durante el desarrollo.
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}
{
"scripts": {
"web": "nx serve web",
"api": "nx serve api",
"dev": "nx run-many -t serve -p api,web --parallel=2",
"affected": "nx affected -t lint,test,build"
}
}
La API es la cocina; el frontend, la sala. Comparten la carta (DTOs) escrita una sola vez. No compartes los fogones (MikroORM) con los camareros (componentes Angular). El monorepo es el edificio; los tags son las puertas cortafuegos.
30.7.3 Ownership, CODEOWNERS y ritmo de PRs
El monorepo concentra el tráfico de pull requests. Sin reglas, libs/shared/dto se convierte
en el pasillo donde todo el mundo deja mudanzas. Define ownership explícito:
/apps/web/ @taskflow/frontend
/apps/api/ @taskflow/backend
/libs/web/ @taskflow/frontend
/libs/api/ @taskflow/backend
/libs/shared/ @taskflow/platform
/nx.json @taskflow/platform
/tsconfig.base.json @taskflow/platform
/.github/workflows/ @taskflow/platform
El equipo platform (aunque sean dos personas a media jornada) cuida tooling, boundaries y shared. Un cambio de DTO exige revisión de platform + del lado consumidor. Parece fricción; en realidad es el sustituto del versionado semver entre repos. Si shared cambia sin revisión, el monorepo solo acelera la propagación de errores.
Sobre el ritmo: prefiere PRs pequeños que toquen una vertical (feature + dto + test) frente a PRs «limpieza general» de veinte libs. Affected y los revisores humanos agradecen lo mismo. Si necesitas un refactor transversal (renombrar un enum), sepáralo de features nuevas y comunícalo: es el equivalente monorepo a un major bump.
30.7.4 Estrategia de tests en el monorepo
No todos los tests deben correr en todos los PRs. Una estratificación sana para TaskFlow:
- Unitarios por lib (Jest/Vitest): rápidos, cacheables, affected fino. Son la red principal.
- Tests de módulo Nest en libs
data-access/feature: suben confianza sin levantar el browser. - Tests de componente Angular en ui/feature: acotados; evita TestBed gigantes por PR.
- e2e (
web-e2e): solo si affected incluye web o api (o en nightly). Caros y frágiles; no son el primer filtro. - Contract smoke: un job que levanta api y valida OpenAPI/DTO básicos cuando cambia shared o api.
El error clásico es trasladar al monorepo la costumbre polyrepo de «la CI de mi repo corre mis e2e siempre». Multiplicado por N apps, el pipeline muere. Affected no es opcional a escala: es la única forma de mantener la promesa de feedback en minutos.
// En Angular: importar un servicio Nest por path relativo
import { TasksService } from
'../../../api/src/app/tasks/tasks.service';
import { TaskDto } from '@taskflow/shared/dto';
// Cada lado implementa su adaptador:
// Nest: TasksService + controlador
// Angular: TasksApi (HttpClient)
30.8 Module Federation / microfrontends con Nx
Nx ofrece soporte de primera clase para Module Federation (webpack y, en evolución, variantes con esbuild/rsbuild según versión del plugin). Permite que varias aplicaciones Angular se carguen en runtime como remotos de un shell (host).
30.8.1 Cuándo sí
- Varios equipos despliegan independientemente partes del frontend con calendarios distintos.
- El tamaño del bundle monolítico ya es un problema medido (no supuesto) y el lazy loading por rutas no basta porque los equipos necesitan pipelines separados.
- Hay un shell estable y remotos con contratos de versión explícitos.
30.8.2 Cuándo no
- TaskFlow con un solo equipo o dos personas: Module Federation añade versionado runtime, duplicación de dependencias compartidas y depuración más dura.
- Solo quieres compartir componentes: usa libs, no microfrontends.
- Solo quieres code-splitting:
loadChildrenen el router basta.
Puedes tener monorepo sin microfrontends (lo habitual en TaskFlow) y microfrontends sin monorepo (varios repos publicando remotos). Mezclar ambos sin necesidad es acumular las dos complejidades.
// En el shell (host): cargar un remoto en runtime
export const appRoutes: Routes = [
{
path: 'admin',
loadChildren: () =>
import('admin/Routes').then((m) => m.remoteRoutes),
},
];
// El nombre 'admin/Routes' lo resuelve Module Federation,
// no el bundler como un path TypeScript normal.
SIN MF (recomendado TaskFlow inicial) CON MF (equipos/deploy independientes)
┌────────────────────┐ ┌──────────┐
│ apps/web (único) │ │ host │
│ feature-tasks │ └────┬─────┘
│ feature-projects │ │ carga runtime
│ libs/web/* │ ┌────┴─────┬──────────┐
└────────────────────┘ ▼ ▼ ▼
remoto remoto remoto
tasks projects admin
Si más adelante TaskFlow crece a un módulo de administración desplegado por otro equipo, entonces evalúa MF. Hasta entonces, libs + lazy routes.
Un criterio de decisión práctico: escribe en una frase quién despliega qué y con qué frecuencia. Si la respuesta es «el mismo equipo, el mismo pipeline, la misma versión de Angular», Module Federation no te está comprando independencia real; solo te vende la ilusión. Si la respuesta es «el equipo de Admin despliega los jueves sin coordinar con Core, y aceptan versionar el contrato del shell», entonces el coste de MF (shared dependencies, debugging cross-app, contratos de semver en runtime) puede merecer la pena. Documenta ese contrato igual que documentarías una API HTTP: qué exporta el remoto, qué versiones del shell soporta y cómo se hace rollback si el remoto rompe el host.
En la práctica, muchos equipos llegan a MF demasiado pronto porque han leído que «microfrontends escalan organizaciones». La organización escala primero con libs, ownership y CI affected. MF es el último escalón, no el primero.
30.9 CI: affected, parallel, caching en GitHub Actions
El capítulo 21 cubrió pipelines genéricos. Aquí el matiz es Nx: el CI debe dejar de construir el mundo entero en cada push.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
main:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Derivar base de affected
id: base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "base=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
else
echo "base=HEAD~1" >> "$GITHUB_OUTPUT"
fi
- name: Lint, test y build afectados
run: |
pnpm exec nx affected -t lint,test,build \
--base=${{ steps.base.outputs.base }} \
--head=HEAD \
--parallel=3 \
--configuration=ci
Puntos críticos del workflow:
fetch-depth: 0: affected necesita historia Git para comparar. Un checkout superficial rompe el cálculo de la base.- Base distinta en PR y en push a main: en PR, el branch destino; en push, el commit anterior (o el último tag verde, según política).
--parallel: aprovecha CPU del runner; ajústalo al tamaño de la máquina (3–4 en runners estándar).- Caché de pnpm + caché de Nx: son capas distintas. La primera evita descargar node_modules; la segunda evita reejecutar builds/tests.
- name: Restaurar caché Nx
uses: actions/cache@v4
with:
path: .nx/cache
key: nx-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
nx-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nx-${{ runner.os }}-
# Alternativa más potente: Nx Cloud (remote cache distribuida)
# NX_CLOUD_ACCESS_TOKEN en secretos del repositorio
La caché de actions/cache es por job/runner y tiene límites de evicción. Nx Cloud (u
otro remote cache) comparte resultados entre PRs y desarrolladores con mayor hit rate. Para un equipo
pequeño, actions/cache sobre .nx/cache ya aporta mucho; mide antes de pagar.
# Lista proyectos affected y decide
pnpm exec nx show projects --affected --base=origin/main --head=HEAD | tee affected.txt
if grep -qE '^(web|web-e2e)$' affected.txt; then
pnpm exec nx run web-e2e:e2e
fi
- uses: actions/checkout@v4
# fetch-depth por defecto = 1
- run: npx nx affected -t test --base=main
# sin origin/main fetch → base incorrecta
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git fetch origin main:main
- run: pnpm exec nx affected -t test --base=main --head=HEAD
30.10 Versionado y publicación de libs
En TaskFlow, la mayoría de las libs no se publican a un registro npm: se consumen por path aliases dentro del workspace. Publicar solo tiene sentido cuando:
- Otro repositorio (cliente externo, app móvil, partner) debe consumir DTOs o SDK.
- Extraes un design system verdaderamente reutilizable fuera del producto.
Si publicas, trata esas libs como producto: semver, changelog, y no rompas consumidores con cambios silenciosos en el barrel.
# Independent versioning con cambios detectados por Conventional Commits
# (herramientas habituales: Nx Release, changesets, semantic-release)
pnpm exec nx release plan # o el flujo de tu herramienta
pnpm exec nx release # bump + changelog + publish
# Solo paquetes marcados como publishable
# package.json de la lib: "publishConfig": { "access": "restricted" }
{
"name": "@taskflow/shared-dto",
"version": "0.0.1",
"type": "commonjs",
"main": "./index.js",
"types": "./index.d.ts",
"publishConfig": {
"access": "restricted",
"registry": "https://npm.pkg.github.com"
}
}
Versionar libs internas «por si acaso» añade ceremonias (changelogs, bumps) sin consumidores externos. Dentro del monorepo, el versionado efectivo es el commit SHA. Reserva semver para lo que cruza la frontera del Git.
Para releases de las apps (web, api), el artefacto es la imagen Docker o el bundle estático
(capítulo 21), no un paquete npm. El monorepo puede etiquetar el repo entero
(taskflow-web@1.4.0) o usar un solo calendario de producto: elige una convención y no
mezcles las dos sin documentarlo.
30.11 Alternativas: Turborepo, pnpm workspaces, Yarn — comparación honesta
Nx no es la única forma de operar un monorepo. Elegir herramienta es elegir qué problemas quieres que resuelva el framework y cuáles asumes tú.
| Herramienta | Fortaleza | Debilidad | Encaja con TaskFlow si… |
|---|---|---|---|
| Nx | Plugins Angular/Nest, generators, boundaries, affected, caché, grafo | Más conceptos; acoplamiento a su modelo de proyectos | Quieres full-stack tipado con reglas de arquitectura |
| Turborepo | Orquestación y caché remota muy simples; poco «framework» | No genera apps Angular/Nest ni enforce boundaries por sí solo | Ya tienes workspaces y solo quieres pipelines rápidos |
| pnpm workspaces | Instalación excelente, enlazado de paquetes local, filtros | No calcula affected semántico ni generators de framework | Monorepo pequeño y disciplina manual alta |
| Yarn workspaces | Maduro, buenos workspaces; Yarn Berry con PnP | PnP complica a veces nativos/Nest; sin grafo de tasks rico | Equipo ya estandarizado en Yarn |
| Lerna (hoy a menudo + Nx) | Histórico en publicación multi-paquete | Solo ya no compite en build orchestration moderna | Legado; migrar en lugar de adoptar |
| Bazel | Hermeticidad y escala extrema | Curva y coste operativo altos | Empresa con equipo de build dedicado |
packages:
- 'apps/*'
- 'libs/*'
- 'libs/shared/*'
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {}
}
}
Con Turborepo + pnpm consigues caché y orden de tasks. Lo que no consigues «de serie» es: generar un
módulo Nest alineado, validar que scope:web no importa entidades ORM, ni un grafo visual del
mismo nivel. Puedes añadir ESLint boundaries a mano, pero entonces estás reconstruyendo parte de Nx.
TaskFlow Angular + Nest + MikroORM → Nx integrated. Si mañana solo mantienes paquetes TS publicados a npm sin apps, reevalúa package-based o Turborepo. No migres por moda: migra cuando el dolor (CI lento, imports ilegales, scaffolding inconsistente) esté medido.
Una trampa frecuente es adoptar Nx y seguir trabajando como si fuera un polyrepo: cada equipo solo
mira «su» app, nadie mantiene shared, y el grafo se pudre con dependencias fantasma. La herramienta no
impone la cultura; como mucho la hace visible. Programa una revisión trimestral del grafo
(nx graph), del número de ciclos detectados, del tiempo medio de CI y del porcentaje de
cache hits. Esas cuatro métricas te dicen si el monorepo está sano mejor que cualquier debate abstracto
sobre «si Nx merece la pena».
pnpm --filter @taskflow/api test
pnpm --filter "./libs/shared/**" lint
pnpm --filter "...@taskflow/web" build # web + dependencias
30.12 Errores comunes y cómo solucionarlos
| Síntoma | Causa real | Solución |
|---|---|---|
| CI dice «No projects were affected» y no testea nada | fetch-depth: 1 o --base mal elegido | Checkout completo; base = branch destino del PR |
Cache hit pero dist/ vacío | Falta outputs en el target | Declarar outputs; limpiar caché y reconstruir |
| ESLint no pilla import app→app | Sin tags o sin enforce-module-boundaries | Tags en todos los proyectos + regla en error |
| Ciclo de dependencias en el grafo | Libs que se importan mutuamente vía barrels | Extraer tipos/util comunes; barrels estrechos |
| Bundle de Angular incluye MikroORM | Import transitivo desde shared/api | Separar entidad de DTO; boundaries scope:web |
| Paths TS fallan en CI pero no en IDE | tsconfig.base.json distinto o lib sin build | Un solo base; dependsOn: ["^build"] |
nx serve lento al tocar una lib | Rebuild excesivo / dependency excess | Libs más pequeñas; build incremental; revisar inputs |
| Dos versiones de RxJS en el grafo | Dependencias duplicadas en package.json de libs | Dependencias de framework solo en raíz; peerDeps |
| Generator crea lib fuera de convención | Flags distintos por persona | Generator propio o documentación + schematic compartido |
| Publicar lib rompe consumidores internos | Versionar libs que solo usa el monorepo | No publicar; consumo por path |
| e2e siempre en rojo tras affected | Entorno (API) no levantado cuando solo web cambia | Job e2e con compose; o mock/contract tests |
«God lib» shared de 10k líneas | Sin taxonomía type/scope | Partir por dominio y por tipo (dto, ui, data-access) |
export * from './api/task.entity';
export * from './web/task-card.component';
export * from './dto/create-task.dto';
// Un solo barrel mezcla ORM, Angular y DTOs
// @taskflow/shared/dto
export * from './lib/create-task.dto';
// @taskflow/api/data-access-tasks
export * from './lib/task.entity';
// @taskflow/web/ui
export * from './lib/task-card.component';
{ "name": "@taskflow/web/ui",
"dependencies": {
"@angular/core": "19.0.0",
"rxjs": "7.8.1"
}
}
{ "name": "@taskflow/web/ui",
"peerDependencies": {
"@angular/core": "^19.0.0",
"rxjs": "^7.8.0"
}
}
30.13 Buenas y malas prácticas
Haz esto
- Apps delgadas que solo componen; lógica en libs.
- Tags scope + type en todos los proyectos desde el día uno.
- Boundaries en error, no en warning: el warning se ignora.
- DTOs y tipos en shared sin frameworks de UI ni ORM.
- affected en CI con historial Git completo.
- Declarar outputs en todo target cacheable.
- Un importPath por lib estable (
@taskflow/...). - Generators documentados (o custom) para no divergir.
- Medir el CI: tiempo, cache hit rate, proyectos affected medios.
- Lazy routes por feature lib alineadas con ownership.
Evita esto
- Importar app desde app o desde deep paths internos.
- Una lib «shared/misc» que crece sin frontera.
- Entidades MikroORM en el frontend «porque TypeScript deja».
- Desactivar boundaries para salir del paso en un PR.
- Module Federation sin equipos ni deploys independientes.
- Publicar todas las libs al registro interno sin consumidores.
- CI que siempre hace run-many sobre 40 proyectos.
- Duplicar Angular/Nest en dependencies de cada lib.
- Commits gigantes que tocan 15 libs sin necesidad: afecta a half CI.
- Documentación oral de la estructura: el siguiente fichaje no la adivina.
El grafo de Nx es el plano del metro: te dice qué líneas conectan. Affected es «solo revisamos estaciones aguas abajo del tramo en obras». La caché es el abono: si el trayecto no cambió, no pagas de nuevo. Los tags son zonas tarifarias: sin ellas, cualquiera se cuela en cualquier andén.
30.14 Preguntas frecuentes
¿Monorepo implica que todo el mundo puede tocar todo el código?
¿Puedo empezar TaskFlow en polyrepo y migrar después a Nx?
¿Qué diferencia hay entre nx run-many y nx affected?
run-many ejecuta un target en todos los proyectos (o en una lista
-p) sin mirar Git. Es útil en nightly builds, releases o cuando quieres validar el repo
entero. affected calcula el subgrafo tocado por un rango de commits y ejecuta solo ahí. En
PRs diarios quieres affected; en main tras un merge grande, a veces conviene un run-many de verificación
completa semanal.¿Dónde pongo la configuración de MikroORM: app o lib?
data-access-*. El MikroOrmModule.forRoot se
declara en AppModule (o en una lib api/util-orm importada solo por apps api),
listando las entidades exportadas por las libs de dominio. Nunca exportes el config con secretos desde
shared.¿Los DTOs con class-validator ensucian el frontend?
TaskDto (interface/tipo) de CreateTaskDto (clase con
validadores) en ficheros distintos y que el web importe solo tipos
(import type); o generar tipos desde OpenAPI. Para TaskFlow, import type +
boundaries suele bastar si eres disciplinado.¿Nx sustituye a Jest, Playwright o ESLint?
¿Cómo evito que un cambio en shared tumbe CI media hora?
¿Package-based es «Nx light»?
package.json como fuente de verdad, más
parecido a pnpm/turbo. Sigue teniendo grafo, caché y affected, pero menos magia de plugins de aplicación.
Para TaskFlow full-stack, integrated te ahorra más. Para un monorepo de diez librerías publicables sin
Angular, package-based es más honesto y menos ceremonioso.¿Puedo mezclar React y Angular en el mismo workspace Nx?
¿Qué hago con secretos y .env en el monorepo?
.env.example en la raíz o por app; valores reales en el gestor de secretos CI y en el entorno
de despliegue. Cuidado con la caché: si un build embebe variables, decláralas en inputs o el
hash será incorrecto. Mejor: builds sin secretos y configuración en runtime para la API.¿Module Federation y lazy loading de Angular son lo mismo?
¿Cómo debugueo un fallo de enforce-module-boundaries?
nx graph y localiza la arista. Pregunta: ¿falta un tag? ¿la dependencia es legítima y hay que
relajar una constraint? ¿o el código debería moverse a otra lib? Relajar la regla «solo esta vez» es deuda.
Mover el símbolo a scope:shared o a una lib de tipo permitido es la salida limpia.¿Vale la pena Nx Cloud en un equipo de tres personas?
actions/cache. Si el CI tarda
menos de cinco minutos y los developers no se bloquean, quizá no. Si cada PR reconstruye Angular y Nest
desde cero en runners fríos, el remote cache suele pagarse solo en tiempo. Tres personas que empujan a
menudo generan más reconstrucciones redundantes de lo que parece.¿Cómo conviven OpenAPI/Swagger y DTOs compartidos?
¿Qué tamaño máximo debe tener una lib?
30.15 Ejercicios
30.1 Crea un workspace Nx con preset apps, añade plugins Angular y Nest, y genera
apps/web y apps/api. Captura la salida de nx show projects y
explica qué tags tienen por defecto (si alguno).
30.2 Genera libs/shared/types y libs/shared/dto con
importPath @taskflow/shared/types y @taskflow/shared/dto. Define
TaskStatus y CreateTaskDto. Importa el DTO desde un controlador Nest y desde un
servicio Angular.
30.3 Activa @nx/enforce-module-boundaries con constraints
scope:web / scope:api / scope:shared. Provoca a propósito un
import ilegal y pega el error de ESLint en tu informe.
30.4 Dibuja a mano (o con nx graph --file) el grafo tras añadir una lib
web/ui usada solo por web. Marca qué nodos se afectarían al cambiar
shared/dto.
30.5 Implementa TasksService en una lib Nest api/data-access-tasks
con entidad MikroORM y mapeo a TaskDto. La app api solo importa el feature
module.
30.6 Configura proxy.conf.json y un script pnpm dev que levante api y
web en paralelo. Demuestra una petición POST /api/tasks desde el frontend tipada con el
DTO compartido.
30.7 Escribe un workflow de GitHub Actions con fetch-depth: 0, pnpm, y
nx affected -t lint,test,build. Abre dos PRs: uno que solo toque README y otro que toque
un DTO; compara qué proyectos ejecuta cada uno.
30.8 Rompe deliberadamente la caché omitiendo outputs en el build de
api, observa el síntoma, corrígelo y documenta el antes/después del contenido de
dist/ tras un cache hit.
30.9 Diseña e implementa la taxonomía completa de tags (scope +
type) para al menos seis libs. Añade constraints para que type:ui no pueda
depender de type:data-access. Incluye un test de lint que falle si se viola.
30.10 Extrae un generator propio (schematic Nx) que cree una feature Angular con la estructura
TaskFlow (routes, component, data-access stub) y tags correctos. Úsalo para crear
feature-projects.
30.11 Compara en un documento de una página Nx affected + cache frente a
pnpm -r / Turborepo en el mismo repo (puedes simular scripts equivalentes). Mide tiempos en
frío y en caliente tras un cambio en shared/types.
30.12 Propón (sin implementarlo en producción) un diseño Module Federation para un futuro
admin remoto. Lista riesgos, contratos de versión y por qué hoy no lo activarías en
TaskFlow. Alternativa: implementa solo el shell host en local como spike de 2 horas.
Solución comentada · 30.3 · Boundaries que fallan en rojo
El objetivo no es «tener la regla», sino demostrar que el CI/local rechaza un import ilegal. Tras etiquetar proyectos, una constraint mínima:
depConstraints: [
{
sourceTag: 'scope:web',
onlyDependOnLibsWithTags: ['scope:web', 'scope:shared'],
},
{
sourceTag: 'scope:api',
onlyDependOnLibsWithTags: ['scope:api', 'scope:shared'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
]
Desde un fichero de apps/web importa algo de @taskflow/api/data-access-tasks.
Al ejecutar nx run web:lint debes ver un error de
@nx/enforce-module-boundaries citando tags. Si no aparece: (1) la lib api no tiene tag
scope:api; (2) el fichero no entra en el lint de web; (3) la regla está en warn. Corrige
hasta que sea error reproducible. Luego elimina el import ilegal: el ejercicio deja el repo verde.
Solución comentada · 30.7 · Affected en GitHub Actions
Lo que más falla en la práctica es el historial Git. El esqueleto válido:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: affected
run: |
BASE="origin/${{ github.base_ref }}"
if [ "${{ github.event_name }}" = "push" ]; then BASE="HEAD~1"; fi
pnpm exec nx affected -t lint,test,build --base="$BASE" --head=HEAD --parallel=3
PR solo README: affected debería listar cero proyectos de build (o solo tooling si el README está en
inputs globales; ajústa namedInputs si el README invalida todo). PR que cambia un DTO:
deben aparecer shared-dto, api, web y cualquier lib intermedia.
Si el PR del DTO no afecta a web, tu grafo no tiene la dependencia de import: revisa que el frontend
importe el DTO de verdad, no una interface duplicada.
Solución comentada · 30.5 · data-access Nest desacoplado
Estructura objetivo:
// libs/api/data-access-tasks/src/lib/tasks.module.ts
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Task } from './task.entity';
import { TasksService } from './tasks.service';
@Module({
imports: [MikroOrmModule.forFeature([Task])],
providers: [TasksService],
exports: [TasksService, MikroOrmModule],
})
export class TasksDataAccessModule {}
// libs/api/feature-tasks/src/lib/tasks.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateTaskDto, TaskDto } from '@taskflow/shared/dto';
import { TasksService } from '@taskflow/api/data-access-tasks';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasks: TasksService) {}
@Post()
create(@Body() dto: CreateTaskDto): Promise<TaskDto> {
return this.tasks.create(dto, dto.projectId ?? 'default');
}
}
La app solo importa TasksFeatureModule. Si alguien intenta importar
TasksController desde Angular, boundaries debe impedirlo. El mapeo entidad→DTO permanece
en el servicio: shared no conoce MikroORM. Añade un test de módulo Nest que inserte una tarea en SQLite
en memoria o en Postgres de CI (capítulo 13) para cerrar el círculo.
30.16 Resumen del capítulo
- Un monorepo serio no es «varias carpetas en Git»: es grafo, límites, ejecución selectiva y caché. Su premio mayor es el cambio atómico de contratos compartidos.
- Polyrepo versiona contratos internos como APIs públicas y paga coordinación; tiene sentido con ownership, secretos o ciclos de release radicalmente distintos.
- Nx aporta generators, executors, project graph, tags/boundaries, affected y caché. Se apoya en pnpm/npm/yarn, no los sustituye.
- Apps vs libs: las apps se despliegan; las libs encapsulan. Prohibido app→app y ciclos.
- Taxonomía TaskFlow:
shared/{types,dto,util},web/{ui,data-access,feature},api/{data-access,feature}. Shared sin Angular ni MikroORM. - Tags + enforce-module-boundaries convierten la arquitectura en error de lint reproducible.
- Affected + outputs + inputs son el truco del CI rápido;
fetch-depth: 0es obligatorio. - Module Federation solo con equipos y deploys independientes; si no, lazy routes + libs.
- Publica libs solo con consumidores fuera del repo; dentro, el versionado es el commit.
- Turborepo/pnpm orquestan bien; Nx gana cuando quieres plugins Angular/Nest y boundaries de verdad. Elige por dolor medido, no por moda.
- Contratos compartidos (DTO, tipos de tarea/proyecto) viven en libs sin UI ni ORM: un solo cambio de campo actualiza clientes y servidores en el mismo PR.
- CI verde rápido no es vanidad: es lo que permite exigir affected en cada PR sin que el equipo desactive la calidad «porque tarda media hora».
Un desarrollador puede añadir un campo a CrearTareaDto, regenerar o actualizar el cliente HTTP, ajustar la entidad MikroORM y el formulario Angular en un único PR, con lint de boundaries en verde y CI que solo construye web, api y las libs tocadas. Si para eso hace falta coordinar tres repositorios y un calendario de versiones, el monorepo (o la disciplina de contratos) aún no está haciendo su trabajo.
Evita dos antipatrones tempranos. El primero es la carpeta shared-everything donde acaba el código que nadie sabe dónde poner: en seis meses es un grafo circular con olor a aplicación disfrazada de librería. El segundo es copiar DTOs «por si acaso» en web y api «para no acoplar»: has recreado el polyrepo dentro del monorepo. La regla práctica: si el cambio debe ser atómico, es una lib compartida; si el ciclo de vida es independiente de verdad, es otro deployable (app) con contrato versionado.
Cuando el equipo crezca, documenta el mapa de tags en el README del workspace y añade un diagrama del project graph generado por nx graph en la wiki interna. Las reglas de boundary que no se entienden se desactivan; las que se enseñan en el onboarding se respetan. Nx no sustituye arquitectura: la hace verificable.
En entrevistas, explica el trade-off en una frase: «pagamos complejidad de tooling para ganar atomicidad de cambios y CI selectivo». Luego da un ejemplo con TaskFlow (shared DTO + affected). Si solo dices «usamos Nx porque está de moda», pierdes la pregunta. Si puedes dibujar el grafo apps/libs y decir qué tag impide que web-ui importe MikroORM, demuestras criterio de arquitectura frontend/backend en monorepo.
Relaciona este capítulo con Docker/CI (21), integración full-stack (18) y migraciones (31): un monorepo bien cacheado hace barato el tren de actualizaciones porque el coste de verificar el impacto está acotado. Un polyrepo sin versionado interno cuidadoso hace cada major update un proyecto de sincronización humana.
30.17 Recursos adicionales
- Nx · Getting Started — punto de entrada oficial al modelo de workspace.
- Nx · Enforce Module Boundaries — tags, constraints y deep imports.
- Nx · Affected — cálculo de proyectos afectados y uso en CI.
- Nx · Remote Cache — caché distribuida y buenas prácticas de inputs/outputs.
- Nx · Angular plugin — generators y executors Angular.
- Nx · Nest plugin — applications y libraries NestJS.
- Nx · Integrated vs Package-based — cuándo elegir cada estilo de workspace.
- Nx · Module Federation recipes — host, remotes y microfrontends.
- Nx · Generators — scaffolding y generators personalizados.
- Nx · CI setup — plantillas y recomendaciones oficiales de pipeline.
- Turborepo docs — alternativa de orquestación ligera.
- pnpm workspaces — workspaces y filtros.
Con monorepo y CI affected, el cuello de botella vuelve al diseño de módulos y al despliegue. Revisa
el capítulo 21 para empaquetar api y web en imágenes multi-stage desde el
mismo repo, y el capítulo 9 para mantener los módulos Nest tan delimitados como tus libs Nx.